From 5b4cb3dd4d61538caf1449eddd82f02d6970fe48 Mon Sep 17 00:00:00 2001 From: turtle1999 Date: Mon, 12 Oct 2026 19:28:12 +0800 Subject: [PATCH 01/35] fix(plugin-manager): drop profile switches the bundles already imply --- packages/boot/plugin-manager/README.i18n.yaml | 4 +- packages/boot/plugin-manager/README.md | 2 +- packages/boot/plugin-manager/README.zh.md | 2 +- packages/boot/plugin-manager/src/index.ts | 12 +-- packages/boot/plugin-manager/src/patch.ts | 46 ++++++++++-- .../boot/plugin-manager/tests/manager.spec.ts | 10 ++- .../boot/plugin-manager/tests/patch.spec.ts | 73 +++++++++++++++---- .../client/ui-plugin-manager/README.i18n.yaml | 4 +- packages/client/ui-plugin-manager/README.md | 2 +- .../client/ui-plugin-manager/README.zh.md | 2 +- 10 files changed, 118 insertions(+), 39 deletions(-) diff --git a/packages/boot/plugin-manager/README.i18n.yaml b/packages/boot/plugin-manager/README.i18n.yaml index 8c7be9fe6f..430a5e215b 100644 --- a/packages/boot/plugin-manager/README.i18n.yaml +++ b/packages/boot/plugin-manager/README.i18n.yaml @@ -15,8 +15,8 @@ en: 87679c51aa3693f5 zh: 11684a48a5806afb /deepseek-ai-dsh-plugin-manager/use-this-package: - en: 4eef24eeb95ebb30 - zh: 99fb6a7e4e3f759f + en: b0c68b304e1b0040 + zh: af0f4b659971d91d /deepseek-ai-dsh-plugin-manager/use-this-package/version-compatibility-and-exemptions: en: 920c0acb3b9feaf3 zh: ba6e832a256880a7 diff --git a/packages/boot/plugin-manager/README.md b/packages/boot/plugin-manager/README.md index 1a2d7a023f..89c58803e3 100644 --- a/packages/boot/plugin-manager/README.md +++ b/packages/boot/plugin-manager/README.md @@ -37,7 +37,7 @@ For a deployment without agent presets, enable the tool in the profile patch. Pr disabled: false ``` -A plugin toggle updates only `disabled` in the last matching override in the profile's `cordis.patch.yml`, or appends an override when none matches. Matching uses the entry id and any module-name assertion. A bundle toggle changes `package.json`'s ordered `dsh.profile.bundles` list. Disabling retains the dependency; enabling appends the bundle at the end, which can change configuration precedence. Installation enables a new bundle by default. Home and invocation patches retain their higher priority. +A plugin toggle updates only `disabled` in the last matching override in the profile's `cordis.patch.yml`, or appends an override when none matches. Matching uses the entry id and any module-name assertion. An override holding only `id` and `disabled` is removed, and none is appended, when the bundle layers and the remaining profile patches already give the entry the requested state, so the profile patch records only departures from the bundles. A bundle toggle changes `package.json`'s ordered `dsh.profile.bundles` list. Disabling retains the dependency; enabling appends the bundle at the end, which can change configuration precedence. Installation enables a new bundle by default. Home and invocation patches retain their higher priority. A selected bundle that cannot load remains in `listBundles` with an `error`; `enabled` records the saved selection, not successful loading. The plugin page shows the error and allows deselection. A broken bundle cannot be enabled. Management bundles remain protected if their files become unreadable. diff --git a/packages/boot/plugin-manager/README.zh.md b/packages/boot/plugin-manager/README.zh.md index aa7bdb7df2..8e6b7ec952 100644 --- a/packages/boot/plugin-manager/README.zh.md +++ b/packages/boot/plugin-manager/README.zh.md @@ -37,7 +37,7 @@ kind: "package-reference" disabled: false ``` -插件开关只更新 profile 的 `cordis.patch.yml` 中最后一条匹配覆盖项的 `disabled`;没有匹配项时追加。匹配依据是条目 id,以及覆盖项声明的模块名称。组合包开关修改 `package.json` 的有序 `dsh.profile.bundles` 列表。关闭保留依赖;开启追加到列表末尾,可能改变配置优先级。安装新组合包默认启用。home 和单次启动 patch 保留更高优先级。 +插件开关只更新 profile 的 `cordis.patch.yml` 中最后一条匹配覆盖项的 `disabled`;没有匹配项时追加。匹配依据是条目 id,以及覆盖项声明的模块名称。如果组合包层和其余 profile patch 已使条目处于所请求的状态,仅含 `id` 与 `disabled` 的覆盖项会被移除,也不会追加覆盖项,因此 profile patch 只记录与组合包不同的设置。组合包开关修改 `package.json` 的有序 `dsh.profile.bundles` 列表。关闭保留依赖;开启追加到列表末尾,可能改变配置优先级。安装新组合包默认启用。home 和单次启动 patch 保留更高优先级。 已选择但无法加载的组合包仍会出现在 `listBundles` 中,并携带 `error`;`enabled` 表示保存的选择,不代表加载成功。插件页面显示错误并允许取消选择。损坏的组合包无法启用。管理组合包的文件变得不可读后仍受保护。 diff --git a/packages/boot/plugin-manager/src/index.ts b/packages/boot/plugin-manager/src/index.ts index cc4db943eb..c0066f38b8 100644 --- a/packages/boot/plugin-manager/src/index.ts +++ b/packages/boot/plugin-manager/src/index.ts @@ -13,7 +13,7 @@ import { TypertRemoteService, Remote } from '@deepseek-ai/dsh-typert-protocol' import { pluginEntryId, readPluginInventory } from '@deepseek-ai/dsh-host-plugin-inventory' import { readPluginMeta, readProfileManifest, resolveBundleDir, loadOverlayPatches, composeEntries, - reconcileProfilePatches, readProfilePatches, OPTIONAL_BUNDLES, bundlePatchPaths, + reconcileProfilePatches, readProfilePatches, loadProfileDirectory, OPTIONAL_BUNDLES, bundlePatchPaths, evaluatePluginCompatibility, readProfileCompatibility, readProfileVersionExemptions, setProfileVersionExemption, PROFILE_COMPATIBILITY_FILENAME, OFFICIAL_ON_DEMAND_CATALOG, getDshRuntimeVersion, @@ -25,7 +25,7 @@ import { classifyInstallFailure } from './install-failure.ts' import { officialBundleInstallTarget } from './official-install-target.ts' import { dependencySpec, InvalidInstallSpecError, parseInstallSpec, type ParsedInstallSpec } from './install-spec.ts' import { attributeFailure, normalizeRegistry, NPMMIRROR_REGISTRY, registryPlan } from './registry.ts' -import { writePluginEnabled } from './patch.ts' +import { flatten, writePluginEnabled } from './patch.ts' import { incompatiblePlugin, ManagementFailure } from './failure.ts' import { approveBuilds, recordPendingBuilds } from './build-approval.ts' import { checkGithubConnection } from './github-connection.ts' @@ -83,11 +83,6 @@ const RESTORED_FILES = ['package.json', 'pnpm-lock.yaml'] as const /** pnpm's colour escapes, which a JSON answer may be wrapped in. */ const ANSI_SEQUENCE = /\x1b\[[0-9;]*m/g -/** Flatten only the groups addressable by the profile's patch composer. */ -function flatten(rows: EntryOptions[]): EntryOptions[] { - return rows.flatMap(row => [row, ...(row.group && Array.isArray(row.config) ? flatten(row.config as EntryOptions[]) : [])]) -} - /** Preserve the exact observed diagnostic, including non-Error failures. */ function messageOf(error: unknown): string { return error instanceof Error ? error.message : String(error) } @@ -460,7 +455,8 @@ export class PluginManager extends TypertRemoteService { const row = (await this.listPlugins()).find(item => item.entryId === id) if (row === undefined) throw new ManagementFailure('unknown-plugin') if (row.readOnlyReason !== undefined) throw new ManagementFailure(row.readOnlyReason) - await writePluginEnabled(this.profile.patchPath, row.patchId, row.moduleName, enabled) + const { layers } = loadProfileDirectory('dsh', this.profile.dir, this.profile.installAnchor, { userLayer: false }) + await writePluginEnabled(this.profile.patchPath, row.patchId, row.moduleName, enabled, layers.flatMap(layer => layer.patches)) result.warnings = await this.reload(enabled ? [row.patchId] : []) const current = (await this.listPlugins()).find(item => item.entryId === id) return current?.enabled !== enabled && this.ownerContext.get('hmr') !== undefined ? 'overridden' : undefined diff --git a/packages/boot/plugin-manager/src/patch.ts b/packages/boot/plugin-manager/src/patch.ts index 5776b586fa..2906a4eaad 100644 --- a/packages/boot/plugin-manager/src/patch.ts +++ b/packages/boot/plugin-manager/src/patch.ts @@ -1,17 +1,32 @@ /** Comment-preserving profile plugin enablement edits. */ import { readFile } from 'node:fs/promises' -import { isMap, isSeq, parseDocument } from 'yaml' -import { loadOptionalPatches } from '@deepseek-ai/dsh-app-boot' +import { isMap, isNode, isScalar, isSeq, parseDocument } from 'yaml' +import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader' +import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include' +import { composeEntries, loadOptionalPatches } from '@deepseek-ai/dsh-app-boot' import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write' -/** Replace the last matching override or append one after existing insertions. +/** Flatten only the groups addressable by the profile's patch composer. + * @param rows Composed top-level entries. + * @returns Entries in depth-first order. + */ +export function flatten(rows: EntryOptions[]): EntryOptions[] { + return rows.flatMap(row => [row, ...(row.group && Array.isArray(row.config) ? flatten(row.config as EntryOptions[]) : [])]) +} + +/** Replace the last matching override or append one after existing insertions. An override that holds only `id` and + * `disabled` is removed, or not appended, when the bundle layers and the remaining profile patches already give the + * entry the requested enablement. * @param filename Current profile patch file. * @param id Unique composition entry id. * @param name Module name used to match name-qualified overrides. * @param enabled Desired entry enablement. + * @param layers Bundle patches applied before the profile patch file. * @returns Whether the file changed. */ -export async function writePluginEnabled(filename: string, id: string, name: string, enabled: boolean): Promise { +export async function writePluginEnabled( + filename: string, id: string, name: string, enabled: boolean, layers: readonly PatchOptions[], +): Promise { let text: string try { text = await readFile(filename, 'utf8') @@ -25,17 +40,34 @@ export async function writePluginEnabled(filename: string, id: string, name: str const error = document.errors[0] if (error !== undefined) throw error if (!isSeq(document.contents)) throw new Error('Profile patch must be a YAML sequence') - loadOptionalPatches('dsh', filename) + const patches = loadOptionalPatches('dsh', filename) ?? [] const items = document.contents.items const target = items.findLast((item, index) => { if (!isMap(item) || document.getIn([index, 'id']) !== id || item.has('insert')) return false const expectedName = document.getIn([index, 'name']) return !expectedName || expectedName === name }) + const inherits = (index: number): boolean => { + const row = flatten(composeEntries([[...layers, ...patches.toSpliced(index, 1)]])).find(entry => entry.id === id) + return row !== undefined && (row.disabled ?? false) === !enabled + } if (isMap(target)) { - if (document.getIn([items.indexOf(target), 'disabled']) === !enabled) return false - document.setIn([items.indexOf(target), 'disabled'], !enabled) + const index = items.indexOf(target) + const onlySwitch = target.items.every(pair => isScalar(pair.key) && (pair.key.value === 'id' || pair.key.value === 'disabled')) + if (onlySwitch && inherits(index)) { + document.deleteIn([index]) + // The comment above the removed override can be the file header, so it moves to the following node. + const comment = target.commentBefore + const next = items[index] + if (comment && isNode(next)) next.commentBefore = next.commentBefore ? `${comment}\n${next.commentBefore}` : comment + else if (comment) document.comment = document.comment ? `${comment}\n${document.comment}` : comment + } else if (document.getIn([index, 'disabled']) === !enabled) { + return false + } else { + document.setIn([index, 'disabled'], !enabled) + } } else { + if (inherits(items.length)) return false document.add({ id, disabled: !enabled }) } await writeFileAtomic(filename, String(document), { mode: 0o600 }) diff --git a/packages/boot/plugin-manager/tests/manager.spec.ts b/packages/boot/plugin-manager/tests/manager.spec.ts index be914515fd..9ccccb7608 100644 --- a/packages/boot/plugin-manager/tests/manager.spec.ts +++ b/packages/boot/plugin-manager/tests/manager.spec.ts @@ -458,14 +458,16 @@ it.each([ }) }) -it('turns a plugin off and on without duplicating patch overrides', async () => { +it('turns a plugin off and on without leaving a patch override the bundle already implies', async () => { const { manager, dir } = await fixture() const id = (await manager.listPlugins()).find(row => row.patchId === 'managed')!.entryId expect(await manager.setPluginEnabled(id, false)).toMatchObject({ changed: true, application: 'applied' }) expect((await manager.listPlugins()).find(row => row.entryId === id)?.enabled).toBe(false) expect(await manager.setPluginEnabled(id, false)).toMatchObject({ changed: false, application: 'applied' }) - expect(await manager.setPluginEnabled(id, true)).toMatchObject({ changed: true, application: 'applied' }) expect(readFileSync(join(dir, 'cordis.patch.yml'), 'utf8').match(/id: managed/g)).toHaveLength(1) + expect(await manager.setPluginEnabled(id, true)).toMatchObject({ changed: true, application: 'applied' }) + expect((await manager.listPlugins()).find(row => row.entryId === id)?.enabled).toBe(true) + expect(readFileSync(join(dir, 'cordis.patch.yml'), 'utf8')).not.toContain('id: managed') }) it('retains installed dependencies when toggling a bundle and appends it when re-enabled', async () => { @@ -481,9 +483,11 @@ it('retains installed dependencies when toggling a bundle and appends it when re }) it('reports an overlay overriding a saved plugin toggle', async () => { - const { manager } = await fixture('live', true) + const { manager, dir } = await fixture('live', true) + writeFileSync(join(dir, 'cordis.patch.yml'), '- id: managed\n disabled: true\n') const id = (await manager.listPlugins()).find(row => row.patchId === 'managed')!.entryId expect(await manager.setPluginEnabled(id, true)).toMatchObject({ changed: true, application: 'overridden' }) + expect(await manager.setPluginEnabled(id, true)).toMatchObject({ changed: false, application: 'overridden' }) }) it('saves startup-only toggles and refuses removal of currently used packages', async () => { diff --git a/packages/boot/plugin-manager/tests/patch.spec.ts b/packages/boot/plugin-manager/tests/patch.spec.ts index 30bb947c84..fc313a1398 100644 --- a/packages/boot/plugin-manager/tests/patch.spec.ts +++ b/packages/boot/plugin-manager/tests/patch.spec.ts @@ -17,7 +17,7 @@ async function fixture(text?: string): Promise { it('preserves comments, expressions, unrelated configuration and the last override', async () => { const file = await fixture('# personal configuration\n- id: tool\n config:\n value: !!js process.platform\n- id: tool\n disabled: false # availability\n') - expect(await writePluginEnabled(file, 'tool', 'package', false)).toBe(true) + expect(await writePluginEnabled(file, 'tool', 'package', false, [])).toBe(true) const text = await readFile(file, 'utf8') expect(text).toContain('# personal configuration') expect(text).toContain('!!js process.platform') @@ -25,47 +25,47 @@ it('preserves comments, expressions, unrelated configuration and the last overri expect(loadOptionalPatches('test', file)).toEqual([ { id: 'tool', config: { value: { __jsExpr: 'process.platform' } } }, { id: 'tool', disabled: true }, ]) - expect(await writePluginEnabled(file, 'tool', 'package', false)).toBe(false) + expect(await writePluginEnabled(file, 'tool', 'package', false, [])).toBe(false) expect(await readFile(file, 'utf8')).toBe(text) - expect(await writePluginEnabled(file, 'tool', 'package', true)).toBe(true) + expect(await writePluginEnabled(file, 'tool', 'package', true, [])).toBe(true) }) it('creates a missing patch file and appends after insertions', async () => { const file = await fixture() - await writePluginEnabled(file, 'tool', 'package', false) + await writePluginEnabled(file, 'tool', 'package', false, []) expect(loadOptionalPatches('test', file)).toEqual([{ id: 'tool', disabled: true }]) await writeFile(file, '- insert:\n - id: tool\n name: package\n') - await writePluginEnabled(file, 'tool', 'package', true) + await writePluginEnabled(file, 'tool', 'package', false, []) expect(loadOptionalPatches('test', file)).toEqual([ - { insert: [{ id: 'tool', name: 'package' }] }, { id: 'tool', disabled: false }, + { insert: [{ id: 'tool', name: 'package' }] }, { id: 'tool', disabled: true }, ]) }) it.each(['- id: [broken', 'mapping: true\n'])('refuses malformed documents without overwriting %s', async (text) => { const file = await fixture(text) - await expect(writePluginEnabled(file, 'tool', 'package', true)).rejects.toThrow() + await expect(writePluginEnabled(file, 'tool', 'package', true, [])).rejects.toThrow() expect(await readFile(file, 'utf8')).toBe(text) }) it('retains name-asserting overrides and appends an unambiguous switch', async () => { const file = await fixture('- id: tool\n name: another-package\n disabled: false\n') - await writePluginEnabled(file, 'tool', 'package', false) + await writePluginEnabled(file, 'tool', 'package', false, []) expect(loadOptionalPatches('test', file)).toEqual([ { id: 'tool', name: 'another-package', disabled: false }, { id: 'tool', disabled: true }, ]) - expect(await writePluginEnabled(file, 'tool', 'package', false)).toBe(false) + expect(await writePluginEnabled(file, 'tool', 'package', false, [])).toBe(false) }) it('reports read failures without replacing a directory with configuration', async () => { const file = await fixture() await mkdir(file) - await expect(writePluginEnabled(file, 'tool', 'package', true)).rejects.toThrow() + await expect(writePluginEnabled(file, 'tool', 'package', true, [])).rejects.toThrow() }) it('updates the last matching named override and leaves mismatched names untouched', async () => { const file = await fixture('- id: tool\n disabled: true\n- id: tool\n name: package\n config:\n value: !!js process.platform\n disabled: true # availability\n- id: tool\n name: another-package\n disabled: true\n') - expect(await writePluginEnabled(file, 'tool', 'package', true)).toBe(true) + expect(await writePluginEnabled(file, 'tool', 'package', true, [])).toBe(true) const text = await readFile(file, 'utf8') expect(text).toContain('!!js process.platform') expect(text).toContain('# availability') @@ -77,8 +77,55 @@ it('updates the last matching named override and leaves mismatched names untouch ]) expect(applyEntryPatches([{ id: 'tool', name: 'package' }], patches, () => {})) .toMatchObject([{ id: 'tool', name: 'package', disabled: false }]) - expect(await writePluginEnabled(file, 'tool', 'package', true)).toBe(false) + expect(await writePluginEnabled(file, 'tool', 'package', true, [])).toBe(false) expect(await readFile(file, 'utf8')).toBe(text) - expect(await writePluginEnabled(file, 'tool', 'package', false)).toBe(true) + expect(await writePluginEnabled(file, 'tool', 'package', false, [])).toBe(true) expect(loadOptionalPatches('test', file)).toHaveLength(3) }) + +it('removes or omits a plain switch that restates the bundle layers', async () => { + const layers = [{ insert: [{ id: 'tool', name: 'package' }] }] + const file = await fixture('# personal configuration\n- id: other\n disabled: true\n- id: tool\n disabled: true\n') + expect(await writePluginEnabled(file, 'tool', 'package', true, layers)).toBe(true) + expect(await readFile(file, 'utf8')).toBe('# personal configuration\n- id: other\n disabled: true\n') + expect(await writePluginEnabled(file, 'tool', 'package', true, layers)).toBe(false) + expect(await readFile(file, 'utf8')).toBe('# personal configuration\n- id: other\n disabled: true\n') + expect(await writePluginEnabled(file, 'tool', 'package', false, layers)).toBe(true) + expect(loadOptionalPatches('test', file)).toEqual([{ id: 'other', disabled: true }, { id: 'tool', disabled: true }]) +}) + +it('keeps a switch that carries other settings or differs from the bundle layers', async () => { + const file = await fixture('- id: tool\n config: { value: 1 }\n disabled: true\n') + expect(await writePluginEnabled(file, 'tool', 'package', true, [{ insert: [{ id: 'tool', name: 'package' }] }])).toBe(true) + expect(loadOptionalPatches('test', file)).toEqual([{ id: 'tool', config: { value: 1 }, disabled: false }]) + const plain = await fixture('- id: tool\n disabled: true\n') + expect(await writePluginEnabled(plain, 'tool', 'package', true, [{ insert: [{ id: 'tool', name: 'package', disabled: true }] }])).toBe(true) + expect(loadOptionalPatches('test', plain)).toEqual([{ id: 'tool', disabled: false }]) +}) + +it('keeps the comment above a removed switch and prunes a switch the composition already implies', async () => { + const layers = [{ insert: [{ id: 'tool', name: 'package' }] }] + const file = await fixture('# header\n- id: tool\n disabled: false\n# other\n- id: other\n disabled: true\n') + expect(await writePluginEnabled(file, 'tool', 'package', true, layers)).toBe(true) + expect(await readFile(file, 'utf8')).toBe('# header\n# other\n- id: other\n disabled: true\n') + const first = await fixture('# header\n- id: tool\n disabled: true\n- id: other\n disabled: true\n') + expect(await writePluginEnabled(first, 'tool', 'package', true, layers)).toBe(true) + expect(await readFile(first, 'utf8')).toBe('# header\n- id: other\n disabled: true\n') + const only = await fixture('# header\n- id: tool\n disabled: true\n') + expect(await writePluginEnabled(only, 'tool', 'package', true, layers)).toBe(true) + expect(loadOptionalPatches('test', only)).toEqual([]) + expect(await readFile(only, 'utf8')).toContain('# header') + const trailing = await fixture('- id: tool\n disabled: true # removed\n# tail\n') + expect(await writePluginEnabled(trailing, 'tool', 'package', true, layers)).toBe(true) + expect(loadOptionalPatches('test', trailing)).toEqual([]) + expect(await readFile(trailing, 'utf8')).toContain('# tail') + const both = await fixture('# header\n- id: tool\n disabled: true\n# tail\n') + expect(await writePluginEnabled(both, 'tool', 'package', true, layers)).toBe(true) + expect(await readFile(both, 'utf8')).toMatch(/# header[\s\S]*# tail/) +}) + +it('treats insertions in the profile patch as part of the inherited composition', async () => { + const file = await fixture('- insert:\n - id: tool\n name: package\n') + expect(await writePluginEnabled(file, 'tool', 'package', true, [])).toBe(false) + expect(loadOptionalPatches('test', file)).toEqual([{ insert: [{ id: 'tool', name: 'package' }] }]) +}) diff --git a/packages/client/ui-plugin-manager/README.i18n.yaml b/packages/client/ui-plugin-manager/README.i18n.yaml index b5992f5632..8994414d70 100644 --- a/packages/client/ui-plugin-manager/README.i18n.yaml +++ b/packages/client/ui-plugin-manager/README.i18n.yaml @@ -24,8 +24,8 @@ en: 59e12480cf0c3e70 zh: f230ea8c752475d5 /deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/switching-one-row-of-a-bundle: - en: 620c6a3ee20848bd - zh: a9429f047a12bb4c + en: d3627d4dba5e6cc2 + zh: e0d0eada4c20331f /deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/configuration-pages: en: e9da90f03691101b zh: 902cda935a4ce3c8 diff --git a/packages/client/ui-plugin-manager/README.md b/packages/client/ui-plugin-manager/README.md index d7640d3de6..d05a585f94 100644 --- a/packages/client/ui-plugin-manager/README.md +++ b/packages/client/ui-plugin-manager/README.md @@ -64,7 +64,7 @@ A bundle's page shows its full package name under the title. Its **Source** sect ### Switching one row of a bundle -A row's switch on the bundle's page calls `pluginManager.setPluginEnabled`, which writes the row's `disabled` override into the profile's `cordis.patch.yml`. The tree recomposes at once on a profile with HMR, so the row's host half unmounts or mounts while the rest of the bundle keeps running, and the page follows the client module graph without reloading. Rows use the shared status marker for their Host fiber phase: pending and disabled are idle, loading and unloading are ongoing, active is done, and failed is error. The switch appears only on a bundle that is on; a row without a live entry, or one the Host will not address through the profile patch, is locked with the Host's reason. A list longer than ten rows gets a filter over localized titles, descriptions, row ids, and module names. +A row's switch on the bundle's page calls `pluginManager.setPluginEnabled`, which records the row's enablement in the profile's `cordis.patch.yml` under the [toggle rules](../../boot/plugin-manager/README.md#use-this-package). The tree recomposes at once on a profile with HMR, so the row's host half unmounts or mounts while the rest of the bundle keeps running, and the page follows the client module graph without reloading. Rows use the shared status marker for their Host fiber phase: pending and disabled are idle, loading and unloading are ongoing, active is done, and failed is error. The switch appears only on a bundle that is on; a row without a live entry, or one the Host will not address through the profile patch, is locked with the Host's reason. A list longer than ten rows gets a filter over localized titles, descriptions, row ids, and module names. ### Configuration pages diff --git a/packages/client/ui-plugin-manager/README.zh.md b/packages/client/ui-plugin-manager/README.zh.md index 88db4ff635..61f69af603 100644 --- a/packages/client/ui-plugin-manager/README.zh.md +++ b/packages/client/ui-plugin-manager/README.zh.md @@ -64,7 +64,7 @@ Host 将网络失败或超时归因于 GitHub 地址,且提供 npmmirror 时 ### 切换组合包里的一行 -组合包页面上行的开关调用 `pluginManager.setPluginEnabled`,往 profile 的 `cordis.patch.yml` 写入该行的 `disabled` 覆盖。启用了 HMR 的 profile 的树随即重组,该行的宿主半区卸下或挂上,组合包其余部分照常运行,页面无需重载即跟随客户端模块图。行使用共享状态标记表示 Host fiber 阶段:pending 与 disabled 为 idle,loading 与 unloading 为 ongoing,active 为 done,failed 为 error。开关只出现在已打开的组合包上;没有存活条目的行,以及 Host 不通过 profile patch 寻址的行,带着 Host 的原因锁定。超过十行的列表带一个按本地化标题、描述、行 id 和模块名筛选的输入框。 +组合包页面上行的开关调用 `pluginManager.setPluginEnabled`,按[开关规则](../../boot/plugin-manager/README.zh.md#use-this-package)在 profile 的 `cordis.patch.yml` 中记录该行的启用状态。启用了 HMR 的 profile 的树随即重组,该行的宿主半区卸下或挂上,组合包其余部分照常运行,页面无需重载即跟随客户端模块图。行使用共享状态标记表示 Host fiber 阶段:pending 与 disabled 为 idle,loading 与 unloading 为 ongoing,active 为 done,failed 为 error。开关只出现在已打开的组合包上;没有存活条目的行,以及 Host 不通过 profile patch 寻址的行,带着 Host 的原因锁定。超过十行的列表带一个按本地化标题、描述、行 id 和模块名筛选的输入框。 ### 配置页 From 5a107fdef5b34fe8a29581ee1b51b00409673e2f Mon Sep 17 00:00:00 2001 From: turtle1999 Date: Tue, 13 Oct 2026 00:50:54 +0800 Subject: [PATCH 02/35] fix(plugin-manager): keep anchors and comment placement when pruning switches --- packages/boot/app-boot/src/index.ts | 2 +- packages/boot/app-boot/src/profile-context.ts | 14 ++++++++-- packages/boot/plugin-manager/README.i18n.yaml | 4 +-- packages/boot/plugin-manager/README.md | 2 +- packages/boot/plugin-manager/README.zh.md | 2 +- packages/boot/plugin-manager/src/index.ts | 5 ++-- packages/boot/plugin-manager/src/patch.ts | 23 +++++++++------- .../boot/plugin-manager/tests/patch.spec.ts | 26 +++++++++++++++++-- 8 files changed, 57 insertions(+), 21 deletions(-) diff --git a/packages/boot/app-boot/src/index.ts b/packages/boot/app-boot/src/index.ts index f5b08a4596..282519fbbd 100644 --- a/packages/boot/app-boot/src/index.ts +++ b/packages/boot/app-boot/src/index.ts @@ -17,7 +17,7 @@ import Include, { applyEntryPatches, entryListSchema, type PatchOptions } from ' import Group from '@deepseek-ai/cordis-plugin-group' import { dshHomePath, resolveDshHome } from '@deepseek-ai/dsh-home-paths' import { createLaunchEnvironmentSnapshot, type LaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment' -export { readProfilePatches, resolveTelemetryPatch, type ProfileContext, type ProfilePnpmInvocation } from './profile-context.ts' +export { readBundlePatches, readProfilePatches, resolveTelemetryPatch, type ProfileContext, type ProfilePnpmInvocation } from './profile-context.ts' export { sanitizeProfile } from './profile-sanitize.ts' export { getDshRuntimeVersion, evaluatePluginCompatibility, pluginCompatibilityWarning, type PluginCompatibility } from './plugin-compatibility.ts' export { diff --git a/packages/boot/app-boot/src/profile-context.ts b/packages/boot/app-boot/src/profile-context.ts index 328b543e00..e2ebfad5ce 100644 --- a/packages/boot/app-boot/src/profile-context.ts +++ b/packages/boot/app-boot/src/profile-context.ts @@ -54,6 +54,17 @@ export function resolveTelemetryPatch(disabledEnv: string | undefined, hasRow: b return { id: TELEMETRY_ROW_ID, disabled: true } } +/** Read the selected bundles' patches, which apply before the profile patch file. + * @param binName Diagnostic prefix for malformed or missing configuration. + * @param context Data supplied by the profile launcher. + * @param initialProfile Already loaded startup profile; omitted reads the current files. + * @returns Bundle patches in application order. + */ +export function readBundlePatches(binName: string, context: ProfileContext, initialProfile?: Profile): PatchOptions[] { + const profile = initialProfile ?? loadProfileDirectory(binName, context.dir, context.installAnchor, { userLayer: false }) + return profile.layers.flatMap(layer => layer.patches) +} + /** Read current bundle and user layers with the launch-time overlays. * @param binName Diagnostic prefix for malformed or missing configuration. * @param context Data supplied by the profile launcher. @@ -61,9 +72,8 @@ export function resolveTelemetryPatch(disabledEnv: string | undefined, hasRow: b * @returns Detached ordered patches; this function does not update the Loader. */ export function readProfilePatches(binName: string, context: ProfileContext, initialProfile?: Profile): PatchOptions[] { - const profile = initialProfile ?? loadProfileDirectory(binName, context.dir, context.installAnchor, { userLayer: false }) const patches = structuredClone([ - ...profile.layers.flatMap(layer => layer.patches), + ...readBundlePatches(binName, context, initialProfile), ...(initialProfile?.patches ?? loadOptionalPatches(binName, context.patchPath) ?? []), ...(loadOptionalPatches(binName, join(context.home, PROFILE_PATCH_FILENAME)) ?? []), ...context.overlays, diff --git a/packages/boot/plugin-manager/README.i18n.yaml b/packages/boot/plugin-manager/README.i18n.yaml index 430a5e215b..c95d399d99 100644 --- a/packages/boot/plugin-manager/README.i18n.yaml +++ b/packages/boot/plugin-manager/README.i18n.yaml @@ -15,8 +15,8 @@ en: 87679c51aa3693f5 zh: 11684a48a5806afb /deepseek-ai-dsh-plugin-manager/use-this-package: - en: b0c68b304e1b0040 - zh: af0f4b659971d91d + en: d7dc0c0500c9d381 + zh: afd5faeaef84c714 /deepseek-ai-dsh-plugin-manager/use-this-package/version-compatibility-and-exemptions: en: 920c0acb3b9feaf3 zh: ba6e832a256880a7 diff --git a/packages/boot/plugin-manager/README.md b/packages/boot/plugin-manager/README.md index 89c58803e3..1c3ca5f62e 100644 --- a/packages/boot/plugin-manager/README.md +++ b/packages/boot/plugin-manager/README.md @@ -37,7 +37,7 @@ For a deployment without agent presets, enable the tool in the profile patch. Pr disabled: false ``` -A plugin toggle updates only `disabled` in the last matching override in the profile's `cordis.patch.yml`, or appends an override when none matches. Matching uses the entry id and any module-name assertion. An override holding only `id` and `disabled` is removed, and none is appended, when the bundle layers and the remaining profile patches already give the entry the requested state, so the profile patch records only departures from the bundles. A bundle toggle changes `package.json`'s ordered `dsh.profile.bundles` list. Disabling retains the dependency; enabling appends the bundle at the end, which can change configuration precedence. Installation enables a new bundle by default. Home and invocation patches retain their higher priority. +A plugin toggle updates only `disabled` in the last matching override in the profile's `cordis.patch.yml`, or appends an override when none matches. Matching uses the entry id and any module-name assertion. An override holding only `id` and `disabled` is removed, and none is appended, when the bundle layers and the remaining profile patches already give the entry the requested state, so the profile patch records only departures from the bundles. Home and invocation patches are left out of that comparison; they apply after the profile patch and do not decide what it records. A bundle toggle changes `package.json`'s ordered `dsh.profile.bundles` list. Disabling retains the dependency; enabling appends the bundle at the end, which can change configuration precedence. Installation enables a new bundle by default. Home and invocation patches retain their higher priority. A selected bundle that cannot load remains in `listBundles` with an `error`; `enabled` records the saved selection, not successful loading. The plugin page shows the error and allows deselection. A broken bundle cannot be enabled. Management bundles remain protected if their files become unreadable. diff --git a/packages/boot/plugin-manager/README.zh.md b/packages/boot/plugin-manager/README.zh.md index 8e6b7ec952..1442c3c351 100644 --- a/packages/boot/plugin-manager/README.zh.md +++ b/packages/boot/plugin-manager/README.zh.md @@ -37,7 +37,7 @@ kind: "package-reference" disabled: false ``` -插件开关只更新 profile 的 `cordis.patch.yml` 中最后一条匹配覆盖项的 `disabled`;没有匹配项时追加。匹配依据是条目 id,以及覆盖项声明的模块名称。如果组合包层和其余 profile patch 已使条目处于所请求的状态,仅含 `id` 与 `disabled` 的覆盖项会被移除,也不会追加覆盖项,因此 profile patch 只记录与组合包不同的设置。组合包开关修改 `package.json` 的有序 `dsh.profile.bundles` 列表。关闭保留依赖;开启追加到列表末尾,可能改变配置优先级。安装新组合包默认启用。home 和单次启动 patch 保留更高优先级。 +插件开关只更新 profile 的 `cordis.patch.yml` 中最后一条匹配覆盖项的 `disabled`;没有匹配项时追加。匹配依据是条目 id,以及覆盖项声明的模块名称。如果组合包层和其余 profile patch 已使条目处于所请求的状态,仅含 `id` 与 `disabled` 的覆盖项会被移除,也不会追加覆盖项,因此 profile patch 只记录与组合包不同的设置。该比较不计入 home 和单次启动 patch;它们在 profile patch 之后应用,不决定 profile patch 记录什么。组合包开关修改 `package.json` 的有序 `dsh.profile.bundles` 列表。关闭保留依赖;开启追加到列表末尾,可能改变配置优先级。安装新组合包默认启用。home 和单次启动 patch 保留更高优先级。 已选择但无法加载的组合包仍会出现在 `listBundles` 中,并携带 `error`;`enabled` 表示保存的选择,不代表加载成功。插件页面显示错误并允许取消选择。损坏的组合包无法启用。管理组合包的文件变得不可读后仍受保护。 diff --git a/packages/boot/plugin-manager/src/index.ts b/packages/boot/plugin-manager/src/index.ts index c0066f38b8..274c9bb7f1 100644 --- a/packages/boot/plugin-manager/src/index.ts +++ b/packages/boot/plugin-manager/src/index.ts @@ -13,7 +13,7 @@ import { TypertRemoteService, Remote } from '@deepseek-ai/dsh-typert-protocol' import { pluginEntryId, readPluginInventory } from '@deepseek-ai/dsh-host-plugin-inventory' import { readPluginMeta, readProfileManifest, resolveBundleDir, loadOverlayPatches, composeEntries, - reconcileProfilePatches, readProfilePatches, loadProfileDirectory, OPTIONAL_BUNDLES, bundlePatchPaths, + reconcileProfilePatches, readProfilePatches, readBundlePatches, OPTIONAL_BUNDLES, bundlePatchPaths, evaluatePluginCompatibility, readProfileCompatibility, readProfileVersionExemptions, setProfileVersionExemption, PROFILE_COMPATIBILITY_FILENAME, OFFICIAL_ON_DEMAND_CATALOG, getDshRuntimeVersion, @@ -455,8 +455,7 @@ export class PluginManager extends TypertRemoteService { const row = (await this.listPlugins()).find(item => item.entryId === id) if (row === undefined) throw new ManagementFailure('unknown-plugin') if (row.readOnlyReason !== undefined) throw new ManagementFailure(row.readOnlyReason) - const { layers } = loadProfileDirectory('dsh', this.profile.dir, this.profile.installAnchor, { userLayer: false }) - await writePluginEnabled(this.profile.patchPath, row.patchId, row.moduleName, enabled, layers.flatMap(layer => layer.patches)) + await writePluginEnabled(this.profile.patchPath, row.patchId, row.moduleName, enabled, readBundlePatches('dsh', this.profile)) result.warnings = await this.reload(enabled ? [row.patchId] : []) const current = (await this.listPlugins()).find(item => item.entryId === id) return current?.enabled !== enabled && this.ownerContext.get('hmr') !== undefined ? 'overridden' : undefined diff --git a/packages/boot/plugin-manager/src/patch.ts b/packages/boot/plugin-manager/src/patch.ts index 2906a4eaad..148b17f783 100644 --- a/packages/boot/plugin-manager/src/patch.ts +++ b/packages/boot/plugin-manager/src/patch.ts @@ -1,4 +1,4 @@ -/** Comment-preserving profile plugin enablement edits. */ +/** Comment-preserving profile plugin enablement edits and the entry flattening they compare against. */ import { readFile } from 'node:fs/promises' import { isMap, isNode, isScalar, isSeq, parseDocument } from 'yaml' import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader' @@ -15,8 +15,8 @@ export function flatten(rows: EntryOptions[]): EntryOptions[] { } /** Replace the last matching override or append one after existing insertions. An override that holds only `id` and - * `disabled` is removed, or not appended, when the bundle layers and the remaining profile patches already give the - * entry the requested enablement. + * `disabled`, without anchors, is removed, or not appended, when the bundle layers and the remaining profile patches + * already give the entry the requested literal `disabled` value; home patches and launch overlays are not consulted. * @param filename Current profile patch file. * @param id Unique composition entry id. * @param name Module name used to match name-qualified overrides. @@ -47,19 +47,24 @@ export async function writePluginEnabled( const expectedName = document.getIn([index, 'name']) return !expectedName || expectedName === name }) - const inherits = (index: number): boolean => { - const row = flatten(composeEntries([[...layers, ...patches.toSpliced(index, 1)]])).find(entry => entry.id === id) + // Compares the entry's own literal `disabled`; a `!!js` value never matches, so its override is kept. + const implied = (index: number): boolean => { + const row = flatten(composeEntries([[...layers, ...patches.toSpliced(index, 1)]])).findLast(entry => entry.id === id) return row !== undefined && (row.disabled ?? false) === !enabled } if (isMap(target)) { const index = items.indexOf(target) - const onlySwitch = target.items.every(pair => isScalar(pair.key) && (pair.key.value === 'id' || pair.key.value === 'disabled')) - if (onlySwitch && inherits(index)) { + // A removed anchor would leave later aliases unresolved. + const onlySwitch = target.anchor === undefined && target.items.every(pair => isScalar(pair.key) && isScalar(pair.value) + && pair.key.anchor === undefined && pair.value.anchor === undefined && (pair.key.value === 'id' || pair.key.value === 'disabled')) + if (onlySwitch && implied(index)) { document.deleteIn([index]) - // The comment above the removed override can be the file header, so it moves to the following node. + // The comment above the removed override can be the file header, so it moves to the following item, above an + // emptied sequence, or after the remaining items. const comment = target.commentBefore const next = items[index] if (comment && isNode(next)) next.commentBefore = next.commentBefore ? `${comment}\n${next.commentBefore}` : comment + else if (comment && items.length === 0) document.contents.commentBefore = comment else if (comment) document.comment = document.comment ? `${comment}\n${document.comment}` : comment } else if (document.getIn([index, 'disabled']) === !enabled) { return false @@ -67,7 +72,7 @@ export async function writePluginEnabled( document.setIn([index, 'disabled'], !enabled) } } else { - if (inherits(items.length)) return false + if (implied(items.length)) return false document.add({ id, disabled: !enabled }) } await writeFileAtomic(filename, String(document), { mode: 0o600 }) diff --git a/packages/boot/plugin-manager/tests/patch.spec.ts b/packages/boot/plugin-manager/tests/patch.spec.ts index fc313a1398..4a80f4cfdc 100644 --- a/packages/boot/plugin-manager/tests/patch.spec.ts +++ b/packages/boot/plugin-manager/tests/patch.spec.ts @@ -114,14 +114,20 @@ it('keeps the comment above a removed switch and prunes a switch the composition const only = await fixture('# header\n- id: tool\n disabled: true\n') expect(await writePluginEnabled(only, 'tool', 'package', true, layers)).toBe(true) expect(loadOptionalPatches('test', only)).toEqual([]) - expect(await readFile(only, 'utf8')).toContain('# header') + expect(await readFile(only, 'utf8')).toBe('# header\n[]\n') + const last = await fixture('- id: other\n disabled: true\n# about tool\n- id: tool\n disabled: true\n') + expect(await writePluginEnabled(last, 'tool', 'package', true, layers)).toBe(true) + expect(await readFile(last, 'utf8')).toBe('- id: other\n disabled: true\n\n# about tool\n') + const lastWithTail = await fixture('- id: other\n disabled: true\n# about tool\n- id: tool\n disabled: true\n# tail\n') + expect(await writePluginEnabled(lastWithTail, 'tool', 'package', true, layers)).toBe(true) + expect(await readFile(lastWithTail, 'utf8')).toBe('- id: other\n disabled: true\n\n# about tool\n# tail\n') const trailing = await fixture('- id: tool\n disabled: true # removed\n# tail\n') expect(await writePluginEnabled(trailing, 'tool', 'package', true, layers)).toBe(true) expect(loadOptionalPatches('test', trailing)).toEqual([]) expect(await readFile(trailing, 'utf8')).toContain('# tail') const both = await fixture('# header\n- id: tool\n disabled: true\n# tail\n') expect(await writePluginEnabled(both, 'tool', 'package', true, layers)).toBe(true) - expect(await readFile(both, 'utf8')).toMatch(/# header[\s\S]*# tail/) + expect(await readFile(both, 'utf8')).toBe('# header\n[]\n\n# tail\n') }) it('treats insertions in the profile patch as part of the inherited composition', async () => { @@ -129,3 +135,19 @@ it('treats insertions in the profile patch as part of the inherited composition' expect(await writePluginEnabled(file, 'tool', 'package', true, [])).toBe(false) expect(loadOptionalPatches('test', file)).toEqual([{ insert: [{ id: 'tool', name: 'package' }] }]) }) + +it.each([ + ['a value', '- id: &plugin tool\n disabled: true\n- id: other\n config: { target: *plugin }\n', { target: 'tool' }], + ['the entry', '- &plugin { id: tool, disabled: true }\n- id: other\n config: { target: *plugin }\n', { target: { id: 'tool', disabled: false } }], + ['a key', '- id: tool\n &key disabled: true\n- id: other\n config: { *key : 1 }\n', { disabled: 1 }], +])('keeps a switch whose anchor on %s other entries reference', async (_, text, config) => { + const file = await fixture(text) + expect(await writePluginEnabled(file, 'tool', 'package', true, [{ insert: [{ id: 'tool', name: 'package' }] }])).toBe(true) + expect(loadOptionalPatches('test', file)).toEqual([{ id: 'tool', disabled: false }, { id: 'other', config }]) +}) + +it('keeps a switch whose value is an alias', async () => { + const file = await fixture('- id: other\n config: { flag: &flag true }\n- id: tool\n disabled: *flag\n') + expect(await writePluginEnabled(file, 'tool', 'package', true, [{ insert: [{ id: 'tool', name: 'package' }] }])).toBe(true) + expect(loadOptionalPatches('test', file)).toEqual([{ id: 'other', config: { flag: true } }, { id: 'tool', disabled: false }]) +}) From 5d074a3f898eca8e11583b85a093f3235f61e4f5 Mon Sep 17 00:00:00 2001 From: kingwl Date: Mon, 12 Oct 2026 18:25:05 +0800 Subject: [PATCH 03/35] test(benchmarks): stabilize user-text timing samples --- ...11-user-text-credential-matching.i18n.yaml | 8 +-- ...026-10-11-user-text-credential-matching.md | 6 +- ...-10-11-user-text-credential-matching.zh.md | 6 +- .../user-text/user-text.bench.client.ts | 2 +- .../user-text/user-text.worker.client.ts | 70 ++++++++++++++++--- 5 files changed, 74 insertions(+), 18 deletions(-) diff --git a/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.i18n.yaml b/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.i18n.yaml index 7c41c4d039..ef43e1a35f 100644 --- a/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.i18n.yaml @@ -21,11 +21,11 @@ en: f07bb6fbbdd01fe4 zh: 9313a0ef2a22af12 /agent-note-linear-credential-matching-and-performance-gates-for-user-text/decision/performance-gate: - en: 6a303115f85ed4e8 - zh: e894f3e9d630bf57 + en: cde50faa429dace3 + zh: 1066765549d31be7 /agent-note-linear-credential-matching-and-performance-gates-for-user-text/alternatives-considered: - en: 1e21f5f9ca959f7e - zh: 401e303071b8d426 + en: 1086c0fdfc46b12a + zh: d2ee22f5ebdf6d2b /agent-note-linear-credential-matching-and-performance-gates-for-user-text/consequences: en: 8f9d25b72d3e7660 zh: e5c6ae0194905876 diff --git a/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.md b/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.md index d7995a1f93..6584385033 100644 --- a/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.md +++ b/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.md @@ -43,15 +43,17 @@ English | [中文](2026-10-11-user-text-credential-matching.zh.md) | Item | Content | |---|---| | Workloads | Without credentials: ordinary messages, and long prose, code, base64, Markdown, log, JSON, and shell text. With credentials: messages each carrying one kind (model key, GitHub token, cloud key ID, JWT, address password, private-key block), and batches of `.env` keys. Adversarial: repeated input shaped against each format's prefix, length, and separator rules, at two sizes. All generated from constants; credential samples are assembled at run time | -| Timing | Each sample builds a fresh registry, document, and matcher instance and times evaluation only; each workload is measured with and without the credential matcher, keeping the fastest of several samples and the process CPU time over the same interval | +| Timing | Ten unmeasured pairs warm each workload’s code paths. Five measured pairs alternate baseline-first and matcher-first order; each evaluation builds a fresh registry, document, and matcher instance and times evaluation only. A sample averages twenty fresh evaluations for a single-document workload, or one evaluation of a 5,000-message batch. The verdict uses each side’s minimum and its process CPU time; the report includes every measured pair and the averaging count. Warm-up retains no matcher memo | | Allocation | A separate process with `--expose-gc` and a fixed semi-space larger than any evaluation; after warm-up and GC, the matcher alone answers every query once and the young-generation growth is taken; GC event timestamps confirm no GC ran inside the interval | | Assertions | Without credentials: absolute budgets and a cap on the with/without time ratio; with credentials: absolute budgets and the time ratio when the key count doubles; adversarial: absolute budgets and doubling ratios; allocation: a floor plus caps per credential, per line break inside masks, and per masked character | -| Excluded | React rendering, browser paint and layout; allocation results are asserted and printed to the console, not added to the HTML report | +| Excluded | Cold code compilation, React rendering, browser paint and layout; allocation results are asserted and printed to the console, not added to the HTML report | Expectations come from an x64 development machine. CI time budgets are converted with the shared scale and headroom; time ratios, doubling ratios, and allocation budgets do not scale with machine speed. Negative controls: the same benchmark fails when pointed at the regular-expression implementation or at an implementation that rebuilds its memo per query. Equivalence tests compare this implementation with the regular-expression one ([regex-oracle.client.ts](../../../../packages/client/ui-secret-mask/tests/regex-oracle.client.ts)) node by node through the production scheduler and from every query offset. ## Alternatives considered +- **Unwarmed, separate baseline and matcher batches**: sub-millisecond baseline measurements can reach optimized code before the matcher’s newly exercised paths do, and separate batches amplify clock-speed drift. Per-workload warm-up, adjacent pairs, and fixed batches compare warmed code while limiting noise in short intervals and preserving fresh documents and matcher state; all absolute, ratio, and allocation limits remain fixed. + - **One quantified pattern per format, run from every query**: patterns without a hit scan to the end of the text each time, so cost grows quadratically with credentials, and length quantifiers backtrack on failure. - **One large quantified pattern for all formats**: one scan finds the leftmost match, but branch quantifiers still backtrack on failure. - **Scanning for rare characters first, then looking back for prefixes**: faster on ordinary text, but cost depends on character distribution, and text dense in `-` or `_` becomes clearly slower. diff --git a/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.zh.md b/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.zh.md index 4da050e2e4..06eacb2a38 100644 --- a/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.zh.md +++ b/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.zh.md @@ -43,15 +43,17 @@ Status: implemented | 项 | 内容 | |---|---| | 负载 | 无凭据:普通消息,以及散文、代码、base64、Markdown、日志、JSON、shell 长文本。含凭据:每条消息含一种凭据(模型密钥、GitHub 令牌、云密钥 ID、JWT、地址密码、私钥块),以及成批的 `.env` 密钥。对抗:针对每种格式的前缀、长度与分隔规则构造的重复输入,两种长度。全部由常量生成,凭据样本运行时拼接 | -| 计时 | 每个样本新建注册表、文档和匹配器实例,只计求值;每个负载分别测有无凭据匹配器,取多个样本中最快的一个,并记录同一区间的进程 CPU 时间 | +| 计时 | 每个负载先用十组未计入结果的配对求值预热代码路径,再测量五组,交替以基线或凭据匹配器开头;每次求值新建注册表、文档和匹配器实例,只计求值。单文档负载的一个样本取二十次全新求值的平均耗时,5,000 条消息的批量负载则求值一次。判定取两侧各自的最小耗时及同一区间的进程 CPU 时间,报告保留全部测量配对和求平均值的次数。预热不保留匹配器记忆 | | 分配 | 单独进程,`--expose-gc`,semi-space 固定为大于任何一次求值的大小;预热后 GC,再单独驱动匹配器回答全部查询一遍,取新生代增量;用 GC 事件的时间戳确认区间内没有 GC | | 断言 | 无凭据:绝对预算,以及"有/无凭据匹配器"的耗时比上限;含凭据:绝对预算,密钥数翻倍时的耗时比;对抗:绝对预算与翻倍耗时比;分配:底噪加每个凭据、每个遮挡内换行和每个遮挡字符的上限 | -| 排除 | React 渲染、浏览器绘制与布局;分配结果只用于断言并输出到控制台,不进 HTML 报告 | +| 排除 | 冷代码编译、React 渲染、浏览器绘制与布局;分配结果只用于断言并输出到控制台,不进 HTML 报告 | 预期值来自 x64 开发机。CI 时间预算按共享比例与余量换算;耗时比、翻倍比和分配预算不随机器缩放。负对照:同一 benchmark 指向正则实现或"每次查询新建记忆"的实现时都会失败。等价性测试经生产调度器逐节点比较本实现与正则实现([regex-oracle.client.ts](../../../../packages/client/ui-secret-mask/tests/regex-oracle.client.ts)),并对每个查询起点单独比较。 ## 考虑过的替代方案 +- **不预热、分批测量基线和匹配器**:亚毫秒基线测量可能先进入优化后的代码,而匹配器新触及的路径还未完成优化;分批测量也会放大时钟频率漂移。逐负载预热、相邻配对和固定批次比较预热后的代码,降低短区间测量的噪声,同时保持文档和匹配器状态全新;所有绝对耗时、比值和分配上限保持固定。 + - **每次查询对每个格式各跑一个带量词的正则**:没有命中的正则每次都扫到文末,凭据多时平方增长;长度量词在失败时回溯。 - **把全部格式合成一个带量词的大正则**:一次扫描能找到最左匹配,但各分支的量词失败时仍回溯。 - **先找稀有字符再回看前缀**:普通文本扫描更快,但成本取决于字符分布,`-`、`_` 密集的文本明显变慢。 diff --git a/benchmarks/user-text/user-text.bench.client.ts b/benchmarks/user-text/user-text.bench.client.ts index 9377281819..07bc1fe73d 100644 --- a/benchmarks/user-text/user-text.bench.client.ts +++ b/benchmarks/user-text/user-text.bench.client.ts @@ -9,7 +9,7 @@ import type { UserTextAllocationReport, UserTextTimeReport, UserTextWorkloadAllocation, UserTextWorkloadTiming, } from './user-text.worker.client.ts' -/** Fresh registries and documents per sample in one compiled worker; the fastest sample removes scheduler delay. */ +/** Fresh registries and documents after warm-up; independent minima of adjacent pairs limit scheduler delay. */ const ATTEMPTS = 5 /** A stuck worker is reaped before the outer benchmark deadline. */ const WORKER_TIMEOUT_MS = 180_000 diff --git a/benchmarks/user-text/user-text.worker.client.ts b/benchmarks/user-text/user-text.worker.client.ts index ca7ab3c099..35c6923cdd 100644 --- a/benchmarks/user-text/user-text.worker.client.ts +++ b/benchmarks/user-text/user-text.worker.client.ts @@ -17,12 +17,22 @@ import { assertBuiltBenchmarkRuntime } from '../support/built-worker.ts' /** What a workload contains: no credential, real credentials, or input shaped against one format. */ export type UserTextWorkloadGroup = 'unmatched' | 'matched' | 'adversarial' +/** Adjacent baseline and credential measurements; even-indexed pairs measure the baseline first. */ +export interface UserTextTimingPair { + readonly baseMs: number + readonly baseCpuMs: number + readonly withSecretMs: number + readonly withSecretCpuMs: number +} + /** Timing of one workload evaluated by the shipped rule set with and without the credential matcher. */ export interface UserTextWorkloadTiming { readonly id: string readonly group: UserTextWorkloadGroup readonly documents: number readonly characters: number + /** Fresh evaluations averaged into each timed sample; counts and characters describe one evaluation. */ + readonly evaluationsPerSample: number readonly secrets: number /** Characters inside credential ranges, whose masks the matcher builds. */ readonly maskedCharacters: number @@ -31,6 +41,8 @@ export interface UserTextWorkloadTiming { /** Process CPU time over the fastest with-credential sample. */ readonly withSecretCpuMs: number readonly ratio: number + /** All measured pairs in execution order, excluding warm-up. */ + readonly samples: readonly UserTextTimingPair[] } /** Heap allocation of the credential matcher over one workload, with the size of what it returns. */ @@ -45,8 +57,9 @@ export interface UserTextWorkloadAllocation { readonly maskedLineBreaks: number } -/** Result of `time `: the fastest of `attempts` fresh samples per workload. */ +/** Result of `time `: warmed code, fresh instances, and independent minima from adjacent sample pairs. */ export interface UserTextTimeReport { + readonly warmupPairs: number readonly attempts: number readonly workloads: readonly UserTextWorkloadTiming[] } @@ -65,6 +78,10 @@ const BASE_RULES: readonly UserTextRule[] = [ const FACTS: Readonly> = { sessionLabels: ['会话一'], skillNames: ['plan', 'review'] } const KIB = 1024 +/** Exercise each workload's code paths before measuring sub-millisecond baseline evaluations. */ +const WARMUP_PAIRS = 10 +/** Single-document baselines take less than a millisecond; a fixed batch limits clock and scheduling noise. */ +const SINGLE_DOCUMENT_EVALUATIONS = 20 const PROSE = '帮我看看这个函数为什么慢,顺便把 README 也更新一下。The quick brown fox jumps over the lazy dog, 1234 times. ' const CODE = 'const value = await fetch(`https://api.example.com/v1/items?id=${id}`); if (!value.ok) throw new Error("bad: " + value.status);\n' const BASE64 = 'QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVphYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ejAxMjM0NTY3ODkrLw' @@ -203,13 +220,45 @@ function evaluateAll(rules: RuleSet, texts: readonly string[]): Sample { return { ms, cpuMs: (cpu.user + cpu.system) / 1_000, secrets, maskedCharacters } } -function fastest(rules: RuleSet, texts: readonly string[], attempts: number): Sample { - let best = evaluateAll(rules, texts) - for (let attempt = 1; attempt < attempts; attempt++) { - const sample = evaluateAll(rules, texts) - if (sample.ms < best.ms) best = sample +function sample(rules: RuleSet, texts: readonly string[], evaluations: number): Sample { + const first = evaluateAll(rules, texts) + let ms = first.ms + let cpuMs = first.cpuMs + for (let index = 1; index < evaluations; index++) { + const next = evaluateAll(rules, texts) + ms += next.ms + cpuMs += next.cpuMs } - return best + return { ...first, ms: ms / evaluations, cpuMs: cpuMs / evaluations } +} + +function evaluatePair(texts: readonly string[], index: number, evaluations: number): { readonly base: Sample; readonly secret: Sample } { + if (index % 2 === 0) { + const base = sample(withoutSecret, texts, evaluations) + return { base, secret: sample(withSecret, texts, evaluations) } + } + const secret = sample(withSecret, texts, evaluations) + return { base: sample(withoutSecret, texts, evaluations), secret } +} + +function measure(texts: readonly string[], attempts: number, evaluations: number): { + readonly base: Sample; readonly secret: Sample; readonly samples: readonly UserTextTimingPair[] +} { + for (let warm = 0; warm < WARMUP_PAIRS; warm++) evaluatePair(texts, warm, evaluations) + let best = evaluatePair(texts, 0, evaluations) + const samples: UserTextTimingPair[] = [] + for (let attempt = 0; attempt < attempts; attempt++) { + const pair = attempt === 0 ? best : evaluatePair(texts, attempt, evaluations) + samples.push({ + baseMs: pair.base.ms, baseCpuMs: pair.base.cpuMs, + withSecretMs: pair.secret.ms, withSecretCpuMs: pair.secret.cpuMs, + }) + best = { + base: pair.base.ms < best.base.ms ? pair.base : best.base, + secret: pair.secret.ms < best.secret.ms ? pair.secret : best.secret, + } + } + return { ...best, samples } } declare const gc: (() => void) | undefined @@ -309,21 +358,24 @@ async function allocations(): Promise { function timings(attempts: number): UserTextTimeReport { return { + warmupPairs: WARMUP_PAIRS, attempts, workloads: workloads().map(({ id, group, texts }) => { - const base = fastest(withoutSecret, texts, attempts) - const secret = fastest(withSecret, texts, attempts) + const evaluationsPerSample = texts.length === 1 ? SINGLE_DOCUMENT_EVALUATIONS : 1 + const { base, secret, samples } = measure(texts, attempts, evaluationsPerSample) return { id, group, documents: texts.length, characters: texts.reduce((sum, text) => sum + text.length, 0), + evaluationsPerSample, secrets: secret.secrets, maskedCharacters: secret.maskedCharacters, baseMs: base.ms, withSecretMs: secret.ms, withSecretCpuMs: secret.cpuMs, ratio: secret.ms / base.ms, + samples, } }), } From 49c92c889dba5b33225a4649163059295ec3a93f Mon Sep 17 00:00:00 2001 From: kingwl Date: Tue, 13 Oct 2026 11:29:00 +0800 Subject: [PATCH 04/35] test(benchmarks): await sample cleanup and clarify timing reports --- ...11-user-text-credential-matching.i18n.yaml | 8 +- ...026-10-11-user-text-credential-matching.md | 7 +- ...-10-11-user-text-credential-matching.zh.md | 7 +- .../user-text/user-text.bench.client.ts | 5 +- .../user-text/user-text.worker.client.ts | 145 +++++++++--------- 5 files changed, 91 insertions(+), 81 deletions(-) diff --git a/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.i18n.yaml b/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.i18n.yaml index ef43e1a35f..b25c6213ba 100644 --- a/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.i18n.yaml @@ -21,11 +21,11 @@ en: f07bb6fbbdd01fe4 zh: 9313a0ef2a22af12 /agent-note-linear-credential-matching-and-performance-gates-for-user-text/decision/performance-gate: - en: cde50faa429dace3 - zh: 1066765549d31be7 + en: 29b8f8c1ad73c77b + zh: 8293b21ec08d3896 /agent-note-linear-credential-matching-and-performance-gates-for-user-text/alternatives-considered: - en: 1086c0fdfc46b12a - zh: d2ee22f5ebdf6d2b + en: 7600c1671b5dc73c + zh: b38e3ae76a5a32d1 /agent-note-linear-credential-matching-and-performance-gates-for-user-text/consequences: en: 8f9d25b72d3e7660 zh: e5c6ae0194905876 diff --git a/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.md b/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.md index 6584385033..b8bcd21b3a 100644 --- a/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.md +++ b/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.md @@ -43,17 +43,18 @@ English | [中文](2026-10-11-user-text-credential-matching.zh.md) | Item | Content | |---|---| | Workloads | Without credentials: ordinary messages, and long prose, code, base64, Markdown, log, JSON, and shell text. With credentials: messages each carrying one kind (model key, GitHub token, cloud key ID, JWT, address password, private-key block), and batches of `.env` keys. Adversarial: repeated input shaped against each format's prefix, length, and separator rules, at two sizes. All generated from constants; credential samples are assembled at run time | -| Timing | Ten unmeasured pairs warm each workload’s code paths. Five measured pairs alternate baseline-first and matcher-first order; each evaluation builds a fresh registry, document, and matcher instance and times evaluation only. A sample averages twenty fresh evaluations for a single-document workload, or one evaluation of a 5,000-message batch. The verdict uses each side’s minimum and its process CPU time; the report includes every measured pair and the averaging count. Warm-up retains no matcher memo | +| Timing | Ten unmeasured pairs warm each workload with the same evaluation count as measured pairs. Five measured pairs alternate baseline-first and matcher-first order; each evaluation builds a fresh registry, document, and matcher instance and times evaluation only. A single-document sample averages twenty evaluations; a 5,000-message sample evaluates the batch once. Each side selects its minimum sample mean, with the mean process CPU time from that sample. The worker JSON in CI logs includes every measured pair and averaging count; the HTML report shows the selected with-credential mean. Registry disposal completes outside the timer before the next evaluation, preventing cleanup and retained registries from accumulating. Warm-up retains no matcher memo | | Allocation | A separate process with `--expose-gc` and a fixed semi-space larger than any evaluation; after warm-up and GC, the matcher alone answers every query once and the young-generation growth is taken; GC event timestamps confirm no GC ran inside the interval | | Assertions | Without credentials: absolute budgets and a cap on the with/without time ratio; with credentials: absolute budgets and the time ratio when the key count doubles; adversarial: absolute budgets and doubling ratios; allocation: a floor plus caps per credential, per line break inside masks, and per masked character | | Excluded | Cold code compilation, React rendering, browser paint and layout; allocation results are asserted and printed to the console, not added to the HTML report | Expectations come from an x64 development machine. CI time budgets are converted with the shared scale and headroom; time ratios, doubling ratios, and allocation budgets do not scale with machine speed. Negative controls: the same benchmark fails when pointed at the regular-expression implementation or at an implementation that rebuilds its memo per query. Equivalence tests compare this implementation with the regular-expression one ([regex-oracle.client.ts](../../../../packages/client/ui-secret-mask/tests/regex-oracle.client.ts)) node by node through the production scheduler and from every query offset. +The reference constants are calibrated for minima of five single evaluations. Minimum-of-five sample means use the existing absolute headroom without recalibration. Averaging can move the ratio of independently selected minima in either direction; a threefold matcher-work negative control verifies that the JSON ratio limit still rejects extra scanning. + ## Alternatives considered -- **Unwarmed, separate baseline and matcher batches**: sub-millisecond baseline measurements can reach optimized code before the matcher’s newly exercised paths do, and separate batches amplify clock-speed drift. Per-workload warm-up, adjacent pairs, and fixed batches compare warmed code while limiting noise in short intervals and preserving fresh documents and matcher state; all absolute, ratio, and allocation limits remain fixed. - +- **Unwarmed, separate baseline and matcher batches**: sub-millisecond baseline measurements can reach optimized code before the matcher’s newly exercised paths do, and separate batches amplify clock-speed drift. Per-workload warm-up, adjacent pairs, and fixed batches compare warmed code while limiting noise in short intervals and preserving fresh documents and matcher state. - **One quantified pattern per format, run from every query**: patterns without a hit scan to the end of the text each time, so cost grows quadratically with credentials, and length quantifiers backtrack on failure. - **One large quantified pattern for all formats**: one scan finds the leftmost match, but branch quantifiers still backtrack on failure. - **Scanning for rare characters first, then looking back for prefixes**: faster on ordinary text, but cost depends on character distribution, and text dense in `-` or `_` becomes clearly slower. diff --git a/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.zh.md b/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.zh.md index 06eacb2a38..96307eb19b 100644 --- a/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.zh.md +++ b/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.zh.md @@ -43,17 +43,18 @@ Status: implemented | 项 | 内容 | |---|---| | 负载 | 无凭据:普通消息,以及散文、代码、base64、Markdown、日志、JSON、shell 长文本。含凭据:每条消息含一种凭据(模型密钥、GitHub 令牌、云密钥 ID、JWT、地址密码、私钥块),以及成批的 `.env` 密钥。对抗:针对每种格式的前缀、长度与分隔规则构造的重复输入,两种长度。全部由常量生成,凭据样本运行时拼接 | -| 计时 | 每个负载先用十组未计入结果的配对求值预热代码路径,再测量五组,交替以基线或凭据匹配器开头;每次求值新建注册表、文档和匹配器实例,只计求值。单文档负载的一个样本取二十次全新求值的平均耗时,5,000 条消息的批量负载则求值一次。判定取两侧各自的最小耗时及同一区间的进程 CPU 时间,报告保留全部测量配对和求平均值的次数。预热不保留匹配器记忆 | +| 计时 | 每个负载先用十组未计入结果的配对预热,每组的求值次数与测量配对相同。五组测量交替以基线或凭据匹配器开头;每次求值新建注册表、文档和匹配器实例,只计求值。单文档样本取二十次求值的平均值,5,000 条消息的样本则整批求值一次。两侧各取样本均值的最小值,并保留对应样本的平均进程 CPU 时间。CI 日志中的 worker JSON 包含全部测量配对和求平均值的次数,HTML 报告展示选中的带凭据匹配器均值。每次求值在计时结束后等待注册表清理完成,再开始下一次,避免清理工作和未释放注册表积累。预热不保留匹配器记忆 | | 分配 | 单独进程,`--expose-gc`,semi-space 固定为大于任何一次求值的大小;预热后 GC,再单独驱动匹配器回答全部查询一遍,取新生代增量;用 GC 事件的时间戳确认区间内没有 GC | | 断言 | 无凭据:绝对预算,以及"有/无凭据匹配器"的耗时比上限;含凭据:绝对预算,密钥数翻倍时的耗时比;对抗:绝对预算与翻倍耗时比;分配:底噪加每个凭据、每个遮挡内换行和每个遮挡字符的上限 | | 排除 | 冷代码编译、React 渲染、浏览器绘制与布局;分配结果只用于断言并输出到控制台,不进 HTML 报告 | 预期值来自 x64 开发机。CI 时间预算按共享比例与余量换算;耗时比、翻倍比和分配预算不随机器缩放。负对照:同一 benchmark 指向正则实现或"每次查询新建记忆"的实现时都会失败。等价性测试经生产调度器逐节点比较本实现与正则实现([regex-oracle.client.ts](../../../../packages/client/ui-secret-mask/tests/regex-oracle.client.ts)),并对每个查询起点单独比较。 +参考常量按五次单独求值的最小值校准;五组样本均值的最小值使用既有绝对耗时余量,不重新校准。求平均值可能使两侧独立最小值的比值向任一方向变化;将匹配器工作量增至三倍的负对照验证 JSON 比值上限仍能拒绝额外扫描。 + ## 考虑过的替代方案 -- **不预热、分批测量基线和匹配器**:亚毫秒基线测量可能先进入优化后的代码,而匹配器新触及的路径还未完成优化;分批测量也会放大时钟频率漂移。逐负载预热、相邻配对和固定批次比较预热后的代码,降低短区间测量的噪声,同时保持文档和匹配器状态全新;所有绝对耗时、比值和分配上限保持固定。 - +- **不预热、分批测量基线和匹配器**:亚毫秒基线测量可能先进入优化后的代码,而匹配器新触及的路径还未完成优化;分批测量也会放大时钟频率漂移。逐负载预热、相邻配对和固定批次比较预热后的代码,降低短区间测量的噪声,同时保持文档和匹配器状态全新。 - **每次查询对每个格式各跑一个带量词的正则**:没有命中的正则每次都扫到文末,凭据多时平方增长;长度量词在失败时回溯。 - **把全部格式合成一个带量词的大正则**:一次扫描能找到最左匹配,但各分支的量词失败时仍回溯。 - **先找稀有字符再回看前缀**:普通文本扫描更快,但成本取决于字符分布,`-`、`_` 密集的文本明显变慢。 diff --git a/benchmarks/user-text/user-text.bench.client.ts b/benchmarks/user-text/user-text.bench.client.ts index 07bc1fe73d..b7235609ce 100644 --- a/benchmarks/user-text/user-text.bench.client.ts +++ b/benchmarks/user-text/user-text.bench.client.ts @@ -103,7 +103,10 @@ describe('sent user text with the credential matcher', () => { return found } const record = (benchmark: BenchmarkCase, measured: UserTextWorkloadTiming, budgetMs: number): void => { - recordTimings(benchmark, { withSecretMs: { ms: measured.withSecretMs, cpuMs: measured.withSecretCpuMs } }, { withSecretMs: budgetMs }) + recordTimings({ + ...benchmark, + measures: `${benchmark.measures} Minimum of ${ATTEMPTS} per-sample means of ${measured.evaluationsPerSample} fresh evaluations after warm-up.`, + }, { withSecretMs: { ms: measured.withSecretMs, cpuMs: measured.withSecretCpuMs } }, { withSecretMs: budgetMs }) } it.each(Object.entries(UNMATCHED_EXPECTED))('evaluates %s without credentials within its budget and ratio', (id, expected) => { diff --git a/benchmarks/user-text/user-text.worker.client.ts b/benchmarks/user-text/user-text.worker.client.ts index 35c6923cdd..354f9b12bb 100644 --- a/benchmarks/user-text/user-text.worker.client.ts +++ b/benchmarks/user-text/user-text.worker.client.ts @@ -37,8 +37,10 @@ export interface UserTextWorkloadTiming { /** Characters inside credential ranges, whose masks the matcher builds. */ readonly maskedCharacters: number readonly baseMs: number + /** Mean process CPU time per evaluation in the fastest baseline sample. */ + readonly baseCpuMs: number readonly withSecretMs: number - /** Process CPU time over the fastest with-credential sample. */ + /** Mean process CPU time per evaluation in the fastest with-credential sample. */ readonly withSecretCpuMs: number readonly ratio: number /** All measured pairs in execution order, excluding warm-up. */ @@ -80,7 +82,7 @@ const FACTS: Readonly> = { sessionLabels: ['会话一'], const KIB = 1024 /** Exercise each workload's code paths before measuring sub-millisecond baseline evaluations. */ const WARMUP_PAIRS = 10 -/** Single-document baselines take less than a millisecond; a fixed batch limits clock and scheduling noise. */ +/** Batch every single-document workload to limit noise in its shortest evaluation intervals. */ const SINGLE_DOCUMENT_EVALUATIONS = 20 const PROSE = '帮我看看这个函数为什么慢,顺便把 README 也更新一下。The quick brown fox jumps over the lazy dog, 1234 times. ' const CODE = 'const value = await fetch(`https://api.example.com/v1/items?id=${id}`); if (!value.ok) throw new Error("bad: " + value.status);\n' @@ -185,80 +187,84 @@ interface Sample { readonly maskedCharacters: number } +interface SamplePair { + readonly base: Sample + readonly secret: Sample +} + /** Rules for one sample; a fresh credential matcher per sample carries no state from an earlier sample. */ type RuleSet = () => readonly UserTextRule[] const withoutSecret: RuleSet = () => BASE_RULES const withSecret: RuleSet = () => [...BASE_RULES, createSecretRule()] -function registryWith(rules: RuleSet): { readonly registry: UserTextRegistry; readonly scope: Context } { - const scope = new Context() - const registry = new UserTextRegistry(scope) - for (const rule of rules()) registry.register(rule) - return { registry, scope } -} - /** - * Evaluate every text once as a fresh document, as a newly rendered message does. + * Evaluate fresh documents; await registry disposal outside the measured interval before returning. * @returns elapsed and CPU milliseconds, credential nodes produced, and their characters. */ -function evaluateAll(rules: RuleSet, texts: readonly string[]): Sample { - const { registry, scope } = registryWith(rules) - let secrets = 0 - let maskedCharacters = 0 - const cpuStart = process.cpuUsage() - const start = performance.now() - for (const text of texts) { - for (const node of registry.createEvaluator()({ text, facts: FACTS })) { - if (node.id !== 'secret') continue - secrets++ - maskedCharacters += node.end - node.start +async function evaluateAll(rules: RuleSet, texts: readonly string[]): Promise { + const scope = new Context() + try { + const registry = new UserTextRegistry(scope) + for (const rule of rules()) registry.register(rule) + let secrets = 0 + let maskedCharacters = 0 + const cpuStart = process.cpuUsage() + const start = performance.now() + for (const text of texts) { + for (const node of registry.createEvaluator()({ text, facts: FACTS })) { + if (node.id !== 'secret') continue + secrets++ + maskedCharacters += node.end - node.start + } } + const ms = performance.now() - start + const cpu = process.cpuUsage(cpuStart) + return { ms, cpuMs: (cpu.user + cpu.system) / 1_000, secrets, maskedCharacters } + } finally { + await scope.fiber.dispose() } - const ms = performance.now() - start - const cpu = process.cpuUsage(cpuStart) - void scope.fiber.dispose() - return { ms, cpuMs: (cpu.user + cpu.system) / 1_000, secrets, maskedCharacters } } -function sample(rules: RuleSet, texts: readonly string[], evaluations: number): Sample { - const first = evaluateAll(rules, texts) +async function sample(rules: RuleSet, texts: readonly string[], evaluations: number): Promise { + const first = await evaluateAll(rules, texts) let ms = first.ms let cpuMs = first.cpuMs for (let index = 1; index < evaluations; index++) { - const next = evaluateAll(rules, texts) + const next = await evaluateAll(rules, texts) ms += next.ms cpuMs += next.cpuMs } return { ...first, ms: ms / evaluations, cpuMs: cpuMs / evaluations } } -function evaluatePair(texts: readonly string[], index: number, evaluations: number): { readonly base: Sample; readonly secret: Sample } { +async function evaluatePair(texts: readonly string[], index: number, evaluations: number): Promise { if (index % 2 === 0) { - const base = sample(withoutSecret, texts, evaluations) - return { base, secret: sample(withSecret, texts, evaluations) } + const base = await sample(withoutSecret, texts, evaluations) + return { base, secret: await sample(withSecret, texts, evaluations) } } - const secret = sample(withSecret, texts, evaluations) - return { base: sample(withoutSecret, texts, evaluations), secret } + const secret = await sample(withSecret, texts, evaluations) + return { base: await sample(withoutSecret, texts, evaluations), secret } } -function measure(texts: readonly string[], attempts: number, evaluations: number): { - readonly base: Sample; readonly secret: Sample; readonly samples: readonly UserTextTimingPair[] -} { - for (let warm = 0; warm < WARMUP_PAIRS; warm++) evaluatePair(texts, warm, evaluations) - let best = evaluatePair(texts, 0, evaluations) - const samples: UserTextTimingPair[] = [] +async function measure(texts: readonly string[], attempts: number, evaluations: number): Promise { + for (let warm = 0; warm < WARMUP_PAIRS; warm++) await evaluatePair(texts, warm, evaluations) + const pairs: SamplePair[] = [] for (let attempt = 0; attempt < attempts; attempt++) { - const pair = attempt === 0 ? best : evaluatePair(texts, attempt, evaluations) - samples.push({ + pairs.push(await evaluatePair(texts, attempt, evaluations)) + } + const best = pairs.reduce((min, pair) => ({ + base: pair.base.ms < min.base.ms ? pair.base : min.base, + secret: pair.secret.ms < min.secret.ms ? pair.secret : min.secret, + })) + return { + ...best, + samples: pairs.map(pair => ({ baseMs: pair.base.ms, baseCpuMs: pair.base.cpuMs, withSecretMs: pair.secret.ms, withSecretCpuMs: pair.secret.cpuMs, - }) - best = { - base: pair.base.ms < best.base.ms ? pair.base : best.base, - secret: pair.secret.ms < best.secret.ms ? pair.secret : best.secret, - } + })), } - return { ...best, samples } } declare const gc: (() => void) | undefined @@ -356,29 +362,28 @@ async function allocations(): Promise { return { workloads: results } } -function timings(attempts: number): UserTextTimeReport { - return { - warmupPairs: WARMUP_PAIRS, - attempts, - workloads: workloads().map(({ id, group, texts }) => { - const evaluationsPerSample = texts.length === 1 ? SINGLE_DOCUMENT_EVALUATIONS : 1 - const { base, secret, samples } = measure(texts, attempts, evaluationsPerSample) - return { - id, - group, - documents: texts.length, - characters: texts.reduce((sum, text) => sum + text.length, 0), - evaluationsPerSample, - secrets: secret.secrets, - maskedCharacters: secret.maskedCharacters, - baseMs: base.ms, - withSecretMs: secret.ms, - withSecretCpuMs: secret.cpuMs, - ratio: secret.ms / base.ms, - samples, - } - }), +async function timings(attempts: number): Promise { + const results: UserTextWorkloadTiming[] = [] + for (const { id, group, texts } of workloads()) { + const evaluationsPerSample = texts.length === 1 ? SINGLE_DOCUMENT_EVALUATIONS : 1 + const { base, secret, samples } = await measure(texts, attempts, evaluationsPerSample) + results.push({ + id, + group, + documents: texts.length, + characters: texts.reduce((sum, text) => sum + text.length, 0), + evaluationsPerSample, + secrets: secret.secrets, + maskedCharacters: secret.maskedCharacters, + baseMs: base.ms, + baseCpuMs: base.cpuMs, + withSecretMs: secret.ms, + withSecretCpuMs: secret.cpuMs, + ratio: secret.ms / base.ms, + samples, + }) } + return { warmupPairs: WARMUP_PAIRS, attempts, workloads: results } } assertBuiltBenchmarkRuntime(import.meta.url, { @@ -387,4 +392,4 @@ assertBuiltBenchmarkRuntime(import.meta.url, { }) const [mode, count] = process.argv.slice(2) if (mode !== 'time' && mode !== 'allocation') throw new Error('usage: user-text.worker.js time | allocation') -console.log(JSON.stringify(mode === 'time' ? timings(positiveInteger(count, 'attempts')) : await allocations())) +console.log(JSON.stringify(mode === 'time' ? await timings(positiveInteger(count, 'attempts')) : await allocations())) From d156ee31a09fc1761cc6334b363ff0340f07b19c Mon Sep 17 00:00:00 2001 From: winewill Date: Tue, 13 Oct 2026 13:59:01 +0800 Subject: [PATCH 05/35] feat(desktop): publish retained packages through approved COS workflow --- .github/workflows/desktop-publish.yml | 108 ++++++++++++++ .github/workflows/macos-package.yml | 29 +++- .github/workflows/windows-package.yml | 29 +++- apps/desktop/packaging/README.i18n.yaml | 4 +- apps/desktop/packaging/README.md | 2 + apps/desktop/packaging/README.zh.md | 2 + apps/desktop/publishing/README.i18n.yaml | 4 +- apps/desktop/publishing/README.md | 2 + apps/desktop/publishing/README.zh.md | 2 + apps/desktop/publishing/ci/README.i18n.yaml | 31 ++++ apps/desktop/publishing/ci/README.md | 68 +++++++++ apps/desktop/publishing/ci/README.zh.md | 68 +++++++++ .../scripts/desktop-package-environment.d.mts | 1 + .../scripts/desktop-package-environment.mjs | 7 + .../desktop/scripts/desktop-publish-config.ts | 46 ++++++ apps/desktop/scripts/desktop-publish-cos.ts | 84 +++++++++++ .../desktop/scripts/desktop-publish-record.ts | 103 +++++++++++++ apps/desktop/scripts/desktop-publish-route.ts | 60 ++++++++ apps/desktop/scripts/desktop-publish-tag.ts | 46 ++++++ apps/desktop/scripts/desktop-publish.ts | 128 ++++++++++++++++ apps/desktop/scripts/desktop-release-store.ts | 139 ++++++++++++++++++ apps/desktop/scripts/desktop-rollback.ts | 54 +++++++ apps/desktop/scripts/desktop-upload-plan.ts | 6 +- apps/desktop/scripts/prepare-macos-ci.mjs | 1 + apps/desktop/scripts/prepare-windows-ci.mjs | 1 + apps/desktop/scripts/publish-desktop-ci.ts | 107 ++++++++++++++ .../scripts/resolve-desktop-publish-ci.ts | 48 ++++++ apps/desktop/scripts/retain-desktop-ci.ts | 79 ++++++++++ .../scripts/rollback-desktop-channel.ts | 53 +++++++ .../scripts/verify-published-windows.ps1 | 16 ++ .../scripts/verify-retained-desktop.ts | 44 ++++++ .../desktop-publication-preflight.spec.ts | 59 ++++++++ .../tests/desktop-publish-config.spec.ts | 47 ++++++ .../desktop/tests/desktop-publish-cos.spec.ts | 40 +++++ .../tests/desktop-publish-record.spec.ts | 67 +++++++++ .../tests/desktop-publish-route.spec.ts | 37 +++++ .../desktop/tests/desktop-publish-tag.spec.ts | 53 +++++++ apps/desktop/tests/desktop-publish.spec.ts | 107 ++++++++++++++ .../tests/desktop-release-store.spec.ts | 70 +++++++++ apps/desktop/tests/desktop-rollback.spec.ts | 56 +++++++ .../desktop/tests/desktop-upload-plan.spec.ts | 14 ++ apps/desktop/tests/retain-desktop-ci.spec.ts | 37 +++++ .../tests/windows-package-preset.spec.ts | 20 +++ scripts/desktop-publish-workflow.spec.ts | 35 +++++ scripts/macos-package-workflow.spec.ts | 6 +- scripts/windows-package-workflow.spec.ts | 11 +- 46 files changed, 2011 insertions(+), 20 deletions(-) create mode 100644 .github/workflows/desktop-publish.yml create mode 100644 apps/desktop/publishing/ci/README.i18n.yaml create mode 100644 apps/desktop/publishing/ci/README.md create mode 100644 apps/desktop/publishing/ci/README.zh.md create mode 100644 apps/desktop/scripts/desktop-publish-config.ts create mode 100644 apps/desktop/scripts/desktop-publish-cos.ts create mode 100644 apps/desktop/scripts/desktop-publish-record.ts create mode 100644 apps/desktop/scripts/desktop-publish-route.ts create mode 100644 apps/desktop/scripts/desktop-publish-tag.ts create mode 100644 apps/desktop/scripts/desktop-publish.ts create mode 100644 apps/desktop/scripts/desktop-release-store.ts create mode 100644 apps/desktop/scripts/desktop-rollback.ts create mode 100644 apps/desktop/scripts/publish-desktop-ci.ts create mode 100644 apps/desktop/scripts/resolve-desktop-publish-ci.ts create mode 100644 apps/desktop/scripts/retain-desktop-ci.ts create mode 100644 apps/desktop/scripts/rollback-desktop-channel.ts create mode 100644 apps/desktop/scripts/verify-published-windows.ps1 create mode 100644 apps/desktop/scripts/verify-retained-desktop.ts create mode 100644 apps/desktop/tests/desktop-publication-preflight.spec.ts create mode 100644 apps/desktop/tests/desktop-publish-config.spec.ts create mode 100644 apps/desktop/tests/desktop-publish-cos.spec.ts create mode 100644 apps/desktop/tests/desktop-publish-record.spec.ts create mode 100644 apps/desktop/tests/desktop-publish-route.spec.ts create mode 100644 apps/desktop/tests/desktop-publish-tag.spec.ts create mode 100644 apps/desktop/tests/desktop-publish.spec.ts create mode 100644 apps/desktop/tests/desktop-release-store.spec.ts create mode 100644 apps/desktop/tests/desktop-rollback.spec.ts create mode 100644 apps/desktop/tests/retain-desktop-ci.spec.ts create mode 100644 scripts/desktop-publish-workflow.spec.ts diff --git a/.github/workflows/desktop-publish.yml b/.github/workflows/desktop-publish.yml new file mode 100644 index 0000000000..b5f6d1ad90 --- /dev/null +++ b/.github/workflows/desktop-publish.yml @@ -0,0 +1,108 @@ +name: Publish Desktop · COS + +on: + workflow_run: + workflows: ['Package Desktop · macOS', 'Package Desktop · Windows'] + types: [completed] + +permissions: + contents: read + actions: read + +jobs: + route: + if: github.event.workflow_run.conclusion == 'success' + runs-on: ubuntu-latest + outputs: + publish: ${{ steps.route.outputs.publish }} + route: ${{ steps.route.outputs.route }} + runner: ${{ steps.route.outputs.runner }} + preset: ${{ steps.route.outputs.preset }} + steps: + - uses: actions/checkout@v7.0.1 + with: + persist-credentials: false + - uses: pnpm/action-setup@v5.0.0 + - uses: actions/setup-node@v7.0.0 + with: + node-version: '24' + - run: pnpm install --frozen-lockfile + - name: Resolve original package and machine + id: route + env: + GH_TOKEN: ${{ github.token }} + DESKTOP_PUBLISH_RUNNERS: ${{ vars.DESKTOP_PUBLISH_RUNNERS }} + run: node --import tsx apps/desktop/scripts/resolve-desktop-publish-ci.ts + + publish: + needs: route + if: needs.route.outputs.publish == 'true' + runs-on: [self-hosted, '${{ needs.route.outputs.runner }}'] + environment: ${{ needs.route.outputs.preset == 'production' && 'desktop-production' || 'desktop-test' }} + concurrency: + group: desktop-publish-${{ needs.route.outputs.runner }}-${{ needs.route.outputs.preset }} + cancel-in-progress: false + timeout-minutes: 90 + env: + DESKTOP_PUBLISH_ROUTE: ${{ needs.route.outputs.route }} + DSH_TELEMETRY_DISABLED: '1' + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@v7.0.1 + with: + persist-credentials: false + clean: false + path: desktop-publisher + - uses: pnpm/action-setup@v5.0.0 + with: + package_json_file: desktop-publisher/package.json + dest: ${{ runner.temp }}/publish-pnpm-${{ github.run_id }}-${{ github.run_attempt }} + - uses: actions/setup-node@v7.0.0 + with: + node-version: '24' + - run: pnpm install --frozen-lockfile + working-directory: desktop-publisher + - name: Verify local package without upload credentials + id: check + working-directory: desktop-publisher + run: node --import tsx apps/desktop/scripts/publish-desktop-ci.ts --check + - name: Read machine GitHub App key + if: needs.route.outputs.preset == 'production' + id: key + env: + APP_KEY_FILE: ${{ steps.check.outputs.app-key-file }} + run: | + node --input-type=module <<'NODE' + import { readFileSync, appendFileSync } from 'node:fs'; + import { randomUUID } from 'node:crypto'; + const key = readFileSync(process.env.APP_KEY_FILE, 'utf8'); + console.log('::add-mask::' + key.replaceAll('%', '%25').replaceAll('\r', '%0D').replaceAll('\n', '%0A')); + const delimiter = randomUUID(); + appendFileSync(process.env.GITHUB_OUTPUT, `private-key<<${delimiter}\n${key}\n${delimiter}\n`); + NODE + - name: Create short-lived tag token + if: needs.route.outputs.preset == 'production' + id: app + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 + with: + client-id: ${{ steps.check.outputs.app-client-id }} + private-key: ${{ steps.key.outputs.private-key }} + owner: ${{ github.repository_owner }} + repositories: ${{ github.event.repository.name }} + permission-contents: write + - name: Publish files, update feeds and record release tag + id: publish + working-directory: desktop-publisher + env: + DESKTOP_TAG_TOKEN: ${{ steps.app.outputs.token }} + run: node --import tsx apps/desktop/scripts/publish-desktop-ci.ts --publish + - name: Retain publication audit + if: always() && steps.publish.outputs.record != '' + uses: actions/upload-artifact@v7.0.1 + with: + name: desktop-publication-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ steps.publish.outputs.record }}/ + if-no-files-found: error + retention-days: 30 diff --git a/.github/workflows/macos-package.yml b/.github/workflows/macos-package.yml index 28dac193e0..2bb5f8fdf2 100644 --- a/.github/workflows/macos-package.yml +++ b/.github/workflows/macos-package.yml @@ -14,6 +14,16 @@ on: type: boolean default: false required: true + publish: + description: 'Publish successful signed packages and update the feed; production requires approval' + type: boolean + default: false + required: true + test_release_id: + description: '测试环境的前缀,用于隔离外部用户访问,留空使用默认配置;测试连续升级时需使用相同值;建议定期轮换默认值。' + type: string + default: '' + required: false build_version: description: 'Version: auto recommended; explicit version must match the checkout product prefix' type: string @@ -38,6 +48,8 @@ jobs: PACKAGE_SKIP_NOTARIZATION: ${{ inputs.skip_notarization }} DSH_TELEMETRY_DISABLED: '1' DSH_PACKAGING_PRESET: ${{ inputs.preset }} + PACKAGE_PUBLISH: ${{ inputs.publish }} + PACKAGE_TEST_RELEASE_ID: ${{ inputs.test_release_id }} steps: - uses: actions/checkout@v7.0.1 id: checkout @@ -71,6 +83,11 @@ jobs: args=() if [[ "$PACKAGE_SKIP_NOTARIZATION" = true ]]; then args+=(--skip-notarization); fi pnpm run package:desktop:mac:arm64 --build-version "$PACKAGE_VERSION" "${args[@]}" + - name: Retain complete package outside the checkout + id: retained + env: + PACKAGE_VERSION: ${{ steps.settings.outputs.version }} + run: node --import tsx apps/desktop/scripts/retain-desktop-ci.ts - name: Retain packaging diagnostics if: always() && steps.checkout.outcome == 'success' uses: actions/upload-artifact@v7.0.1 @@ -86,8 +103,14 @@ jobs: with: name: macos-arm64-${{ steps.settings.outputs.mode }}-${{ steps.settings.outputs.version }}-${{ github.run_id }}-${{ github.run_attempt }} include-hidden-files: true - path: | - apps/desktop/.desktop-build/targets/mac-arm64/${{ steps.settings.outputs.artifacts }}/*.dmg - apps/desktop/.desktop-build/targets/mac-arm64/${{ steps.settings.outputs.artifacts }}/*.zip + path: ${{ steps.retained.outputs.directory }}/* + if-no-files-found: error + retention-days: 14 + - name: Retain publication request + if: success() && inputs.publish + uses: actions/upload-artifact@v7.0.1 + with: + name: desktop-publish-request-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ steps.retained.outputs.directory }}/manifest.json if-no-files-found: error retention-days: 14 diff --git a/.github/workflows/windows-package.yml b/.github/workflows/windows-package.yml index d92472e6b2..9b3cbdb7a3 100644 --- a/.github/workflows/windows-package.yml +++ b/.github/workflows/windows-package.yml @@ -15,6 +15,16 @@ on: options: [unsigned, signed] default: unsigned required: true + publish: + description: 'Publish successful signed packages and update the feed; production requires approval' + type: boolean + default: false + required: true + test_release_id: + description: '测试环境的前缀,用于隔离外部用户访问,留空使用默认配置;测试连续升级时需使用相同值;建议定期轮换默认值。' + type: string + default: '' + required: false build_version: description: 'Version: auto recommended; or checkout product version / e.g. 0.2.1-alpha.1.20261009.1 (prefix must match checkout)' type: string @@ -38,6 +48,8 @@ jobs: env: DSH_TELEMETRY_DISABLED: '1' DSH_PACKAGING_PRESET: ${{ inputs.preset }} + PACKAGE_PUBLISH: ${{ inputs.publish }} + PACKAGE_TEST_RELEASE_ID: ${{ inputs.test_release_id }} PACKAGE_SIGNING: ${{ inputs.signing }} steps: - name: Reset packaging checkout @@ -87,6 +99,11 @@ jobs: if ($env:PACKAGE_SIGNING -eq 'unsigned') { $packageArgs += '--unsigned' } pnpm @packageArgs exit $LASTEXITCODE + - name: Retain complete package outside the checkout + id: retained + env: + PACKAGE_VERSION: ${{ steps.settings.outputs.version }} + run: node --import tsx apps/desktop/scripts/retain-desktop-ci.ts - name: Retain packaging diagnostics if: always() && steps.checkout.outcome == 'success' uses: actions/upload-artifact@v7.0.1 @@ -102,7 +119,7 @@ jobs: with: name: windows-x64-unsigned-${{ steps.settings.outputs.version }}-${{ github.run_id }}-${{ github.run_attempt }} include-hidden-files: true - path: apps/desktop/.desktop-build/targets/win-x64/unsigned-artifacts/*.exe + path: ${{ steps.retained.outputs.directory }}/* if-no-files-found: error retention-days: 14 - name: Retain verified signed installer @@ -111,6 +128,14 @@ jobs: with: name: windows-x64-signed-${{ steps.settings.outputs.version }}-${{ github.run_id }}-${{ github.run_attempt }} include-hidden-files: true - path: apps/desktop/.desktop-build/targets/win-x64/artifacts/*.exe + path: ${{ steps.retained.outputs.directory }}/* + if-no-files-found: error + retention-days: 14 + - name: Retain publication request + if: success() && inputs.publish + uses: actions/upload-artifact@v7.0.1 + with: + name: desktop-publish-request-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ steps.retained.outputs.directory }}/manifest.json if-no-files-found: error retention-days: 14 diff --git a/apps/desktop/packaging/README.i18n.yaml b/apps/desktop/packaging/README.i18n.yaml index 1cd123a28c..a4eff8cc20 100644 --- a/apps/desktop/packaging/README.i18n.yaml +++ b/apps/desktop/packaging/README.i18n.yaml @@ -21,8 +21,8 @@ en: d80a92c02a583b80 zh: b27f75ff29fcea1a /desktop-packaging/configuration-and-local-builds: - en: e4f526604231ed43 - zh: d412179ffdc402ba + en: f5dc36baf414ecc5 + zh: 8984741b356ac1d3 /desktop-packaging/windows-actions-packaging: en: e3f51e7130a0fe05 zh: 70fd6a40d5899871 diff --git a/apps/desktop/packaging/README.md b/apps/desktop/packaging/README.md index e1633e6cbd..b6548275c5 100644 --- a/apps/desktop/packaging/README.md +++ b/apps/desktop/packaging/README.md @@ -69,6 +69,8 @@ Version derivation does not change the fixed update channel or `nightly.yml` / ` Set `DSH_PACKAGING_CONFIG_DIR` to the absolute machine configuration directory and `DSH_PACKAGING_PRESET` to `test` or `production` for local Windows commands. The loader reads `presets/.env`; signed commands additionally read `secrets/windows-signing.env`. Relative certificate and SignTool paths resolve beside the file declaring them. Public presets reject credentials, and the signing file cannot override public settings. Missing configuration fails without falling back to `.env.windows`; commands without a preset retain the existing dotenv behavior. Unsigned and preparation-only presets never read the signing file. Cache maintenance also reads only public preset settings. Test signed packages also require the existing download origin and release ID, but no upload credentials. Local preset commands with `--build-version auto` number from local artifacts only; Actions uses its run number and attempt. +Both manual packaging workflows accept `test_release_id`. Blank uses the machine preset; a nonblank value must contain 32 lowercase hexadecimal characters and requires the test preset. The loader applies it through `PACKAGE_TEST_RELEASE_ID` without changing the preset file. Keep the same value for successive upgrade tests on that platform. Rotating the default affects future builds only: installed clients retain their original update address, and old objects remain accessible until separately removed. A random prefix reduces discoverability but does not authenticate downloads. + Outside machine-preset mode, packaging, upload, and manual macOS signature verification (except local unsigned macOS builds) read `apps/desktop/.env.windows` or `.env.macos`, selected by target platform. Copy the [Windows template](../.env.windows.example) or [macOS template](../.env.macos.example) and fill in the local settings; Git ignores both local files, and packaged artifacts exclude them. Release fields come only from the selected configuration files, without fallback to system or shell variables; `PATH`, proxies, and build-tool settings remain inherited. The published version is an argument rather than a release field, and upload reads it from the completion record the packaging run wrote. Files use UTF-8 with optional BOM; relative certificate, SignTool, Apple API key, and keychain paths resolve from `apps/desktop`, values are not shell-expanded, and passwords containing `#` or spaces need quotes. Windows CI reads the same machine presets. Every package command checks the application ID and its mode-specific update and signing configuration before building or downloading, then probes the external tools the run will use: the archive reader, and on a Windows target the installer compiler. macOS checks the identity, Team ID, one complete notarization strategy, readable local `CSC_LINK` p12 file, explicit `CSC_KEY_PASSWORD`, and referenced API key and keychain files; Windows checks the public code-signing certificate, SignTool file, container name, and PIN format. Windows preparation-only builds and explicit unsigned builds on either platform do not require signing credentials. Configuration checks do not authenticate the PIN, log in to the token, unlock a keychain, or contact Apple; actual signing and notarization perform those checks. Without a machine preset, `--build-version auto` contacts the destination bucket, including under `--check`. Run the same checks separately: diff --git a/apps/desktop/packaging/README.zh.md b/apps/desktop/packaging/README.zh.md index c5458b979b..83df32e75c 100644 --- a/apps/desktop/packaging/README.zh.md +++ b/apps/desktop/packaging/README.zh.md @@ -69,6 +69,8 @@ production 发布使用产品版本本身,不传 `--build-version`。其上传 本地 Windows 命令通过 `DSH_PACKAGING_CONFIG_DIR` 指定机器配置绝对目录,通过 `DSH_PACKAGING_PRESET` 选择 `test` 或 `production`。加载器读取 `presets/.env`;签名命令另读 `secrets/windows-signing.env`。相对证书和 SignTool 路径以声明它们的文件所在目录为基准。公开 preset 拒绝凭据,签名文件不能覆盖公开配置。配置缺失立即失败,不回退到 `.env.windows`;不选择 preset 的命令保留原 dotenv 行为。未签名和仅准备 preset 不读取签名文件。缓存维护也只读取公开 preset 配置。签名测试包还需现有下载 origin 和 release ID,但无需上传凭据。本地 preset 命令使用 `--build-version auto` 时仅按本地产物编号;Actions 使用运行编号和重试次数。 +两个手动打包工作流都接受 `test_release_id`。留空沿用机器 preset;非空值必须是 32 位小写十六进制字符串,且仅允许 test preset。加载器通过 `PACKAGE_TEST_RELEASE_ID` 应用覆盖值,不修改 preset 文件。同一平台连续升级测试需使用相同值。轮换默认值只影响后续构建:已安装客户端保留原更新地址,旧对象仍可访问,除非另行删除。随机前缀降低地址的可发现性,但不提供下载身份认证。 + 不选择机器 preset 时,打包、上传以及手动 macOS 签名检查(本地 macOS 未签名构建除外)使用 `apps/desktop/.env.windows` 或 `.env.macos`,由目标平台选择。复制对应的 [Windows 模板](../.env.windows.example) 或 [macOS 模板](../.env.macos.example),填写本机配置;Git 忽略这两个本地文件,安装产物也不包含它们。发布字段只从选定的配置文件读取,不回退到系统或 shell 中的同名变量;`PATH`、代理和构建工具环境仍保留。发布版本是命令参数而非发布字段,上传从打包写下的完成记录中读取它。文件使用 UTF-8,支持 BOM;相对证书、SignTool、Apple API Key 和钥匙串路径以 `apps/desktop` 为基准,变量值不做 shell 展开,包含 `#` 或空格的密码需要引号。Windows CI 读取相同的机器 preset。 每条打包命令在构建与下载前检查应用 ID 以及该模式需要的更新与签名配置,随后探测本次运行要用的外部工具:归档读取工具,以及 Windows 目标的安装器编译器。macOS 检查身份、Team ID、一套完整公证凭据、`CSC_LINK` 指定的可读本地 p12 文件、显式配置的 `CSC_KEY_PASSWORD`,以及引用的 API Key 和钥匙串文件;Windows 检查公开代码签名证书、SignTool 文件、容器名称和 PIN 格式。仅准备 Windows 资源以及两个平台上的显式未签名打包均不要求签名凭据。配置检查不验证 PIN 是否正确、Token 是否登录、钥匙串是否解锁或 Apple 是否接受凭据;实际签名与公证负责这些检查。未选择机器 preset 时,`--build-version auto` 会访问目标 bucket,`--check` 下同样如此。单独运行相同检查: diff --git a/apps/desktop/publishing/README.i18n.yaml b/apps/desktop/publishing/README.i18n.yaml index 570a6157aa..aa8eb8df42 100644 --- a/apps/desktop/publishing/README.i18n.yaml +++ b/apps/desktop/publishing/README.i18n.yaml @@ -12,8 +12,8 @@ en: 722633597f74ace6 zh: 5303bcf5567a8511 /desktop-publication/table-of-contents: - en: 64af8f421bc63580 - zh: 69844c0a0d4771f6 + en: f8b8485c16ef4dbb + zh: e6196274c5a246fe /desktop-publication/publication-prerequisites: en: b407de63118c9bf9 zh: de19f1ba9feacbbf diff --git a/apps/desktop/publishing/README.md b/apps/desktop/publishing/README.md index 0a698741fa..0cd51400cc 100644 --- a/apps/desktop/publishing/README.md +++ b/apps/desktop/publishing/README.md @@ -19,6 +19,8 @@ Publish completed Desktop installers and update feeds to the selected download s - [Upload credentials](#credentials) - [Upload audit and recovery](#audit-and-recovery) +For retained self-hosted builds, use the [CI publication workflow](ci/README.md), which keeps installers on their original machine and supports resumable publication. + ## Publication prerequisites diff --git a/apps/desktop/publishing/README.zh.md b/apps/desktop/publishing/README.zh.md index 7f7f230065..7d11255494 100644 --- a/apps/desktop/publishing/README.zh.md +++ b/apps/desktop/publishing/README.zh.md @@ -19,6 +19,8 @@ description: "将完整 Desktop 安装包与更新清单发布到选定下载服 - [上传凭据](#credentials) - [上传审计与恢复](#audit-and-recovery) +自托管留存构建使用 [CI 发布工作流](ci/README.zh.md),在原机器保留安装包并支持恢复发布。 + ## 发布前提 diff --git a/apps/desktop/publishing/ci/README.i18n.yaml b/apps/desktop/publishing/ci/README.i18n.yaml new file mode 100644 index 0000000000..971919bd98 --- /dev/null +++ b/apps/desktop/publishing/ci/README.i18n.yaml @@ -0,0 +1,31 @@ +# Bilingual-pair consistency record for README.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write apps/desktop/publishing/ci/README.md +/: + en: 5bbdeb4ea94502b4 + zh: 107894e10c74d22d +/desktop-ci-publication: + en: a610b66106849734 + zh: bc1760791f56fa28 +/desktop-ci-publication/summary: + en: 51eb8286f4a0ae4c + zh: 9afc15bdcf1068c5 +/desktop-ci-publication/table-of-contents: + en: 1b6a4d7146879eec + zh: f2520fc412cd2d2a +/desktop-ci-publication/machine-setup: + en: 9fef91f1957219d7 + zh: df2c2038eb8a72ed +/desktop-ci-publication/package-and-publish: + en: 596fa81b1033ce87 + zh: fde2729de6275764 +/desktop-ci-publication/recovery-and-records: + en: 7617b2f746783d63 + zh: dcf0b081d740224d +/desktop-ci-publication/restore-a-channel: + en: 5e23b45714a6c8d4 + zh: 882427a071907981 +/desktop-ci-publication/dev-note: + en: b75c8458928b72b9 + zh: 003ba4e17b4b5269 diff --git a/apps/desktop/publishing/ci/README.md b/apps/desktop/publishing/ci/README.md new file mode 100644 index 0000000000..64095911d0 --- /dev/null +++ b/apps/desktop/publishing/ci/README.md @@ -0,0 +1,68 @@ +--- +description: "Operate retained Desktop package publication from dedicated macOS and Windows runners." +--- + +# Desktop CI publication + +English | [中文](README.zh.md) + +## Summary + +Publish a completed package from its original machine without downloading its installer again. Both test and production publish binaries followed by update YAML. Production requires GitHub Environment approval and a GitHub App tag. Real COS, CDN and administrator-permission acceptance remain deployment checks; local fixtures do not establish those permissions. + +## Table of Contents + +- [Machine setup](#machine-setup) +- [Package and publish](#package-and-publish) +- [Recovery and records](#recovery-and-records) +- [Restore a channel](#restore-a-channel) + + + +## Machine setup + +Keep `DSH_PACKAGING_CONFIG_DIR` pointed at the existing machine packaging directory. Completed attempts are copied to its sibling `releases///`; `DSH_DESKTOP_RELEASES_DIR` overrides that root with an absolute path. The inventory includes installers, update archives, blockmaps, completion metadata and a credential-free manifest. Debug packages are retained but cannot publish. Copies are checked before the attempt directory becomes visible, and an existing attempt cannot be replaced. GitHub retains the same file inventory for 14 days. Local files remain until an operator removes them; do not delete an attempt while it is being verified or published. + +Create `publishing/test.json` and, when authorized, `publishing/production.json` below the configuration directory. Each declares `bucket`, HTTPS `origin` without a path, and an absolute `credentialsFile`. The credential file contains only `secretId` and `secretKey`. macOS also requires `macOS.signingIdentity` and `macOS.teamId`; Windows requires `windows.certificateThumbprint`. Production requires the exact origin `https://download.deepseek.com` and `githubApp.clientId` plus an absolute `githubApp.privateKeyFile`. These files stay outside Git. The uploader supports permanent COS credentials, not STS session credentials. Bucket versioning must be disabled for immutable binary writes. + +Set the repository variable `DESKTOP_PUBLISH_RUNNERS` to a JSON mapping of registered runner names to unique labels and targets, for example `{"mini":{"label":"dsh-mini-publish","target":"mac-arm64"}}`. Assign that label to exactly that runner. Windows uses target `win-x64`. The routing job checks the original successful job, and the publisher checks its own runner name against the recorded name. Machines need no shared filesystem or LAN connection to each other. + +An administrator must create `desktop-production` with required reviewers and an allowed `master` deployment branch. The workflow checks that required reviewers exist before scheduling production. The GitHub App needs installation access to this repository and Contents write permission, plus permission under the `desktop-v*` tag ruleset to create release tags. Reviewer approval does not transfer the reviewer's tag permissions to the App. Missing configuration stops publication; there is no personal-token fallback. The App action creates and later revokes a short-lived token. Protect the local private key and COS credential files with the publishing account's filesystem permissions. + +Environment approval gates this workflow, not arbitrary code running under the same machine account. Account isolation and forced source-branch restrictions are separate operational work. Keep existing release Environments unchanged. The test job uses `desktop-test` without requiring production approval. + + + +## Package and publish + +Select the platform's `Package Desktop` workflow, preset and version. The optional `test_release_id` is the test URL prefix: blank retains the machine default, successive upgrade tests on that platform need the same value, and defaults should be rotated periodically. It is not a GitHub run ID. See [packaging configuration](../../packaging/README.md#configuration-and-local-builds) for its validation and rotation limits. + +Select `publish` to request publication after packaging succeeds. Windows must be signed; macOS must finish notarization and stapling. The separate `Publish Desktop · COS` workflow reads the small request manifest from GitHub, binds it to the source run and attempt, and routes to the original machine. It uses the locally retained installers. A failed build or one without a publication request produces no upload. + +Before uploading, the publisher hashes all retained files, checks version and destination metadata, and rechecks the platform signatures. macOS verifies the DMG and extracts the ZIP to verify the app's ticket; Windows checks Authenticode, the configured signer thumbprint and the timestamp. The production job waits for a reviewer to select **Review deployments → Approve and deploy** before running. Both deployments upload missing binaries, verify remote bytes, then update YAML. There is no files-only mode in this workflow. Production finally creates `desktop-v` at the recorded packaging commit; an existing tag must name the same commit. + + + +## Recovery and records + +Re-run the publishing workflow after resolving a failure; do not rebuild solely to retry an upload. Its source package run and attempt remain fixed. Missing objects are uploaded, identical objects are skipped, and conflicting same-name bytes stop the operation. Unknown write outcomes are reconciled by reading remote bytes. A newer channel version prevents a stale upload from replacing it. + +The sibling `upload-records/` directory retains an operation identity, newline-delimited events and terminal result. Feed-write intents retain both previous and requested YAML. Writes and readback are recorded before proceeding; missing terminal results mean interruption, not success. Actions also keeps its operation record as a diagnostic artifact for 30 days. These local records are not tamper-proof. COS readback verifies storage bytes, not public CDN behavior; public download acceptance is separate. + +If the feed completed but tagging failed, the result is `published-tag-pending` and the job fails visibly. On retry, durable feed-completion evidence permits only tag recovery, even if a later release has advanced the channel. It never re-publishes old YAML. An existing conflicting tag requires administrator investigation and is never moved automatically. + +Normal publication and manual restoration acquire the same local channel lock. A crash can leave a lock directory under the sibling `publish-locks/`; verify no writer is running before manually removing it. A runner label alone does not coordinate writers on other machines. Keep one publishing machine per channel and preserve its records when migrating. + + + +## Restore a channel + +Run the repository script `apps/desktop/scripts/rollback-desktop-channel.ts` through `node --import tsx` on the publishing machine. Keep `GITHUB_REPOSITORY`, `RUNNER_NAME`, `DSH_PACKAGING_CONFIG_DIR` and `DESKTOP_PUBLISH_ROUTE` set to the original package context. The route JSON contains `runId`, `attempt`, `commit`, `runnerName`, `target` and `preset`, available in the retained manifest's source fields. Pass `--record` with that successful publication's audit directory, `--expected-version` with the version currently online, and `--reason` with the operational reason. + +Without `--execute`, the script reads remote objects and prints the exact YAML change without writing. Preview requires COS read credentials. With `--execute`, it verifies historical files, preserves before/after YAML in a new audit record, checks the current channel again, and restores only YAML. It neither uploads binaries nor changes tags. A different current version aborts the operation. Restoration is not a multi-object transaction; inspect the audit after any partial failure before continuing. + +The Desktop client disables automatic downgrade. Restoring an old feed can stop additional clients receiving a bad release; clients already on a newer version do not downgrade. A higher-version corrective release is a separate recovery path. + +## Dev Note + +None. diff --git a/apps/desktop/publishing/ci/README.zh.md b/apps/desktop/publishing/ci/README.zh.md new file mode 100644 index 0000000000..c648dde54e --- /dev/null +++ b/apps/desktop/publishing/ci/README.zh.md @@ -0,0 +1,68 @@ +--- +description: "在专用 macOS 和 Windows runner 上发布留存的 Desktop 安装包。" +--- + +# Desktop CI 发布 + +[English](README.md) | 中文 + +## 概述 + +在原打包机器上发布完整安装包,无需再次下载安装包。test 和 production 都先发布二进制文件,再更新 YAML。production 需要 GitHub Environment 审批,并通过 GitHub App 创建 tag。真实 COS、CDN 和管理员权限仍需部署验收;本地 fixture 不证明这些权限可用。 + +## 目录 + +- [机器配置](#machine-setup) +- [打包与发布](#package-and-publish) +- [恢复与记录](#recovery-and-records) +- [恢复频道](#restore-a-channel) + + + +## 机器配置 + +保持 `DSH_PACKAGING_CONFIG_DIR` 指向现有机器打包目录。完成的构建复制到其同级 `releases///`;`DSH_DESKTOP_RELEASES_DIR` 可用绝对路径覆盖该根目录。文件清单包含安装包、更新归档、blockmap、完成记录和不含凭据的 manifest。调试包会留存,但不能发布。复制内容通过校验后才开放 attempt 目录,已有 attempt 不能被替换。GitHub 保留相同文件清单 14 天。本地文件保留到操作员删除;校验或发布期间不要删除对应 attempt。 + +在配置目录下创建 `publishing/test.json`,获得授权后再创建 `publishing/production.json`。每份配置声明 `bucket`、不带路径的 HTTPS `origin` 和绝对路径 `credentialsFile`。凭据文件只包含 `secretId` 和 `secretKey`。macOS 还需 `macOS.signingIdentity` 和 `macOS.teamId`;Windows 需要 `windows.certificateThumbprint`。production 必须使用 origin `https://download.deepseek.com`,并提供 `githubApp.clientId` 和绝对路径 `githubApp.privateKeyFile`。这些文件不进入 Git。上传器支持长期 COS 凭据,不支持 STS 会话凭据。不可覆盖的二进制写入要求 bucket 版本控制关闭。 + +将仓库变量 `DESKTOP_PUBLISH_RUNNERS` 配置为已注册 runner 名称到唯一标签和目标的 JSON 映射,例如 `{"mini":{"label":"dsh-mini-publish","target":"mac-arm64"}}`。该标签只能分配给这台 runner。Windows 使用目标 `win-x64`。路由 job 检查原构建成功的 job,发布器再检查自身 runner 名称与记录一致。机器之间不需要共享文件系统或局域网连接。 + +管理员必须创建 `desktop-production`,设置必需审批人并允许 `master` 部署分支。工作流调度 production 前会检查必需审批人已配置。GitHub App 需要安装到本仓库、具有 Contents write 权限,并获准在 `desktop-v*` tag ruleset 下创建发布 tag。审批不会把审批人的 tag 权限传给 App。缺少配置会停止发布,不回退到个人 token。App action 创建短期 token,并在结束后撤销。使用发布账户的文件系统权限保护本机私钥及 COS 凭据文件。 + +Environment 审批限制此工作流,不限制同一机器账户运行的任意代码。账户隔离和强制源分支限制属于独立运维工作。保持现有发布 Environment 不变。test job 使用 `desktop-test`,无需 production 审批。 + + + +## 打包与发布 + +选择对应平台的 `Package Desktop` 工作流、preset 和版本。可选 `test_release_id` 是测试 URL 前缀:留空沿用机器默认值,同一平台连续升级测试需保持相同值,并应定期轮换默认值。它不是 GitHub run ID。格式校验和轮换限制见[打包配置](../../packaging/README.zh.md#configuration-and-local-builds)。 + +选中 `publish`,请求在打包成功后发布。Windows 必须签名;macOS 必须完成公证和 stapling。独立的 `Publish Desktop · COS` 工作流从 GitHub 读取小型请求 manifest,将其与原 run 和 attempt 核对,再路由到原机器。安装包使用本机留存文件。构建失败或未请求发布时不会上传。 + +上传前,发布器校验全部留存文件的哈希、版本及目标信息,再复核平台签名。macOS 校验 DMG,并解压 ZIP 校验 app 票据;Windows 校验 Authenticode、配置的签名证书指纹及时间戳。production job 等待审批人选择 **Review deployments → Approve and deploy** 后才运行。两种环境都上传缺失文件、校验远端字节,再更新 YAML。此工作流没有只上传文件的模式。production 最后为记录的打包提交创建 `desktop-v`;已有 tag 必须指向同一提交。 + + + +## 恢复与记录 + +解决故障后重跑发布工作流,不要仅为重试上传而重新打包。原 package run 和 attempt 保持不变。缺失对象会上传,相同对象跳过,同名但字节不同的对象会停止操作。写入结果不明时通过读取远端字节核对。频道已有更高版本时,旧上传不能替换它。 + +同级 `upload-records/` 目录保留操作标识、逐行事件和最终结果。频道写入意图同时记录先前和目标 YAML。写入和回读在继续操作前落盘;没有最终结果代表中断,不代表成功。Actions 还将操作记录保存为诊断 artifact,保留 30 天。这些本地记录不防篡改。COS 回读验证存储字节,不代表公开 CDN 行为;公开下载需单独验收。 + +频道更新完成但 tag 失败时,结果为 `published-tag-pending`,job 明确失败。重试时,已落盘的频道完成证据只允许恢复 tag,即使后来发布已推进频道,也不会重新发布旧 YAML。已有冲突 tag 需要管理员调查,不会自动移动。 + +正常发布和手动恢复获取同一把本地频道锁。崩溃可能在同级 `publish-locks/` 下留下锁目录;手动删除前确认没有写入任务运行。runner 标签本身不能协调其他机器的写入。每个频道保持单一发布机器,迁移时保留记录。 + + + +## 恢复频道 + +在发布机器上通过 `node --import tsx` 运行仓库脚本 `apps/desktop/scripts/rollback-desktop-channel.ts`。保持 `GITHUB_REPOSITORY`、`RUNNER_NAME`、`DSH_PACKAGING_CONFIG_DIR` 和 `DESKTOP_PUBLISH_ROUTE` 对应原打包上下文。route JSON 包含 `runId`、`attempt`、`commit`、`runnerName`、`target` 和 `preset`,可从留存 manifest 的 source 字段取得。`--record` 指定该次成功发布的审计目录,`--expected-version` 指定当前线上版本,`--reason` 填写操作原因。 + +不带 `--execute` 时,脚本读取远端对象并打印确切 YAML 变更,不执行写入。预览需要 COS 读取凭据。带 `--execute` 时,脚本校验历史文件,将前后 YAML 保存在新的审计记录中,再次检查当前频道,然后仅恢复 YAML。它不上传二进制文件,也不改变 tag。当前版本不同会中止操作。恢复不是多对象事务;部分失败后需先检查审计记录再继续。 + +Desktop 客户端禁用自动降级。恢复旧 feed 可以阻止更多客户端获得问题版本,已经安装更高版本的客户端不会降级。发布更高版本号的修正版是独立恢复方式。 + +## 开发备注 + +无。 diff --git a/apps/desktop/scripts/desktop-package-environment.d.mts b/apps/desktop/scripts/desktop-package-environment.d.mts index b8c6887657..995c117d07 100644 --- a/apps/desktop/scripts/desktop-package-environment.d.mts +++ b/apps/desktop/scripts/desktop-package-environment.d.mts @@ -9,6 +9,7 @@ export function localMacOSPackageEnvironment(environment?: NodeJS.ProcessEnv): N /** * Read a machine preset when selected, otherwise the target's required dotenv; no ambient release fallback. + * PACKAGE_TEST_RELEASE_ID overrides the test preset only; blank retains its configured ID. * @param platform Target platform. * @param environment Parent environment, retained only for unrelated build tools. * @param appRoot Desktop application directory; relative credential paths resolve here. diff --git a/apps/desktop/scripts/desktop-package-environment.mjs b/apps/desktop/scripts/desktop-package-environment.mjs index f3522be26d..89540b7363 100644 --- a/apps/desktop/scripts/desktop-package-environment.mjs +++ b/apps/desktop/scripts/desktop-package-environment.mjs @@ -35,6 +35,7 @@ export function localMacOSPackageEnvironment(environment = process.env) { /** * Read a machine preset when selected, otherwise the target's required dotenv; no ambient release fallback. + * PACKAGE_TEST_RELEASE_ID overrides the test preset only; blank retains its configured ID. * @param {'win32' | 'darwin'} platform Target platform. * @param {NodeJS.ProcessEnv} environment Parent environment, retained only for unrelated build tools. * @param {string} appRoot Desktop application directory; relative credential paths resolve here. @@ -67,6 +68,12 @@ export function loadDesktopPackageEnvironment(platform, environment = process.en : MACOS_SETTING.test(name) && !publicPlatformSetting.test(name))) } } + const releaseId = environment.PACKAGE_TEST_RELEASE_ID?.trim() + if (releaseId) { + if (preset !== 'test') throw new Error('desktop package: test release ID override requires the test preset') + if (!/^[a-f0-9]{32}$/u.test(releaseId)) throw new Error('desktop package: test release ID must contain 32 lowercase hexadecimal characters') + settings.DOWNLOAD_TEST_RELEASE_ID = releaseId + } return { ...Object.fromEntries(Object.entries(environment).filter(([name]) => !AMBIENT_RELEASE_SETTING.test(name))), ...settings, diff --git a/apps/desktop/scripts/desktop-publish-config.ts b/apps/desktop/scripts/desktop-publish-config.ts new file mode 100644 index 0000000000..f5d9a314da --- /dev/null +++ b/apps/desktop/scripts/desktop-publish-config.ts @@ -0,0 +1,46 @@ +/** Read public deployment settings independently from upload and GitHub App secrets. */ +import { readFile } from 'node:fs/promises' +import { isAbsolute, join } from 'node:path' +import { z } from 'zod' + +const singleLine = z.string().min(1).regex(/^[^\r\n\0]+$/u, 'expected a single-line value') +const absolutePath = singleLine.refine(isAbsolute, 'expected an absolute path') +const schema = z.strictObject({ + bucket: z.string().regex(/^[a-z0-9-]+-\d+$/u), + origin: z.url().refine((value) => { const url = new URL(value); return url.protocol === 'https:' && !url.username && !url.password && url.pathname === '/' && !url.search && !url.hash }, 'expected an HTTPS origin'), + credentialsFile: absolutePath, + macOS: z.strictObject({ signingIdentity: z.string().min(1), teamId: z.string().regex(/^[A-Z0-9]{10}$/u) }).optional(), + windows: z.strictObject({ certificateThumbprint: z.string().regex(/^[A-Fa-f0-9]{40}$/u) }).optional(), + githubApp: z.strictObject({ clientId: singleLine, privateKeyFile: absolutePath }).optional(), +}) +/** Public machine configuration; all secret fields are file references. */ +export type DesktopPublishConfig = z.infer + +/** + * Read one named deployment without opening referenced credential files. + * @param configRoot Absolute machine packaging configuration directory. + * @param preset Selected deployment from the retained package. + * @returns Validated public configuration; production requires App configuration. + */ +export async function readDesktopPublishConfig(configRoot: string, preset: 'test' | 'production'): Promise { + if (!isAbsolute(configRoot)) throw new Error('desktop publish: configuration root must be absolute') + const config = schema.parse(JSON.parse(await readFile(join(configRoot, 'publishing', `${preset}.json`), 'utf8'))) + if (preset === 'production' && (config.origin !== 'https://download.deepseek.com' || !config.githubApp)) { + throw new Error('desktop publish: production requires its fixed origin and GitHub App configuration') + } + return config +} + +/** + * Read upload credentials only after authorization and local validation have passed. + * @param config Previously validated public deployment configuration. + * @returns Explicit COS credential pair; contents never enter audit records. + */ +export async function readDesktopUploadCredentials(config: DesktopPublishConfig): Promise<{ secretId: string; secretKey: string }> { + try { + return z.strictObject({ secretId: z.string().min(1), secretKey: z.string().min(1) }) + .parse(JSON.parse(await readFile(config.credentialsFile, 'utf8'))) + } catch { + throw new Error('desktop publish: cannot read valid upload credentials from the configured file') + } +} diff --git a/apps/desktop/scripts/desktop-publish-cos.ts b/apps/desktop/scripts/desktop-publish-cos.ts new file mode 100644 index 0000000000..5b89a8ef40 --- /dev/null +++ b/apps/desktop/scripts/desktop-publish-cos.ts @@ -0,0 +1,84 @@ +/** COS transport for resumable Desktop publication; callers authorize writes and own the channel lock. */ +import { createHash } from 'node:crypto' +import { createReadStream } from 'node:fs' +import { Readable, Writable } from 'node:stream' +import type COS from 'cos-nodejs-sdk-v5' +import { createDesktopCos, DESKTOP_COS_REGION, type DesktopCosCredentials } from './desktop-cos.ts' +import { cosOperation } from './cos-operation.ts' +import type { DesktopUploadPlan } from './desktop-upload-plan.ts' +import { desktopArtifactDigest, type DesktopPublishTransport } from './desktop-publish.ts' + +function missing(error: unknown): boolean { + return typeof error === 'object' && error !== null && 'code' in error && error.code === 'NoSuchKey' +} + +/** + * Create a bounded COS transport restricted to the validated plan's object keys. + * @param plan Destination and exact object inventory; arbitrary keys are rejected. + * @param credentials Credentials for this deployment only. + * @param createClient Client factory; absent uses the shared production client, tests supply a loopback client. + * @returns Streaming reads and single-attempt writes; transport construction performs no requests. + */ +export function desktopPublishCos( + plan: DesktopUploadPlan, credentials: DesktopCosCredentials, + createClient: (credentials: DesktopCosCredentials) => COS = createDesktopCos, +): DesktopPublishTransport { + const keys = new Map(plan.artifacts.map(artifact => [artifact.key, artifact.channelMetadata])) + const client = () => createClient(credentials) + const location = (key: string) => { + if (!keys.has(key)) throw new Error('desktop publish: object is outside the selected plan') + return { Bucket: plan.bucket, Region: DESKTOP_COS_REGION, Key: key } + } + return { + async read(key, channel) { + const address = location(key) + if (keys.get(key) !== channel) throw new Error('desktop publish: object role mismatch') + const sha512 = createHash('sha512') + const chunks: Buffer[] = [] + let size = 0 + const output = new Writable({ + write(bytes: Buffer, _encoding, done) { + size += bytes.length + if (channel && size > 1024 * 1024) { done(new Error('desktop publish: channel metadata exceeds 1 MiB')); return } + sha512.update(bytes) + if (channel) chunks.push(Buffer.from(bytes)) + done() + }, + }) + try { + const cos = client() + await cosOperation(cos, 900_000, () => cos.getObject({ ...address, Output: output })) + return { size, sha512: sha512.digest('base64'), ...(channel ? { contents: Buffer.concat(chunks).toString('utf8') } : {}) } + } catch (error) { + if (missing(error)) return null + throw error + } finally { output.destroy() } + }, + async put(artifact, digest, forbidOverwrite) { + const address = location(artifact.key) + if (keys.get(artifact.key) !== artifact.channelMetadata || forbidOverwrite === artifact.channelMetadata) { + throw new Error('desktop publish: object write policy mismatch') + } + const actual = await desktopArtifactDigest(artifact) + if (actual.size !== digest.size || actual.sha512 !== digest.sha512) throw new Error('desktop publish: local input changed') + const cos = client() + // COS overwrite prevention requires versioning to be absent, not enabled or suspended. + if (forbidOverwrite) { + const state = await cosOperation(cos, 30_000, () => cos.getBucketVersioning({ Bucket: plan.bucket, Region: DESKTOP_COS_REGION })) + if (state.statusCode !== 200 || state.VersioningConfiguration.Status !== undefined) { + throw new Error('desktop publish: immutable objects require bucket versioning to be disabled') + } + } + const md5 = createHash('md5') + const bytes = artifact.contents === undefined ? createReadStream(artifact.path) : [Buffer.from(artifact.contents)] + for await (const chunk of bytes) md5.update(chunk) + const body = artifact.contents === undefined ? createReadStream(artifact.path) : Readable.from([Buffer.from(artifact.contents)]) + try { + await cosOperation(cos, 900_000, () => cos.putObject({ + ...address, Body: body, ContentLength: digest.size, ContentType: artifact.contentType, + Headers: { 'Content-MD5': md5.digest('base64'), ...(forbidOverwrite ? { 'x-cos-forbid-overwrite': 'true' } : {}) }, + })) + } finally { body.destroy() } + }, + } +} diff --git a/apps/desktop/scripts/desktop-publish-record.ts b/apps/desktop/scripts/desktop-publish-record.ts new file mode 100644 index 0000000000..d3094b80bd --- /dev/null +++ b/apps/desktop/scripts/desktop-publish-record.ts @@ -0,0 +1,103 @@ +/** Machine-local channel exclusion and durable, credential-free publication records. */ +import { createHash } from 'node:crypto' +import { appendFile, mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { z } from 'zod' +import type { DesktopPublishEvent } from './desktop-publish.ts' + +/** + * Execute one publication or rollback while excluding other writers of its exact destination. + * @param root Machine-owned lock directory shared by automatic and manual entrypoints. + * @param destination Bucket and channel prefix identifying the serialized destination. + * @param operation Work to finish before releasing the lock. + * @returns Operation result. A held or abandoned lock rejects; operators inspect stale locks explicitly. + */ +export async function withDesktopChannelLock(root: string, destination: string, operation: () => Promise): Promise { + await mkdir(root, { recursive: true, mode: 0o700 }) + const path = join(root, `${createHash('sha256').update(destination).digest('hex')}.lock`) + try { await mkdir(path, { mode: 0o700 }) } catch (cause) { + throw new Error(`desktop publish: channel is locked or inaccessible; inspect ${path}`, { cause }) + } + try { + await writeFile(join(path, 'owner.json'), JSON.stringify({ pid: process.pid, + startedAt: new Date().toISOString(), destination }), { flag: 'wx', mode: 0o600, flush: true }) + return await operation() + } finally { await rm(path, { recursive: true }) } +} + +/** An operation journal retained independently of disposable checkout and artifact files. */ +export interface DesktopPublicationRecord { + readonly directory: string + record(event: DesktopPublishEvent): Promise + finish(status: 'complete' | 'preview' | 'failed' | 'published-tag-pending'): Promise +} + +/** + * Allocate a private journal before performing any remote writes. + * @param root Persistent audit root, outside the build checkout. + * @param identity Public source, actor and destination metadata; never pass credentials. + * @returns Durable event writer and explicit terminal-status writer. + */ +export async function createDesktopPublicationRecord( + root: string, + identity: { repository: string; runId: string; attempt: string; actor: string; destination: string; operation: 'publish' | 'rollback'; reason: string }, +): Promise { + await mkdir(root, { recursive: true, mode: 0o700 }) + const directory = await mkdtemp(join(root, `${identity.operation}-`)) + const startedAt = new Date().toISOString() + await writeFile(join(directory, 'operation.json'), `${JSON.stringify({ ...identity, + startedAt }, null, 2)}\n`, { flag: 'wx', mode: 0o600, flush: true }) + await writeFile(join(directory, 'events.jsonl'), '', { flag: 'wx', mode: 0o600 }) + return { + directory, + async record(event) { + await appendFile(join(directory, 'events.jsonl'), `${JSON.stringify({ ...event, at: new Date().toISOString() })}\n`, { flush: true }) + }, + async finish(status) { + await writeFile(join(directory, 'result.json'), `${JSON.stringify({ status, + startedAt, finishedAt: new Date().toISOString() })}\n`, { flag: 'wx', mode: 0o600, flush: true }) + }, + } +} + +/** + * Find durable evidence that this immutable attempt finished updating its feeds. + * @param root Persistent publication journal root. + * @param expected Exact source and destination for the current retained package. + * @returns True permits tag-only recovery; false requires normal remote reconciliation. + */ +export async function wasDesktopAttemptPublished(root: string, expected: { + repository: string + runId: string + attempt: string + destination: string +}): Promise { + let entries + try { entries = await readdir(root, { withFileTypes: true }) } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') return false + throw error + } + for (const entry of entries) { + if (!entry.isDirectory() || !entry.name.startsWith('publish-')) continue + const directory = join(root, entry.name) + let operation + try { operation = JSON.parse(await readFile(join(directory, 'operation.json'), 'utf8')) } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') continue + throw error + } + const parsed = z.object({ operation: z.string(), repository: z.string(), + runId: z.string(), attempt: z.string(), destination: z.string() }).parse(operation) + if (parsed.operation !== 'publish' || parsed.repository !== expected.repository || parsed.runId !== expected.runId + || parsed.attempt !== expected.attempt || parsed.destination !== expected.destination) continue + let events + try { events = await readFile(join(directory, 'events.jsonl'), 'utf8') } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') continue + throw error + } + // Only newline-terminated entries were completely appended before an interruption. + for (const line of events.split('\n').slice(0, -1)) { + if (z.object({ type: z.string() }).parse(JSON.parse(line)).type === 'feeds-complete') return true + } + } + return false +} diff --git a/apps/desktop/scripts/desktop-publish-route.ts b/apps/desktop/scripts/desktop-publish-route.ts new file mode 100644 index 0000000000..03d4edbbea --- /dev/null +++ b/apps/desktop/scripts/desktop-publish-route.ts @@ -0,0 +1,60 @@ +/** Validate a completed packaging attempt and route publication back to its original runner. */ +import { z } from 'zod' + +const positive = z.number().int().positive() +const runSchema = z.object({ + id: positive, run_attempt: positive, head_sha: z.string().regex(/^[a-f0-9]{40}$/u), + path: z.string(), conclusion: z.literal('success'), event: z.literal('workflow_dispatch'), + repository: z.object({ full_name: z.string() }), +}) +const requestSchema = z.object({ source: z.object({ + repository: z.string(), runId: z.string(), attempt: z.string(), commit: z.string(), + runnerName: z.string(), target: z.enum(['mac-arm64', 'win-x64']), preset: z.enum(['test', 'production']), + publishRequested: z.literal(true), qualification: z.enum(['signed', 'notarized']), +}) }) +const jobsSchema = z.array(z.object({ runner_name: z.string().nullable(), conclusion: z.string().nullable(), labels: z.array(z.string()) })) +const routesSchema = z.record(z.string(), z.object({ label: z.string().regex(/^dsh-[a-z0-9-]+$/u), target: z.enum(['mac-arm64', 'win-x64']) })) + +/** Validated values handed to the privileged publisher; no caller-selected shell fragments. */ +export interface DesktopPublishRoute { + readonly runId: string + readonly attempt: string + readonly commit: string + readonly runnerName: string + readonly runnerLabel: string + readonly target: 'mac-arm64' | 'win-x64' + readonly preset: 'test' | 'production' +} + +/** + * Match a public publication request to GitHub's attempt/job evidence and configured machine labels. + * @param repository Current repository, not a value selected by the artifact. + * @param runValue GitHub response for the exact packaging attempt. + * @param requestValue Retained manifest downloaded from that attempt's request artifact. + * @param jobsValue GitHub jobs for the same packaging attempt. + * @param routesValue Administrator-maintained runner-name to unique-label mapping. + * @returns Route to the original machine. Unknown runners, modes and mismatches reject. + */ +export function resolveDesktopPublishRoute( + repository: string, runValue: unknown, requestValue: unknown, jobsValue: unknown, routesValue: unknown, +): DesktopPublishRoute { + const run = runSchema.parse(runValue) + const { source } = requestSchema.parse(requestValue) + const jobs = jobsSchema.parse(jobsValue) + const routes = routesSchema.parse(routesValue) + const workflow = source.target === 'win-x64' ? '.github/workflows/windows-package.yml' : '.github/workflows/macos-package.yml' + if (run.repository.full_name !== repository || source.repository !== repository || run.path !== workflow + || source.runId !== String(run.id) || source.attempt !== String(run.run_attempt) || source.commit !== run.head_sha + || source.qualification !== (source.target === 'win-x64' ? 'signed' : 'notarized')) { + throw new Error('desktop publish: request does not match the successful packaging attempt') + } + const route = routes[source.runnerName] + if (!route || route.target !== source.target) throw new Error('desktop publish: original machine has no configured publishing route') + if (new Set(Object.values(routes).map(value => value.label)).size !== Object.keys(routes).length) { + throw new Error('desktop publish: each configured machine needs its own unique runner label') + } + const matches = jobs.filter(job => job.runner_name === source.runnerName && job.conclusion === 'success') + if (matches.length !== 1) throw new Error('desktop publish: original job does not establish a single successful packaging runner') + return { runId: source.runId, attempt: source.attempt, commit: source.commit, runnerName: source.runnerName, + runnerLabel: route.label, target: source.target, preset: source.preset } +} diff --git a/apps/desktop/scripts/desktop-publish-tag.ts b/apps/desktop/scripts/desktop-publish-tag.ts new file mode 100644 index 0000000000..077a4aaa8c --- /dev/null +++ b/apps/desktop/scripts/desktop-publish-tag.ts @@ -0,0 +1,46 @@ +/** Create immutable Desktop release tags using the explicitly supplied GitHub App installation token. */ +import { valid } from 'semver' +import { z } from 'zod' +import { desktopReleaseTag } from './desktop-release-tag.ts' + +const refSchema = z.object({ object: z.object({ type: z.literal('commit'), sha: z.string().regex(/^[a-f0-9]{40}$/u) }) }) + +/** + * Create a lightweight release tag, or verify that an existing tag names the same packaged commit. + * @param repository Owner/name of the package source repository. + * @param version Validated packaged version. + * @param commit Actual packaged commit, never the publishing workflow checkout commit. + * @param installationToken Explicit GitHub App token; no ambient authentication fallback is used. + * @param request HTTP implementation; defaults to fetch, tests inject an isolated fake. + * @returns Created or already-present status. Conflicts and unknown responses reject without updating a ref. + */ +export async function publishDesktopTag( + repository: string, version: string, commit: string, installationToken: string, + request: typeof fetch = fetch, +): Promise<'created' | 'present'> { + if (!/^[\w.-]+\/[\w.-]+$/u.test(repository) || valid(version) !== version || !/^[a-f0-9]{40}$/u.test(commit)) { + throw new Error('desktop tag: invalid repository, version or packaged commit') + } + if (!installationToken.trim()) throw new Error('desktop tag: GitHub App installation token is required') + const tag = desktopReleaseTag(version) + const base = `https://api.github.com/repos/${repository}/git` + const headers = { Authorization: `Bearer ${installationToken}`, Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28' } + const get = () => request(`${base}/ref/tags/${encodeURIComponent(tag)}`, { headers, redirect: 'error', signal: AbortSignal.timeout(30_000) }) + const matches = async (response: Response) => { + if (!response.ok) throw new Error(`desktop tag: cannot verify tag (HTTP ${response.status})`) + const ref = refSchema.parse(await response.json()) + if (ref.object.sha !== commit) throw new Error('desktop tag: existing tag points to another commit; refusing to move it') + } + const existing = await get() + if (existing.status !== 404) { await matches(existing); return 'present' } + await existing.body?.cancel() + const response = await request(`${base}/refs`, { + method: 'POST', headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify({ ref: `refs/tags/${tag}`, sha: commit }), redirect: 'error', signal: AbortSignal.timeout(30_000), + }) + if (response.status === 201) { await matches(response); return 'created' } + // A parallel platform release may have created the same version tag after our first read. + if (response.status === 422) { await response.body?.cancel(); await matches(await get()); return 'present' } + await response.body?.cancel() + throw new Error(`desktop tag: creation failed (HTTP ${response.status}); reconcile the tag before retrying`) +} diff --git a/apps/desktop/scripts/desktop-publish.ts b/apps/desktop/scripts/desktop-publish.ts new file mode 100644 index 0000000000..a1c81bbe7a --- /dev/null +++ b/apps/desktop/scripts/desktop-publish.ts @@ -0,0 +1,128 @@ +/** Resume Desktop publication by reconciling object bytes before changing update feeds. */ +import { createHash } from 'node:crypto' +import { createReadStream } from 'node:fs' +import { load } from 'js-yaml' +import { gt, valid } from 'semver' +import { z } from 'zod' +import type { DesktopUploadArtifact, DesktopUploadPlan } from './desktop-upload-plan.ts' + +/** File identity computed from bytes, never inferred from COS ETags. */ +export interface DesktopObjectDigest { readonly size: number; readonly sha512: string } +/** Remote object observation; channel objects additionally carry their exact YAML. */ +export interface DesktopRemoteObject extends DesktopObjectDigest { readonly contents?: string } +/** Transport owned by the caller holding the destination channel lock. */ +export interface DesktopPublishTransport { + /** Read and hash remote bytes; only a confirmed missing object returns null. */ + read(key: string, channel: boolean): Promise + /** Write once. Immutable binaries must use COS overwrite prevention. */ + put(artifact: DesktopUploadArtifact, digest: DesktopObjectDigest, forbidOverwrite: boolean): Promise +} +/** Credential-free, durably flushed publication event. */ +export interface DesktopPublishEvent { + readonly type: 'object-present' | 'put-intent' | 'put-verified' | 'feeds-complete' | 'tag-pending' | 'complete' + readonly key?: string + readonly before?: string | null + readonly after?: string +} +/** Caller owns audit persistence and the separately authorized GitHub App identity. */ +export interface DesktopPublishOperations { + readonly transport: DesktopPublishTransport + record(event: DesktopPublishEvent): Promise + /** Ensure the production tag names the packaged commit; never move an existing tag. */ + tag(): Promise +} +/** Published files and feeds can succeed even when tag creation remains outstanding. */ +export type DesktopPublishResult = { readonly status: 'complete' } | { readonly status: 'published-tag-pending'; readonly cause: unknown } + +/** A publication failure that requires reconciliation rather than a blind retry. */ +export class DesktopPublishError extends Error { + constructor(readonly code: 'conflict' | 'superseded' | 'invalid-plan' | 'remote-changed' | 'verification-failed', message: string) { + super(message) + this.name = 'DesktopPublishError' + } +} + +/** + * Hash exactly the file or generated metadata selected by a validated upload plan. + * @param artifact File-backed installer or in-memory channel metadata. + * @returns Byte length and SHA-512 for input reconciliation. + */ +export async function desktopArtifactDigest(artifact: DesktopUploadArtifact): Promise { + const hash = createHash('sha512') + let size = 0 + const stream = artifact.contents === undefined ? createReadStream(artifact.path) : [Buffer.from(artifact.contents)] + for await (const bytes of stream) { hash.update(bytes); size += bytes.length } + return { size, sha512: hash.digest('base64') } +} + +function same(left: DesktopObjectDigest | null, right: DesktopObjectDigest | null): boolean { + return left === null || right === null ? left === right : left.size === right.size && left.sha512 === right.sha512 +} + +function version(contents: string): string { + const parsed = z.object({ version: z.string() }).parse(load(contents)) + if (valid(parsed.version) === null) throw new DesktopPublishError('invalid-plan', 'desktop publish: invalid channel version') + return parsed.version +} + +/** + * Reconcile every binary, then publish feeds, then create the production tag. + * The caller must hold the channel lock through this entire operation. No writes are retried here; + * a later invocation hashes remote bytes to recover confirmed and ambiguous earlier writes. + * @param plan Validated package plan containing both binaries and channel metadata. + * @param operations Locked transport, durable audit sink, and production tag operation. + * @returns Complete or published-with-tag-pending; other failures stop all dependent work. + */ +export async function publishDesktopRelease(plan: DesktopUploadPlan, operations: DesktopPublishOperations): Promise { + const files = plan.artifacts.filter(file => !file.channelMetadata) + const feeds = plan.artifacts.filter(file => file.channelMetadata) + if (files.length === 0 || feeds.length === 0 || new Set(plan.artifacts.map(file => file.key)).size !== plan.artifacts.length) { + throw new DesktopPublishError('invalid-plan', 'desktop publish: complete files and update feeds are required') + } + if (plan.environment === 'production' && (!plan.commit || !/^[a-f0-9]{40}$/u.test(plan.commit) || plan.dirty !== false)) { + throw new DesktopPublishError('invalid-plan', 'desktop publish: production requires a clean packaged commit') + } + const snapshots = new Map() + // Reject obsolete requests before even uploading their missing binary files. + for (const feed of feeds) { + if (feed.contents === undefined || version(feed.contents) !== plan.version) { + throw new DesktopPublishError('invalid-plan', 'desktop publish: channel version differs from package') + } + const remote = await operations.transport.read(feed.key, true) + if (remote !== null) { + if (remote.contents === undefined) throw new DesktopPublishError('verification-failed', 'desktop publish: channel bytes were not returned') + if (gt(version(remote.contents), plan.version)) throw new DesktopPublishError('superseded', 'desktop publish: a newer version already owns this channel') + if (version(remote.contents) === plan.version && !same(remote, await desktopArtifactDigest(feed))) { + throw new DesktopPublishError('conflict', 'desktop publish: same version has different channel metadata') + } + } + snapshots.set(feed.key, remote) + } + for (const artifact of [...files, ...feeds]) { + const digest = await desktopArtifactDigest(artifact) + const remote = await operations.transport.read(artifact.key, artifact.channelMetadata) + if (same(remote, digest)) { + await operations.record({ type: 'object-present', key: artifact.key }) + continue + } + if (!artifact.channelMetadata && remote !== null) throw new DesktopPublishError('conflict', `desktop publish: object differs: ${artifact.key}`) + if (artifact.channelMetadata && !same(remote, snapshots.get(artifact.key) ?? null)) { + throw new DesktopPublishError('remote-changed', 'desktop publish: channel changed after preflight; inspect before retrying') + } + await operations.record({ type: 'put-intent', key: artifact.key, + ...(artifact.channelMetadata ? { before: remote?.contents ?? null, after: artifact.contents } : {}) }) + await operations.transport.put(artifact, digest, !artifact.channelMetadata) + const actual = await operations.transport.read(artifact.key, artifact.channelMetadata) + if (!same(actual, digest)) throw new DesktopPublishError('verification-failed', `desktop publish: remote bytes differ: ${artifact.key}`) + await operations.record({ type: 'put-verified', key: artifact.key }) + } + await operations.record({ type: 'feeds-complete' }) + if (plan.environment === 'production') { + try { await operations.tag() } catch (cause) { + await operations.record({ type: 'tag-pending' }) + return { status: 'published-tag-pending', cause } + } + } + await operations.record({ type: 'complete' }) + return { status: 'complete' } +} diff --git a/apps/desktop/scripts/desktop-release-store.ts b/apps/desktop/scripts/desktop-release-store.ts new file mode 100644 index 0000000000..465e1af0e9 --- /dev/null +++ b/apps/desktop/scripts/desktop-release-store.ts @@ -0,0 +1,139 @@ +/** Retain immutable Desktop build files outside disposable runner checkouts. */ +import { createHash } from 'node:crypto' +import { createReadStream, constants } from 'node:fs' +import { copyFile, lstat, mkdir, mkdtemp, readFile, realpath, rename, rm, writeFile } from 'node:fs/promises' +import { basename, isAbsolute, join } from 'node:path' +import { z } from 'zod' + +const positiveId = z.string().regex(/^[1-9]\d*$/u) +const filename = z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/u) +const sourceSchema = z.strictObject({ + repository: z.string().regex(/^[\w.-]+\/[\w.-]+$/u), + runId: positiveId, + attempt: positiveId, + commit: z.string().regex(/^[a-f0-9]{40}$/u), + runnerName: z.string().min(1), + target: z.enum(['mac-arm64', 'mac-x64', 'win-x64']), + preset: z.enum(['test', 'production']), + productVersion: z.string().min(1), + version: z.string().min(1), + testReleaseId: z.string().regex(/^[a-f0-9]{32}$/u).nullable(), + publishRequested: z.boolean(), + qualification: z.enum(['signed', 'notarized', 'unsigned', 'unnotarized']), +}) +const manifestSchema = z.strictObject({ + schemaVersion: z.literal(1), + source: sourceSchema, + files: z.array(z.strictObject({ name: filename, size: z.number().int().positive(), + sha512: z.string().regex(/^[A-Za-z0-9+/]{86}==$/u) })).min(1), +}) + +/** Public, credential-free identity of one packaging attempt. */ +export type DesktopRetainedSource = z.infer +/** File inventory bound to the source packaging attempt. */ +export type DesktopRetainedManifest = z.infer + +async function fingerprint(path: string) { + const info = await lstat(path) + if (!info.isFile() || info.isSymbolicLink()) throw new Error('desktop release store: expected a regular file') + const hash = createHash('sha512') + let size = 0 + for await (const bytes of createReadStream(path)) { size += bytes.length; hash.update(bytes) } + if (size === 0) throw new Error('desktop release store: empty artifact') + return { size, sha512: hash.digest('base64') } +} + +async function directory(path: string): Promise { + const info = await lstat(path) + if (!info.isDirectory() || info.isSymbolicLink()) throw new Error('desktop release store: expected a real directory') + return realpath(path) +} + +/** + * Resolve an attempt beneath an absolute, machine-owned releases root. + * @param root Persistent releases directory, outside the checkout. + * @param runId GitHub packaging run ID. + * @param attempt Packaging attempt, not the publishing attempt. + * @returns Deterministic path after validating both identifiers. + */ +export function retainedDesktopPath(root: string, runId: string, attempt: string): string { + if (!isAbsolute(root)) throw new Error('desktop release store: root must be absolute') + positiveId.parse(runId) + positiveId.parse(attempt) + return join(root, runId, attempt) +} + +/** + * Copy an explicit file inventory, verify the copies, then expose one complete attempt atomically. + * @param root Persistent releases root owned by this runner. + * @param source Packaging identity and selected public settings. + * @param paths Complete artifact and completion-record paths; directory trees are not accepted. + * @returns Published attempt directory. An existing attempt is never replaced. + */ +export async function retainDesktopRelease(root: string, source: DesktopRetainedSource, paths: readonly string[]): Promise { + sourceSchema.parse(source) + const destination = retainedDesktopPath(root, source.runId, source.attempt) + const names = paths.map(path => filename.parse(basename(path))) + if (names.length === 0 || new Set(names).size !== names.length) throw new Error('desktop release store: empty or duplicate file inventory') + await mkdir(root, { recursive: true, mode: 0o700 }) + await directory(root) + const parent = join(root, source.runId) + await mkdir(parent, { recursive: true, mode: 0o700 }) + await directory(parent) + const claim = join(parent, `${source.attempt}.lock`) + await mkdir(claim, { mode: 0o700 }) + let staging: string | undefined + try { + try { + await lstat(destination) + throw new Error('desktop release store: attempt already exists') + } catch (error) { + if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT')) throw error + } + staging = await mkdtemp(join(parent, '.retaining-')) + const files = [] + for (const path of paths) { + const name = basename(path) + if (name === 'manifest.json') throw new Error('desktop release store: manifest filename is reserved') + const before = await fingerprint(path) + const copied = join(staging, name) + await copyFile(path, copied, constants.COPYFILE_EXCL) + const after = await fingerprint(copied) + if (before.size !== after.size || before.sha512 !== after.sha512) throw new Error('desktop release store: source changed during retention') + files.push({ name, ...after }) + } + const manifest: DesktopRetainedManifest = { schemaVersion: 1, source, files } + manifestSchema.parse(manifest) + await writeFile(join(staging, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`, { flag: 'wx', mode: 0o600, flush: true }) + await rename(staging, destination) + staging = undefined + return destination + } finally { + if (staging !== undefined) await rm(staging, { recursive: true, force: true }) + await rm(claim, { recursive: true }) + } +} + +/** + * Read an attempt and rehash every retained file before a consumer uses it. + * @param root Persistent releases root. + * @param runId Expected source run ID. + * @param attempt Expected source attempt. + * @returns Parsed inventory; missing, linked, changed or mismatched files reject. + */ +export async function verifyRetainedDesktopRelease(root: string, runId: string, attempt: string): Promise { + const path = retainedDesktopPath(root, runId, attempt) + await directory(root) + await directory(join(root, runId)) + await directory(path) + const manifestPath = join(path, 'manifest.json') + if (!(await lstat(manifestPath)).isFile() || (await lstat(manifestPath)).isSymbolicLink()) throw new Error('desktop release store: invalid manifest file') + const manifest = manifestSchema.parse(JSON.parse(await readFile(manifestPath, 'utf8'))) + if (manifest.source.runId !== runId || manifest.source.attempt !== attempt) throw new Error('desktop release store: attempt identity mismatch') + if (new Set(manifest.files.map(file => file.name)).size !== manifest.files.length) throw new Error('desktop release store: duplicate file inventory') + for (const file of manifest.files) { + const current = await fingerprint(join(path, file.name)) + if (current.size !== file.size || current.sha512 !== file.sha512) throw new Error(`desktop release store: changed file ${file.name}`) + } + return manifest +} diff --git a/apps/desktop/scripts/desktop-rollback.ts b/apps/desktop/scripts/desktop-rollback.ts new file mode 100644 index 0000000000..4f1e4957c2 --- /dev/null +++ b/apps/desktop/scripts/desktop-rollback.ts @@ -0,0 +1,54 @@ +/** Restore historical update feeds without uploading binaries or moving release tags. */ +import { load } from 'js-yaml' +import { z } from 'zod' +import type { DesktopUploadPlan } from './desktop-upload-plan.ts' +import { desktopArtifactDigest, type DesktopPublishOperations, type DesktopRemoteObject } from './desktop-publish.ts' + +/** Outcome of an explicitly requested channel restoration. */ +export interface DesktopRollbackResult { + readonly status: 'preview' | 'complete' + readonly feeds: readonly { key: string; before: string; after: string }[] +} + +/** + * Verify historical binaries and restore only the selected feed YAML under a caller-held channel lock. + * @param plan Previously published release reconstructed from retained immutable files. + * @param expectedVersion Version the operator expects to find online; another version stops the operation. + * @param execute False only inspects and previews; true writes after all historical bytes are verified. + * @param operations Remote transport and audit sink. The tag operation is never called. + * @returns Before/after YAML and whether it was written. Partial writes remain recorded for reconciliation. + */ +export async function rollbackDesktopChannel( + plan: DesktopUploadPlan, expectedVersion: string, execute: boolean, operations: DesktopPublishOperations, +): Promise { + const files = plan.artifacts.filter(file => !file.channelMetadata) + const feeds = plan.artifacts.filter(file => file.channelMetadata) + if (files.length === 0 || feeds.length === 0) throw new Error('desktop rollback: a complete historical release is required') + const equal = (left: DesktopRemoteObject | null, right: { size: number; sha512: string }) => + left !== null && left.size === right.size && left.sha512 === right.sha512 + for (const file of files) { + if (!equal(await operations.transport.read(file.key, false), await desktopArtifactDigest(file))) { + throw new Error(`desktop rollback: historical object is missing or different: ${file.key}`) + } + } + const changes = [] + for (const feed of feeds) { + const before = await operations.transport.read(feed.key, true) + if (!before || before.contents === undefined || feed.contents === undefined) throw new Error('desktop rollback: channel YAML is unavailable') + const current = z.object({ version: z.string() }).parse(load(before.contents)).version + if (current !== expectedVersion) throw new Error('desktop rollback: current version differs from the expected version') + changes.push({ key: feed.key, before: before.contents, after: feed.contents, observed: before, artifact: feed }) + } + if (execute) { + for (const change of changes) { + if (!equal(await operations.transport.read(change.key, true), change.observed)) throw new Error('desktop rollback: channel changed after preview') + await operations.record({ type: 'put-intent', key: change.key, before: change.before, after: change.after }) + const digest = await desktopArtifactDigest(change.artifact) + await operations.transport.put(change.artifact, digest, false) + if (!equal(await operations.transport.read(change.key, true), digest)) throw new Error('desktop rollback: restored channel could not be verified') + await operations.record({ type: 'put-verified', key: change.key }) + } + await operations.record({ type: 'complete' }) + } + return { status: execute ? 'complete' : 'preview', feeds: changes.map(({ key, before, after }) => ({ key, before, after })) } +} diff --git a/apps/desktop/scripts/desktop-upload-plan.ts b/apps/desktop/scripts/desktop-upload-plan.ts index 439a4fa6e1..a9d61d1ef5 100644 --- a/apps/desktop/scripts/desktop-upload-plan.ts +++ b/apps/desktop/scripts/desktop-upload-plan.ts @@ -63,6 +63,8 @@ export interface DesktopUploadPlanOptions { readonly repositoryRoot?: string readonly appRoot?: string readonly artifactsRoot?: string + /** Original product version from a verified retained build; absent reads the checkout manifests. */ + readonly packagedProductVersion?: string } interface UpdateFileInfo { @@ -188,8 +190,8 @@ export async function createDesktopUploadPlan( const repositoryRoot = options.repositoryRoot ?? REPOSITORY_ROOT const appRoot = options.appRoot ?? APP_ROOT const artifactsRoot = options.artifactsRoot ?? desktopTargetBuildPaths(targetName).artifacts - const dshVersion = await manifestVersion(join(repositoryRoot, 'package.json'), 'dsh package') - const desktopVersion = await manifestVersion(join(appRoot, 'package.json'), 'desktop package') + const dshVersion = options.packagedProductVersion ?? await manifestVersion(join(repositoryRoot, 'package.json'), 'dsh package') + const desktopVersion = options.packagedProductVersion ?? await manifestVersion(join(appRoot, 'package.json'), 'desktop package') if (dshVersion !== desktopVersion) { throw new Error(`desktop upload: desktop version ${desktopVersion} does not match current dsh version ${dshVersion}`) } diff --git a/apps/desktop/scripts/prepare-macos-ci.mjs b/apps/desktop/scripts/prepare-macos-ci.mjs index 4aeaef8e8a..6974610a8c 100644 --- a/apps/desktop/scripts/prepare-macos-ci.mjs +++ b/apps/desktop/scripts/prepare-macos-ci.mjs @@ -9,6 +9,7 @@ const product = JSON.parse(readFileSync(resolve(appRoot, 'package.json'), 'utf8' const version = desktopCiVersion(process.env, product.version) if (!process.env.DSH_PACKAGING_PRESET) throw new Error('Select a machine preset') const skipNotarization = process.env.PACKAGE_SKIP_NOTARIZATION === 'true' +if (process.env.PACKAGE_PUBLISH === 'true' && skipNotarization) throw new Error('Publishing requires notarization and stapling') if (!['true', 'false', undefined].includes(process.env.PACKAGE_SKIP_NOTARIZATION)) throw new Error('Invalid skip notarization selection') const environment = loadDesktopPackageEnvironment('darwin', process.env, appRoot) for (const [name, value] of Object.entries(environment)) { diff --git a/apps/desktop/scripts/prepare-windows-ci.mjs b/apps/desktop/scripts/prepare-windows-ci.mjs index 7581d05bab..f99e313180 100644 --- a/apps/desktop/scripts/prepare-windows-ci.mjs +++ b/apps/desktop/scripts/prepare-windows-ci.mjs @@ -10,6 +10,7 @@ if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.fi const product = JSON.parse(readFileSync(resolve(appRoot, 'package.json'), 'utf8')) const version = windowsCiVersion(process.env, product.version) const mode = process.env.PACKAGE_SIGNING + if (process.env.PACKAGE_PUBLISH === 'true' && mode !== 'signed') throw new Error('Publishing requires signed Windows packages') if (mode !== 'unsigned' && mode !== 'signed') throw new Error('Select unsigned or signed packaging') if (!process.env.DSH_PACKAGING_PRESET) throw new Error('Select a machine preset') const environment = loadDesktopPackageEnvironment('win32', process.env, appRoot, { unsigned: mode === 'unsigned' }) diff --git a/apps/desktop/scripts/publish-desktop-ci.ts b/apps/desktop/scripts/publish-desktop-ci.ts new file mode 100644 index 0000000000..041ba9d484 --- /dev/null +++ b/apps/desktop/scripts/publish-desktop-ci.ts @@ -0,0 +1,107 @@ +/** Publish a retained package selected by the routing job; --check never reads secrets or contacts COS. */ +import { appendFile } from 'node:fs/promises' +import { dirname, join, resolve } from 'node:path' +import { z } from 'zod' +import { readDesktopPublishConfig, readDesktopUploadCredentials } from './desktop-publish-config.ts' +import { desktopReleasesRoot } from './retain-desktop-ci.ts' +import { retainedDesktopPath, verifyRetainedDesktopRelease } from './desktop-release-store.ts' +import { verifyRetainedDesktopSignatures } from './verify-retained-desktop.ts' +import { createDesktopUploadPlan } from './desktop-upload-plan.ts' +import { desktopPublishCos } from './desktop-publish-cos.ts' +import { createDesktopPublicationRecord, wasDesktopAttemptPublished, withDesktopChannelLock } from './desktop-publish-record.ts' +import { DesktopPublishError, publishDesktopRelease } from './desktop-publish.ts' +import { publishDesktopTag } from './desktop-publish-tag.ts' + +const routeSchema = z.object({ runId: z.string(), attempt: z.string(), commit: z.string(), runnerName: z.string(), + target: z.enum(['mac-arm64', 'win-x64']), preset: z.enum(['test', 'production']) }) + +/** + * Check retained bytes, original machine, destination and signatures before reading any upload secret. + * @param env Router output and machine configuration directory. + * @param verifySignatures Platform verifier; tests inject a verifier for fixture bytes. + * @returns Validated local publication context; no network operation is performed. + */ +export async function prepareDesktopPublication(env: NodeJS.ProcessEnv, verifySignatures = verifyRetainedDesktopSignatures) { + const route = routeSchema.parse(JSON.parse(env.DESKTOP_PUBLISH_ROUTE || '{}')) + if (route.runnerName !== env.RUNNER_NAME) throw new Error('desktop publish: task was dispatched to a different machine') + const root = desktopReleasesRoot(env) + const manifest = await verifyRetainedDesktopRelease(root, route.runId, route.attempt) + const source = manifest.source + if (source.repository !== env.GITHUB_REPOSITORY || source.runnerName !== route.runnerName || source.target !== route.target + || source.commit !== route.commit || source.preset !== route.preset || !source.publishRequested) { + throw new Error('desktop publish: local package differs from the routed request') + } + if (source.qualification !== (source.target === 'win-x64' ? 'signed' : 'notarized')) throw new Error('desktop publish: debugging artifacts cannot be published') + const config = await readDesktopPublishConfig(env.DSH_PACKAGING_CONFIG_DIR!, source.preset) + const directory = retainedDesktopPath(root, route.runId, route.attempt) + const environment = source.preset === 'test' + ? { DSH_DESKTOP_AUTO_UPDATE_ENV: 'test', DOWNLOAD_TEST_ORIGIN: config.origin, + DOWNLOAD_TEST_RELEASE_ID: source.testReleaseId ?? '', DOWNLOAD_TEST_COS_BUCKET: config.bucket } + : { DSH_DESKTOP_AUTO_UPDATE_ENV: 'production', DOWNLOAD_PROD_COS_BUCKET: config.bucket } + const plan = await createDesktopUploadPlan(source.target, { artifactsRoot: directory, + packagedProductVersion: source.productVersion, environment }) + if (plan.version !== source.version || plan.commit !== source.commit || plan.dirty !== false) throw new Error('desktop publish: completion record differs from retained source or has uncommitted changes') + await verifySignatures(directory, manifest, config) + return { route, manifest, config, plan, root } +} + +if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.filename) { + let phase = 'local package, destination and signature verification' + try { + const mode = process.argv[2] + if (process.argv.length !== 3 || (mode !== '--check' && mode !== '--publish')) throw new Error('Usage: publish-desktop-ci.ts --check|--publish') + const env = process.env + const { route, manifest, config, plan, root } = await prepareDesktopPublication(env) + if (mode === '--check') { + if (env.GITHUB_OUTPUT && config.githubApp) await appendFile(env.GITHUB_OUTPUT, + `app-client-id=${config.githubApp.clientId}\napp-key-file=${config.githubApp.privateKeyFile}\n`) + process.stdout.write(`desktop publish: verified ${plan.target} ${plan.version}; no credentials read or COS requests made\n`) + } else { + if (plan.environment === 'production' && !env.DESKTOP_TAG_TOKEN) throw new Error('desktop publish: GitHub App token is required before production upload') + phase = 'upload credential loading' + const credentials = await readDesktopUploadCredentials(config) + const destination = `${plan.bucket}/${plan.publicUrl}` + const recordsRoot = join(dirname(root), 'upload-records') + phase = 'channel lock and publication journal acquisition' + await withDesktopChannelLock(join(dirname(root), 'publish-locks'), destination, async () => { + const published = await wasDesktopAttemptPublished(recordsRoot, + { repository: manifest.source.repository, runId: route.runId, attempt: route.attempt, destination }) + const journal = await createDesktopPublicationRecord(recordsRoot, { + repository: manifest.source.repository, runId: route.runId, attempt: route.attempt, + actor: env.GITHUB_TRIGGERING_ACTOR || env.GITHUB_ACTOR || 'unknown', + destination, operation: 'publish', reason: 'Actions publication', + }) + if (env.GITHUB_OUTPUT) await appendFile(env.GITHUB_OUTPUT, `record=${journal.directory}\n`) + let status: 'complete' | 'failed' | 'published-tag-pending' = 'failed' + try { + phase = 'remote object reconciliation and publication' + const tag = async () => { await publishDesktopTag(manifest.source.repository, + plan.version, route.commit, env.DESKTOP_TAG_TOKEN || '') } + const result = published + ? await (async () => { + await journal.record({ type: 'feeds-complete' }) + if (plan.environment === 'production') { + try { await tag() } catch (cause) { + await journal.record({ type: 'tag-pending' }) + return { status: 'published-tag-pending' as const, cause } + } + } + return { status: 'complete' as const } + })() + : await publishDesktopRelease(plan, { + transport: desktopPublishCos(plan, credentials), record: event => journal.record(event), + tag, + }) + status = result.status + if (env.GITHUB_STEP_SUMMARY) await appendFile(env.GITHUB_STEP_SUMMARY, + `\nDesktop publication: **${status}**\n\n${plan.target} ${plan.version}\n\nSource: ${route.runId}, attempt ${route.attempt}\n`) + if (result.status === 'published-tag-pending') process.exitCode = 1 + } finally { await journal.finish(status) } + }) + } + } catch (error) { + const code = error instanceof DesktopPublishError ? ` (${error.code})` : '' + process.stderr.write(`Desktop publication stopped during ${phase}${code}. Inspect the retained operation record and configuration before retrying; remote effects may be incomplete.\n`) + process.exitCode = 1 + } +} diff --git a/apps/desktop/scripts/resolve-desktop-publish-ci.ts b/apps/desktop/scripts/resolve-desktop-publish-ci.ts new file mode 100644 index 0000000000..e08b01f606 --- /dev/null +++ b/apps/desktop/scripts/resolve-desktop-publish-ci.ts @@ -0,0 +1,48 @@ +/** Resolve a workflow_run publication request without reading machine or COS credentials. */ +import { execFileSync } from 'node:child_process' +import { appendFile, mkdir, readFile } from 'node:fs/promises' +import { join, resolve } from 'node:path' +import { z } from 'zod' +import { resolveDesktopPublishRoute } from './desktop-publish-route.ts' + +const eventSchema = z.object({ workflow_run: z.object({ id: z.number().int().positive(), run_attempt: z.number().int().positive() }) }) +const artifactsSchema = z.object({ artifacts: z.array(z.object({ name: z.string(), expired: z.boolean() })) }) + +if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.filename) { + const env = process.env + if (!env.GITHUB_EVENT_PATH || !env.GITHUB_REPOSITORY || !env.GITHUB_OUTPUT || !env.RUNNER_TEMP) throw new Error('desktop publish: Actions context required') + const event = eventSchema.parse(JSON.parse(await readFile(env.GITHUB_EVENT_PATH, 'utf8'))) + const repository = env.GITHUB_REPOSITORY + if (!/^[\w.-]+\/[\w.-]+$/u.test(repository)) throw new Error('desktop publish: invalid repository') + const { id, run_attempt: attempt } = event.workflow_run + const api = (path: string): unknown => JSON.parse(execFileSync('gh', ['api', + path], { encoding: 'utf8', timeout: 60_000, maxBuffer: 8 * 1024 * 1024 })) + const base = `repos/${repository}/actions/runs/${id}` + const name = `desktop-publish-request-${id}-${attempt}` + const pages: unknown = JSON.parse(execFileSync('gh', ['api', '--paginate', '--slurp', + `${base}/artifacts?per_page=100`], { encoding: 'utf8', timeout: 60_000, maxBuffer: 8 * 1024 * 1024 })) + const match = z.array(artifactsSchema).parse(pages).flatMap(page => page.artifacts) + .find(artifact => artifact.name === name) + if (match === undefined) { + await appendFile(env.GITHUB_OUTPUT, 'publish=false\n') + } else { + if (match.expired) throw new Error('desktop publish: request artifact expired') + const destination = join(env.RUNNER_TEMP, `desktop-request-${id}-${attempt}-${env.GITHUB_RUN_ID}-${env.GITHUB_RUN_ATTEMPT}`) + await mkdir(destination, { recursive: false, mode: 0o700 }) + execFileSync('gh', ['run', 'download', String(id), '--repo', repository, + '--name', name, '--dir', destination], { timeout: 60_000, stdio: 'pipe' }) + const request: unknown = JSON.parse(await readFile(join(destination, 'manifest.json'), 'utf8')) + const jobs = z.object({ jobs: z.array(z.unknown()) }).parse(api(`${base}/attempts/${attempt}/jobs?per_page=100`)) + const route = resolveDesktopPublishRoute(repository, api(`${base}/attempts/${attempt}`), + request, jobs.jobs, JSON.parse(env.DESKTOP_PUBLISH_RUNNERS || '{}')) + if (route.preset === 'production') { + const environment = z.object({ protection_rules: z.array(z.object({ type: z.string(), + reviewers: z.array(z.unknown()).optional() })) }) + .parse(api(`repos/${repository}/environments/desktop-production`)) + if (!environment.protection_rules.some(rule => rule.type === 'required_reviewers' && (rule.reviewers?.length ?? 0) > 0)) { + throw new Error('desktop publish: desktop-production requires configured reviewers') + } + } + await appendFile(env.GITHUB_OUTPUT, `publish=true\nroute=${JSON.stringify(route)}\nrunner=${route.runnerLabel}\npreset=${route.preset}\n`) + } +} diff --git a/apps/desktop/scripts/retain-desktop-ci.ts b/apps/desktop/scripts/retain-desktop-ci.ts new file mode 100644 index 0000000000..feee5247cd --- /dev/null +++ b/apps/desktop/scripts/retain-desktop-ci.ts @@ -0,0 +1,79 @@ +/** Retain completed CI packages and a credential-free request for the publishing workflow. */ +import { appendFile, readFile } from 'node:fs/promises' +import { dirname, isAbsolute, join, resolve } from 'node:path' +import { desktopTargetBuildPaths, resolveDesktopBuildTarget } from './desktop-build-paths.mjs' +import { desktopBuildRecordFilename, desktopUpdateMetadataFilename } from './desktop-auto-update-environment.mjs' +import { loadDesktopPackageEnvironment } from './desktop-package-environment.mjs' +import { retainDesktopRelease, type DesktopRetainedSource } from './desktop-release-store.ts' +import { validateDesktopBuildVersion } from './desktop-build-version.mjs' + +function required(env: NodeJS.ProcessEnv, name: string): string { + const value = env[name] + if (!value) throw new Error(`desktop retention: ${name} is required`) + return value +} + +/** + * Resolve the persistent release root without assuming a particular user's home directory. + * @param env Machine packaging configuration; an explicit releases directory overrides its sibling default. + * @returns Absolute directory outside the disposable checkout. + */ +export function desktopReleasesRoot(env: NodeJS.ProcessEnv): string { + const configured = required(env, 'DSH_PACKAGING_CONFIG_DIR') + const root = env.DSH_DESKTOP_RELEASES_DIR || join(dirname(configured), 'releases') + if (!isAbsolute(configured) || !isAbsolute(root)) throw new Error('desktop retention: machine directories must be absolute') + return root +} + +/** + * Save the complete installer inventory for a successful package job, without reading signing secrets. + * @param env Actions identity, resolved package version and machine preset selection. + * @param appRoot Desktop checkout directory; defaults to this script's owning app. + * @returns Retained directory. Publish requests for debugging packages reject before copying. + */ +export async function retainDesktopCi(env: NodeJS.ProcessEnv, appRoot = resolve(import.meta.dirname, '..')): Promise { + const target = resolveDesktopBuildTarget(env) + const windows = target === 'win-x64' + const platform = windows ? 'win32' : 'darwin' + const settings = loadDesktopPackageEnvironment(platform, env, appRoot, { unsigned: true }) + const preset = settings.DSH_PACKAGING_PRESET + if (preset !== 'test' && preset !== 'production') throw new Error('desktop retention: a machine preset is required') + const publish = env.PACKAGE_PUBLISH === 'true' + if (!['true', 'false', undefined].includes(env.PACKAGE_PUBLISH)) throw new Error('desktop retention: invalid publish selection') + if (windows && !['signed', 'unsigned'].includes(env.PACKAGE_SIGNING ?? '')) throw new Error('desktop retention: invalid signing selection') + if (!windows && !['true', 'false'].includes(env.PACKAGE_SKIP_NOTARIZATION ?? '')) throw new Error('desktop retention: invalid notarization selection') + const qualification = windows ? (env.PACKAGE_SIGNING === 'signed' ? 'signed' : 'unsigned') + : (env.PACKAGE_SKIP_NOTARIZATION === 'true' ? 'unnotarized' : 'notarized') + if (publish && (qualification === 'unsigned' || qualification === 'unnotarized')) { + throw new Error('desktop retention: publishing requires signed Windows or notarized macOS packages') + } + const product: { version: string } = JSON.parse(await readFile(join(appRoot, 'package.json'), 'utf8')) + const version = validateDesktopBuildVersion(required(env, 'PACKAGE_VERSION'), product.version) + const source: DesktopRetainedSource = { + repository: required(env, 'GITHUB_REPOSITORY'), runId: required(env, 'GITHUB_RUN_ID'), + attempt: required(env, 'GITHUB_RUN_ATTEMPT'), commit: required(env, 'GITHUB_SHA'), + runnerName: required(env, 'RUNNER_NAME'), target, preset, productVersion: product.version, version, + testReleaseId: preset === 'test' ? (settings.DOWNLOAD_TEST_RELEASE_ID || null) : null, + publishRequested: publish, qualification, + } + const paths = desktopTargetBuildPaths(target) + // The CLI's checkout owns these paths; alternate app roots are used by fixtures. + const mode = qualification === 'unsigned' ? 'unsigned-artifacts' : qualification === 'unnotarized' ? 'unnotarized-artifacts' : 'artifacts' + const artifacts = appRoot === resolve(import.meta.dirname, '..') + ? join(paths.root, mode) : join(appRoot, '.desktop-build', 'targets', target, mode) + const base = `deepseek-harness-${version}-${windows ? 'win-x64' : target}` + const debugSuffix = qualification === 'unnotarized' ? '-unnotarized' : qualification === 'unsigned' ? '-unsigned' : '' + const names = windows ? [`${base}${debugSuffix}.exe`] : [`${base}${debugSuffix}.dmg`, `${base}${debugSuffix}.zip`] + if (qualification === 'signed' || qualification === 'notarized') { + names.push(`${base}.${windows ? 'exe' : 'zip'}.blockmap`, desktopBuildRecordFilename(target), desktopUpdateMetadataFilename(version, platform)) + } + const retained = await retainDesktopRelease(desktopReleasesRoot(env), source, names.map(name => join(artifacts, name))) + if (env.GITHUB_OUTPUT) await appendFile(env.GITHUB_OUTPUT, `directory=${retained}\n`) + if (env.GITHUB_STEP_SUMMARY) await appendFile(env.GITHUB_STEP_SUMMARY, + `\nRetained package: ${env.GITHUB_RUN_ID}/${env.GITHUB_RUN_ATTEMPT}\n\nPublish requested: ${publish}\n`) + return retained +} + +if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.filename) { + await retainDesktopCi(process.env) +} diff --git a/apps/desktop/scripts/rollback-desktop-channel.ts b/apps/desktop/scripts/rollback-desktop-channel.ts new file mode 100644 index 0000000000..add593e8ee --- /dev/null +++ b/apps/desktop/scripts/rollback-desktop-channel.ts @@ -0,0 +1,53 @@ +/** Manual channel restoration: read-only preview unless --execute is explicitly present. */ +import { readFile } from 'node:fs/promises' +import { dirname, join, resolve } from 'node:path' +import { parseArgs } from 'node:util' +import { z } from 'zod' +import { prepareDesktopPublication } from './publish-desktop-ci.ts' +import { readDesktopUploadCredentials } from './desktop-publish-config.ts' +import { desktopPublishCos } from './desktop-publish-cos.ts' +import { createDesktopPublicationRecord, withDesktopChannelLock } from './desktop-publish-record.ts' +import { rollbackDesktopChannel } from './desktop-rollback.ts' + +if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.filename) { + try { + const { values } = parseArgs({ options: { + record: { type: 'string' }, 'expected-version': { type: 'string' }, + reason: { type: 'string' }, execute: { type: 'boolean', default: false }, + } }) + if (!values.record || !values['expected-version'] || !values.reason?.trim()) { + throw new Error('Usage: rollback-desktop-channel.ts --record --expected-version --reason [--execute]') + } + const operation = z.object({ repository: z.string(), runId: z.string(), + attempt: z.string(), destination: z.string(), operation: z.literal('publish') }) + .parse(JSON.parse(await readFile(join(values.record, 'operation.json'), 'utf8'))) + z.object({ status: z.enum(['complete', 'published-tag-pending']) }) + .parse(JSON.parse(await readFile(join(values.record, 'result.json'), 'utf8'))) + const { plan, config, root } = await prepareDesktopPublication(process.env) + const route = z.object({ runId: z.string(), attempt: z.string() }).parse(JSON.parse(process.env.DESKTOP_PUBLISH_ROUTE || '{}')) + const destination = `${plan.bucket}/${plan.publicUrl}` + if (operation.runId !== route.runId || operation.attempt !== route.attempt + || operation.repository !== process.env.GITHUB_REPOSITORY || operation.destination !== destination) { + throw new Error('desktop rollback: successful publication record does not match the selected package') + } + const credentials = await readDesktopUploadCredentials(config) + await withDesktopChannelLock(join(dirname(root), 'publish-locks'), destination, async () => { + const journal = await createDesktopPublicationRecord(join(dirname(root), 'upload-records'), { + ...operation, actor: process.env.USER || process.env.USERNAME || 'unknown', operation: 'rollback', reason: values.reason!, + }) + process.stdout.write(`desktop rollback: audit ${journal.directory}\n`) + let complete = false + try { + const outcome = await rollbackDesktopChannel(plan, values['expected-version']!, values.execute!, { + transport: desktopPublishCos(plan, credentials), record: event => journal.record(event), + tag: async () => { throw new Error('desktop rollback never changes tags') }, + }) + process.stdout.write(`${JSON.stringify(outcome, null, 2)}\n`) + complete = true + } finally { await journal.finish(complete ? (values.execute ? 'complete' : 'preview') : 'failed') } + }) + } catch { + process.stderr.write('Desktop publication stopped. Inspect the retained operation record and configuration before retrying; remote effects may be incomplete.\n') + process.exitCode = 1 + } +} diff --git a/apps/desktop/scripts/verify-published-windows.ps1 b/apps/desktop/scripts/verify-published-windows.ps1 new file mode 100644 index 0000000000..2e2f772451 --- /dev/null +++ b/apps/desktop/scripts/verify-published-windows.ps1 @@ -0,0 +1,16 @@ +# Verify the existing installer; never sign or access the hardware token. +param( + [Parameter(Mandatory = $true)][string]$Installer, + [Parameter(Mandatory = $true)][string]$Thumbprint +) +$ErrorActionPreference = 'Stop' +$signature = Get-AuthenticodeSignature -LiteralPath $Installer +if ($signature.Status -ne 'Valid' -or $null -eq $signature.SignerCertificate) { + throw 'Installer does not have a valid Authenticode signature.' +} +if ($signature.SignerCertificate.Thumbprint -ne $Thumbprint) { + throw 'Installer signer does not match the configured release certificate.' +} +if ($null -eq $signature.TimeStamperCertificate) { + throw 'Installer has no verified signing timestamp.' +} diff --git a/apps/desktop/scripts/verify-retained-desktop.ts b/apps/desktop/scripts/verify-retained-desktop.ts new file mode 100644 index 0000000000..613580da9a --- /dev/null +++ b/apps/desktop/scripts/verify-retained-desktop.ts @@ -0,0 +1,44 @@ +/** Recheck distribution signatures on retained installers without signing credentials. */ +import { execFileSync } from 'node:child_process' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { DesktopRetainedManifest } from './desktop-release-store.ts' +import type { DesktopPublishConfig } from './desktop-publish-config.ts' +import { verifyMacOSDiskImage, verifyMacOSNotarizedApplication } from './verify-macos-signature.mjs' + +/** + * Verify Windows Authenticode or macOS DMG and archived app notarization on the original platform. + * @param directory Retained attempt whose file hashes were already checked. + * @param manifest Validated inventory and packaging mode. + * @param config Public expected signer configuration. + * @returns Resolves after platform verification; extracted ZIP contents are removed on all exits. + */ +export async function verifyRetainedDesktopSignatures( + directory: string, manifest: DesktopRetainedManifest, config: DesktopPublishConfig, +): Promise { + const { source } = manifest + const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !/KEY|SECRET|TOKEN|PASSWORD/iu.test(key))) + const run = (command: string, args: string[]) => execFileSync(command, args, { env, encoding: 'utf8', timeout: 120_000, maxBuffer: 16 * 1024 * 1024 }) + if (source.target === 'win-x64') { + if (process.platform !== 'win32' || source.qualification !== 'signed' || !config.windows) throw new Error('desktop publish: signed Windows package and signer configuration required on Windows') + const installer = manifest.files.find(file => file.name.endsWith('.exe')) + if (!installer) throw new Error('desktop publish: installer missing') + run('pwsh', ['-NoProfile', '-NonInteractive', '-File', join(import.meta.dirname, 'verify-published-windows.ps1'), + '-Installer', join(directory, installer.name), '-Thumbprint', config.windows.certificateThumbprint]) + return + } + if (process.platform !== 'darwin' || source.qualification !== 'notarized' || !config.macOS) throw new Error('desktop publish: notarized package and signer configuration required on macOS') + const dmg = manifest.files.find(file => file.name.endsWith('.dmg')) + const zip = manifest.files.find(file => file.name.endsWith('.zip')) + if (!dmg || !zip) throw new Error('desktop publish: DMG and ZIP are required') + verifyMacOSDiskImage(join(directory, dmg.name), config.macOS) + const zipPath = join(directory, zip.name) + const listing = run('/usr/bin/unzip', ['-Z1', zipPath]).trim().split('\n') + if (listing.some(name => name.startsWith('/') || name.includes('\\') || name.split('/').includes('..'))) throw new Error('desktop publish: unsafe ZIP path') + const extracted = await mkdtemp(join(tmpdir(), 'desktop-publish-verify-')) + try { + run('/usr/bin/ditto', ['-x', '-k', zipPath, extracted]) + verifyMacOSNotarizedApplication(join(extracted, 'DeepSeek Harness.app'), config.macOS) + } finally { await rm(extracted, { recursive: true, force: true }) } +} diff --git a/apps/desktop/tests/desktop-publication-preflight.spec.ts b/apps/desktop/tests/desktop-publication-preflight.spec.ts new file mode 100644 index 0000000000..268017a498 --- /dev/null +++ b/apps/desktop/tests/desktop-publication-preflight.spec.ts @@ -0,0 +1,59 @@ +import { createHash } from 'node:crypto' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { expect, it } from 'vitest' +import { retainDesktopCi } from '../scripts/retain-desktop-ci.ts' +import { prepareDesktopPublication } from '../scripts/publish-desktop-ci.ts' + +it.each(['mac-arm64', 'win-x64'] as const)('preflights a complete retained %s build without credentials or source checkout manifests', async (target) => { + const root = await mkdtemp(join(tmpdir(), 'desktop-preflight-')) + try { + const app = join(root, 'app') + const configRoot = join(root, 'packaging') + const artifacts = join(app, '.desktop-build/targets', target, 'artifacts') + await mkdir(artifacts, { recursive: true }) + await mkdir(join(configRoot, 'presets'), { recursive: true }) + await mkdir(join(configRoot, 'publishing')) + await writeFile(join(app, 'package.json'), JSON.stringify({ version: '1.0.0' })) + const releaseId = 'a'.repeat(32) + await writeFile(join(configRoot, 'presets/test.env'), `DSH_DESKTOP_AUTO_UPDATE_ENV=test\nDOWNLOAD_TEST_RELEASE_ID=${releaseId}\n`) + await writeFile(join(configRoot, 'publishing/test.json'), JSON.stringify({ + bucket: 'fixture-1250000000', origin: 'https://example.com', credentialsFile: join(root, 'absent-credentials.json'), + })) + const commit = 'b'.repeat(40) + const base = `deepseek-harness-1.0.0-${target}` + const extension = target === 'win-x64' ? 'exe' : 'zip' + const bytes = 'fixture installer bytes' + await writeFile(join(artifacts, `${base}.${extension}`), bytes) + await writeFile(join(artifacts, `${base}.${extension}.blockmap`), 'fixture blockmap') + if (target !== 'win-x64') await writeFile(join(artifacts, `${base}.dmg`), 'fixture DMG') + await writeFile(join(artifacts, `${target}-release.json`), JSON.stringify({ + schemaVersion: 1, target, environment: 'test', version: '1.0.0', commit, dirty: false, + publicUrl: `https://example.com/dsh-desk/${releaseId}/feeds/${target}/`, + })) + await writeFile(join(artifacts, target === 'win-x64' ? 'nightly.yml' : 'nightly-mac.yml'), JSON.stringify({ + version: '1.0.0', files: [{ url: `${base}.${extension}`, size: Buffer.byteLength(bytes), + sha512: createHash('sha512').update(bytes).digest('base64') }], + })) + const env = { + DSH_PACKAGING_CONFIG_DIR: configRoot, DSH_PACKAGING_PRESET: 'test', + DSH_DESKTOP_TARGET_PLATFORM: target === 'win-x64' ? 'win32' : 'darwin', DSH_DESKTOP_TARGET_ARCH: target === 'win-x64' ? 'x64' : 'arm64', + PACKAGE_VERSION: '1.0.0', PACKAGE_PUBLISH: 'true', PACKAGE_SIGNING: 'signed', PACKAGE_SKIP_NOTARIZATION: 'false', + GITHUB_RUN_ID: '10', GITHUB_RUN_ATTEMPT: '2', GITHUB_REPOSITORY: 'owner/repo', GITHUB_SHA: commit, RUNNER_NAME: 'fixture-runner', + DESKTOP_PUBLISH_ROUTE: JSON.stringify({ runId: '10', attempt: '2', commit, runnerName: 'fixture-runner', target, preset: 'test' }), + } + const retained = await retainDesktopCi(env, app) + await rm(app, { recursive: true }) + let signatureChecks = 0 + const signature = async () => { signatureChecks++ } + const prepared = await prepareDesktopPublication(env, signature) + expect(prepared.plan.version).toBe('1.0.0') + expect(prepared.plan.artifacts.every(file => file.path.startsWith(retained))).toBe(true) + expect(signatureChecks).toBe(1) + await expect(prepareDesktopPublication({ ...env, RUNNER_NAME: 'other-machine' }, signature)).rejects.toThrow(/different machine/u) + await writeFile(join(retained, `${base}.${extension}`), 'changed') + await expect(prepareDesktopPublication(env, signature)).rejects.toThrow(/changed file/u) + expect(signatureChecks).toBe(1) + } finally { await rm(root, { recursive: true, force: true }) } +}) diff --git a/apps/desktop/tests/desktop-publish-config.spec.ts b/apps/desktop/tests/desktop-publish-config.spec.ts new file mode 100644 index 0000000000..9577581d82 --- /dev/null +++ b/apps/desktop/tests/desktop-publish-config.spec.ts @@ -0,0 +1,47 @@ +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { expect, it } from 'vitest' +import { readDesktopPublishConfig, readDesktopUploadCredentials } from '../scripts/desktop-publish-config.ts' + +it('reads public settings without opening the absent credential file and requires production App settings', async () => { + const root = await mkdtemp(join(tmpdir(), 'publish-config-')) + try { + await mkdir(join(root, 'publishing')) + const config = { bucket: 'test-1250000000', origin: 'https://example.com', credentialsFile: join(root, 'absent.json') } + await writeFile(join(root, 'publishing/test.json'), JSON.stringify(config)) + const selected = await readDesktopPublishConfig(root, 'test') + expect(selected).toEqual(config) + await expect(readDesktopUploadCredentials(selected)).rejects.toThrow(/cannot read valid upload credentials/u) + await writeFile(join(root, 'publishing/production.json'), JSON.stringify({ ...config, origin: 'https://download.deepseek.com' })) + await expect(readDesktopPublishConfig(root, 'production')).rejects.toThrow(/GitHub App configuration/u) + await writeFile(config.credentialsFile, JSON.stringify({ secretId: 'fixture', secretKey: 'fixture-only' })) + await expect(readDesktopUploadCredentials(selected)).resolves.toEqual({ secretId: 'fixture', secretKey: 'fixture-only' }) + } finally { await rm(root, { recursive: true, force: true }) } +}) + +it('does not include malformed credential contents in errors', async () => { + const root = await mkdtemp(join(tmpdir(), 'publish-config-')) + try { + const credentialsFile = join(root, 'credentials.json') + await writeFile(credentialsFile, '{secret-value-should-not-appear') + await expect(readDesktopUploadCredentials({ credentialsFile, bucket: 'test-1', origin: 'https://example.com' })) + .rejects.not.toThrow(/secret-value/u) + } finally { await rm(root, { recursive: true, force: true }) } +}) + +it('rejects multiline App outputs before writing Actions output fields', async () => { + const root = await mkdtemp(join(tmpdir(), 'publish-config-')) + try { + await mkdir(join(root, 'publishing')) + const config = { bucket: 'test-1', origin: 'https://download.deepseek.com', credentialsFile: join(root, 'absent.json'), + githubApp: { clientId: 'app-id', privateKeyFile: join(root, 'key.pem') } } + for (const githubApp of [ + { ...config.githubApp, clientId: 'app-id\nother=value' }, + { ...config.githubApp, privateKeyFile: join(root, 'key\nother=value') }, + ]) { + await writeFile(join(root, 'publishing/production.json'), JSON.stringify({ ...config, githubApp })) + await expect(readDesktopPublishConfig(root, 'production')).rejects.toThrow(/single-line/u) + } + } finally { await rm(root, { recursive: true, force: true }) } +}) diff --git a/apps/desktop/tests/desktop-publish-cos.spec.ts b/apps/desktop/tests/desktop-publish-cos.spec.ts new file mode 100644 index 0000000000..1d6fd1798a --- /dev/null +++ b/apps/desktop/tests/desktop-publish-cos.spec.ts @@ -0,0 +1,40 @@ +import { expect, it } from 'vitest' +import { createDesktopCos } from '../scripts/desktop-cos.ts' +import { desktopPublishCos } from '../scripts/desktop-publish-cos.ts' +import { desktopArtifactDigest } from '../scripts/desktop-publish.ts' +import type { DesktopUploadPlan } from '../scripts/desktop-upload-plan.ts' +import { answer, cosError, startCosLoopback } from './cos-loopback.ts' + +it('uses overwrite prevention, streams verified bytes, and hashes remote readback through the SDK', async () => { + const objects = new Map() + const server = await startCosLoopback((request, response) => { + if (request.path.includes('versioning')) { answer(response, 200, ''); return } + if (request.method === 'PUT') { + expect(request.headers['x-cos-forbid-overwrite']).toBe('true') + objects.set(request.path, request.body) + answer(response) + } else { + const bytes = objects.get(request.path) + if (bytes === undefined) answer(response, 404, cosError('NoSuchKey')) + else answer(response, 200, bytes) + } + }) + try { + const artifact = { path: '', filename: 'file.exe', key: 'bin/file.exe', contents: 'fixture bytes', contentType: 'application/octet-stream', channelMetadata: false } + const plan: DesktopUploadPlan = { + environment: 'test', target: 'win-x64', version: '1.0.0', bucket: 'fixture-1250000000', publicUrl: 'https://example.com/', + secretIdEnvName: 'ID', secretKeyEnvName: 'KEY', artifacts: [artifact], + } + const transport = desktopPublishCos(plan, { secretId: 'fixture-id', secretKey: 'fixture-secret' }, (credentials) => { + const client = createDesktopCos(credentials) + server.redirect(client) + return client + }) + expect(await transport.read(artifact.key, false)).toBeNull() + const digest = await desktopArtifactDigest(artifact) + await transport.put(artifact, digest, true) + expect(await transport.read(artifact.key, false)).toEqual(digest) + await expect(transport.read('elsewhere/file.exe', false)).rejects.toThrow(/outside/u) + expect(server.requests.filter(request => request.method === 'PUT')).toHaveLength(1) + } finally { await server.close() } +}) diff --git a/apps/desktop/tests/desktop-publish-record.spec.ts b/apps/desktop/tests/desktop-publish-record.spec.ts new file mode 100644 index 0000000000..b0e73923df --- /dev/null +++ b/apps/desktop/tests/desktop-publish-record.spec.ts @@ -0,0 +1,67 @@ +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { execFile } from 'node:child_process' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { promisify } from 'node:util' +import { expect, it } from 'vitest' +import { createDesktopPublicationRecord, wasDesktopAttemptPublished, withDesktopChannelLock } from '../scripts/desktop-publish-record.ts' + +it('excludes another process while a channel lock is held and admits it after release', async () => { + const root = await mkdtemp(join(tmpdir(), 'desktop-process-lock-')) + const script = ` + const { withDesktopChannelLock } = await import(process.argv[1]); + try { + await withDesktopChannelLock(process.argv[2], 'bucket/mac', async () => process.stdout.write('acquired')); + } catch (error) { + if (!error.message.includes('locked')) throw error; + process.stdout.write('locked'); + } + ` + const contender = () => promisify(execFile)(process.execPath, ['--import', 'tsx/esm', '--input-type=module', '-e', script, + new URL('../scripts/desktop-publish-record.ts', import.meta.url).href, root], { encoding: 'utf8' }) + try { + await withDesktopChannelLock(root, 'bucket/mac', async () => { + expect((await contender()).stdout).toBe('locked') + }) + expect((await contender()).stdout).toBe('acquired') + } finally { await rm(root, { recursive: true, force: true }) } +}) + +it('excludes overlapping writers of one channel but permits independent channels and releases after failure', async () => { + const root = await mkdtemp(join(tmpdir(), 'desktop-lock-')) + try { + await withDesktopChannelLock(root, 'bucket/mac', async () => { + await expect(withDesktopChannelLock(root, 'bucket/mac', async () => {})).rejects.toThrow(/locked/u) + await expect(withDesktopChannelLock(root, 'bucket/win', async () => 'independent')).resolves.toBe('independent') + }) + await expect(withDesktopChannelLock(root, 'bucket/mac', async () => { throw new Error('fixture') })).rejects.toThrow('fixture') + await expect(withDesktopChannelLock(root, 'bucket/mac', async () => 'recovered')).resolves.toBe('recovered') + } finally { await rm(root, { recursive: true, force: true }) } +}) + +it('persists intent, before/after bytes and terminal state outside the checkout', async () => { + const root = await mkdtemp(join(tmpdir(), 'desktop-audit-')) + try { + const record = await createDesktopPublicationRecord(root, { repository: 'owner/repo', runId: '1', attempt: '2', + actor: 'operator', destination: 'bucket/feed/mac', operation: 'rollback', reason: 'restore previous release' }) + await record.record({ type: 'put-intent', key: 'feed/mac/nightly.yml', before: 'version: 2.0.0', after: 'version: 1.0.0' }) + await record.finish('complete') + expect(JSON.parse(await readFile(join(record.directory, 'operation.json'), 'utf8'))).toMatchObject({ actor: 'operator', operation: 'rollback' }) + expect(JSON.parse((await readFile(join(record.directory, 'events.jsonl'), 'utf8')).trim())).toMatchObject({ before: 'version: 2.0.0', after: 'version: 1.0.0' }) + expect(JSON.parse(await readFile(join(record.directory, 'result.json'), 'utf8'))).toMatchObject({ status: 'complete' }) + } finally { await rm(root, { recursive: true, force: true }) } +}) + +it('recognizes feed completion even when the later tag failed', async () => { + const root = await mkdtemp(join(tmpdir(), 'desktop-recovery-')) + try { + const identity = { repository: 'owner/repo', runId: '12', attempt: '2', destination: 'bucket/mac' } + const journal = await createDesktopPublicationRecord(root, { ...identity, actor: 'operator', operation: 'publish', reason: 'publish' }) + expect(await wasDesktopAttemptPublished(root, identity)).toBe(false) + await journal.record({ type: 'feeds-complete' }) + await journal.record({ type: 'tag-pending' }) + await journal.finish('published-tag-pending') + expect(await wasDesktopAttemptPublished(root, identity)).toBe(true) + expect(await wasDesktopAttemptPublished(root, { ...identity, attempt: '1' })).toBe(false) + } finally { await rm(root, { recursive: true, force: true }) } +}) diff --git a/apps/desktop/tests/desktop-publish-route.spec.ts b/apps/desktop/tests/desktop-publish-route.spec.ts new file mode 100644 index 0000000000..c83aa7d981 --- /dev/null +++ b/apps/desktop/tests/desktop-publish-route.spec.ts @@ -0,0 +1,37 @@ +import { expect, it } from 'vitest' +import { resolveDesktopPublishRoute } from '../scripts/desktop-publish-route.ts' + +function fixture() { + const run = { id: 42, run_attempt: 2, head_sha: 'a'.repeat(40), path: '.github/workflows/macos-package.yml', + conclusion: 'success', event: 'workflow_dispatch', repository: { full_name: 'owner/repo' } } + const source = { repository: 'owner/repo', runId: '42', attempt: '2', commit: run.head_sha, runnerName: 'mini', + target: 'mac-arm64', preset: 'test', publishRequested: true, qualification: 'notarized' } + const jobs = [{ runner_name: 'mini', conclusion: 'success', labels: ['self-hosted', 'dsh-mini'] }] + const routes = { mini: { label: 'dsh-mini', target: 'mac-arm64' } } + return { run, source, jobs, routes } +} + +it('routes the exact successful attempt to its registered machine', () => { + const f = fixture() + expect(resolveDesktopPublishRoute('owner/repo', f.run, { source: f.source }, f.jobs, f.routes)) + .toMatchObject({ runId: '42', attempt: '2', runnerLabel: 'dsh-mini', preset: 'test' }) +}) + +it.each(['attempt', 'repository', 'commit', 'mode', 'request', 'runner', 'workflow'] as const)('rejects mismatched %s evidence', (field) => { + const f = fixture() + if (field === 'attempt') f.source.attempt = '1' + if (field === 'repository') f.source.repository = 'other/repo' + if (field === 'commit') f.source.commit = 'b'.repeat(40) + if (field === 'mode') f.source.qualification = 'unnotarized' + if (field === 'request') f.source.publishRequested = false + if (field === 'runner') f.jobs[0]!.runner_name = 'laptop' + if (field === 'workflow') f.run.path = '.github/workflows/ci.yml' + expect(() => resolveDesktopPublishRoute('owner/repo', f.run, { source: f.source }, f.jobs, f.routes)).toThrow() +}) + +it('fails closed when a publishing route or its unique label is missing', () => { + const f = fixture() + expect(() => resolveDesktopPublishRoute('owner/repo', f.run, { source: f.source }, f.jobs, {})).toThrow(/no configured/u) + const duplicates = { ...f.routes, laptop: { label: 'dsh-mini', target: 'mac-arm64' } } + expect(() => resolveDesktopPublishRoute('owner/repo', f.run, { source: f.source }, f.jobs, duplicates)).toThrow(/unique runner label/u) +}) diff --git a/apps/desktop/tests/desktop-publish-tag.spec.ts b/apps/desktop/tests/desktop-publish-tag.spec.ts new file mode 100644 index 0000000000..868befe015 --- /dev/null +++ b/apps/desktop/tests/desktop-publish-tag.spec.ts @@ -0,0 +1,53 @@ +import { expect, it } from 'vitest' +import { publishDesktopTag } from '../scripts/desktop-publish-tag.ts' + +const commit = 'a'.repeat(40) +const tag = (sha = commit) => ({ object: { type: 'commit', sha } }) +function transport(replies: Array<{ status: number; body?: object }>) { + const calls: Array<{ url: string; init: RequestInit | undefined }> = [] + const request: typeof fetch = async (url, init) => { + calls.push({ url: typeof url === 'string' ? url : url instanceof URL ? url.href : url.url, init }) + const reply = replies.shift() + if (!reply) throw new Error('unexpected HTTP request') + return new Response(JSON.stringify(reply.body ?? {}), { status: reply.status }) + } + return { calls, request } +} + +it('tags the packaged commit using explicit App authentication', async () => { + const http = transport([{ status: 404 }, { status: 201, body: tag() }]) + await expect(publishDesktopTag('owner/repo', '1.2.3', commit, 'fixture-token', http.request)).resolves.toBe('created') + expect(http.calls[1]?.init?.body).toBe(JSON.stringify({ ref: 'refs/tags/desktop-v1.2.3', sha: commit })) + expect(http.calls[1]?.init?.headers).toMatchObject({ Authorization: 'Bearer fixture-token' }) +}) + +it('does not write when the same release tag already exists', async () => { + const http = transport([{ status: 200, body: tag() }]) + await expect(publishDesktopTag('owner/repo', '1.2.3', commit, 'fixture-token', http.request)).resolves.toBe('present') + expect(http.calls).toHaveLength(1) +}) + +it('rejects a conflicting tag without updating or deleting it', async () => { + const http = transport([{ status: 200, body: tag('b'.repeat(40)) }]) + await expect(publishDesktopTag('owner/repo', '1.2.3', commit, 'fixture-token', http.request)).rejects.toThrow(/refusing to move/u) + expect(http.calls).toHaveLength(1) +}) + +it('reconciles a create race between two platform releases', async () => { + const http = transport([{ status: 404 }, { status: 422 }, { status: 200, body: tag() }]) + await expect(publishDesktopTag('owner/repo', '1.2.3', commit, 'fixture-token', http.request)).resolves.toBe('present') + expect(http.calls.map(call => call.init?.method ?? 'GET')).toEqual(['GET', 'POST', 'GET']) +}) + +it('does not reinterpret authorization failure as a missing tag', async () => { + const http = transport([{ status: 403 }]) + await expect(publishDesktopTag('owner/repo', '1.2.3', commit, 'fixture-token', http.request)).rejects.toThrow(/HTTP 403/u) + expect(http.calls).toHaveLength(1) +}) + +it('rejects missing credentials and malformed inputs before any request', async () => { + const http = transport([]) + await expect(publishDesktopTag('owner/repo', '1.2.3', commit, '', http.request)).rejects.toThrow(/required/u) + await expect(publishDesktopTag('owner/repo/other', '1.2.3', commit, 'fixture', http.request)).rejects.toThrow(/invalid/u) + expect(http.calls).toHaveLength(0) +}) diff --git a/apps/desktop/tests/desktop-publish.spec.ts b/apps/desktop/tests/desktop-publish.spec.ts new file mode 100644 index 0000000000..e3067a0838 --- /dev/null +++ b/apps/desktop/tests/desktop-publish.spec.ts @@ -0,0 +1,107 @@ +import { createHash } from 'node:crypto' +import { expect, it } from 'vitest' +import { publishDesktopRelease, type DesktopPublishEvent, type DesktopPublishOperations, type DesktopRemoteObject } from '../scripts/desktop-publish.ts' +import type { DesktopUploadPlan } from '../scripts/desktop-upload-plan.ts' + +function observed(contents: string): DesktopRemoteObject { + return { contents, size: Buffer.byteLength(contents), sha512: createHash('sha512').update(contents).digest('base64') } +} +function fixture(environment: 'test' | 'production' = 'test') { + const objects = new Map() + const writes: string[] = [] + const events: DesktopPublishEvent[] = [] + let tags = 0 + const plan: DesktopUploadPlan = { + environment, target: 'win-x64', version: '1.0.0', bucket: 'fixture', publicUrl: 'https://example.com/feed/', + secretIdEnvName: 'ID', secretKeyEnvName: 'KEY', commit: 'a'.repeat(40), dirty: false, + artifacts: [ + { path: '', filename: 'installer.exe', key: 'bin/installer.exe', contentType: 'application/octet-stream', channelMetadata: false, contents: 'installer' }, + { path: '', filename: 'installer.blockmap', key: 'bin/installer.blockmap', contentType: 'application/octet-stream', channelMetadata: false, contents: 'blockmap' }, + { path: '', filename: 'nightly.yml', key: 'feed/nightly.yml', contentType: 'application/yaml', channelMetadata: true, contents: 'version: 1.0.0\n' }, + ], + } + const operations: DesktopPublishOperations = { + transport: { + async read(key) { return objects.get(key) ?? null }, + async put(artifact, _digest, forbidOverwrite) { + if (forbidOverwrite && objects.has(artifact.key)) throw new Error('already exists') + writes.push(artifact.key) + objects.set(artifact.key, observed(artifact.contents!)) + }, + }, + async record(event) { events.push(event) }, + async tag() { tags++ }, + } + return { plan, operations, objects, writes, events, tags: () => tags } +} + +it('uploads files before the feed and skips identical bytes on a repeat invocation', async () => { + const f = fixture() + await expect(publishDesktopRelease(f.plan, f.operations)).resolves.toEqual({ status: 'complete' }) + expect(f.writes).toEqual(['bin/installer.exe', 'bin/installer.blockmap', 'feed/nightly.yml']) + await publishDesktopRelease(f.plan, f.operations) + expect(f.writes).toHaveLength(3) + expect(f.tags()).toBe(0) + expect(f.events.find(event => event.key === 'feed/nightly.yml' && event.type === 'put-intent')) + .toMatchObject({ before: null, after: 'version: 1.0.0\n' }) +}) + +it('recovers an ambiguous binary write without repeating it or publishing the feed early', async () => { + const f = fixture() + const put = f.operations.transport.put.bind(f.operations.transport) + let fail = true + f.operations.transport.put = async (...args) => { + await put(...args) + if (fail) { fail = false; throw new Error('connection lost after server stored bytes') } + } + await expect(publishDesktopRelease(f.plan, f.operations)).rejects.toThrow(/connection lost/u) + expect(f.objects.has('feed/nightly.yml')).toBe(false) + await publishDesktopRelease(f.plan, f.operations) + expect(f.writes).toEqual(['bin/installer.exe', 'bin/installer.blockmap', 'feed/nightly.yml']) +}) + +it('reports published but tag pending and retries only the tag', async () => { + const f = fixture('production') + let fail = true + const tag = f.operations.tag.bind(f.operations) + f.operations.tag = async () => { if (fail) throw new Error('GitHub unavailable'); await tag() } + await expect(publishDesktopRelease(f.plan, f.operations)).resolves.toMatchObject({ status: 'published-tag-pending' }) + expect(f.events.at(-1)?.type).toBe('tag-pending') + fail = false + await expect(publishDesktopRelease(f.plan, f.operations)).resolves.toEqual({ status: 'complete' }) + expect(f.writes).toHaveLength(3) + expect(f.tags()).toBe(1) +}) + +it.each(['binary', 'same-version-feed', 'newer-feed'] as const)('stops on %s conflict', async (kind) => { + const f = fixture() + if (kind === 'binary') f.objects.set('bin/installer.exe', observed('different')) + else f.objects.set('feed/nightly.yml', observed(kind === 'newer-feed' ? 'version: 2.0.0\n' : 'version: 1.0.0\nchanged: true\n')) + await expect(publishDesktopRelease(f.plan, f.operations)).rejects.toMatchObject({ code: kind === 'newer-feed' ? 'superseded' : 'conflict' }) + expect(f.writes).toHaveLength(0) +}) + +it('rejects a files-only plan and a production build without clean commit evidence', async () => { + const f = fixture('production') + await expect(publishDesktopRelease({ ...f.plan, artifacts: f.plan.artifacts.slice(0, 2) }, f.operations)).rejects.toMatchObject({ code: 'invalid-plan' }) + await expect(publishDesktopRelease({ ...f.plan, dirty: true }, f.operations)).rejects.toMatchObject({ code: 'invalid-plan' }) + expect(f.writes).toHaveLength(0) +}) + +it('stops when a channel changes between preflight and writing', async () => { + const f = fixture() + const put = f.operations.transport.put.bind(f.operations.transport) + f.operations.transport.put = async (...args) => { + await put(...args) + f.objects.set('feed/nightly.yml', observed('version: 3.0.0\n')) + } + await expect(publishDesktopRelease(f.plan, f.operations)).rejects.toMatchObject({ code: 'remote-changed' }) + expect(f.writes).not.toContain('feed/nightly.yml') +}) + +it('does not update the channel when readback cannot confirm a binary', async () => { + const f = fixture() + f.operations.transport.put = async () => {} + await expect(publishDesktopRelease(f.plan, f.operations)).rejects.toMatchObject({ code: 'verification-failed' }) + expect(f.events.some(event => event.type === 'feeds-complete')).toBe(false) +}) diff --git a/apps/desktop/tests/desktop-release-store.spec.ts b/apps/desktop/tests/desktop-release-store.spec.ts new file mode 100644 index 0000000000..7ab2bb7e40 --- /dev/null +++ b/apps/desktop/tests/desktop-release-store.spec.ts @@ -0,0 +1,70 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it } from 'vitest' +import { retainDesktopRelease, retainedDesktopPath, verifyRetainedDesktopRelease, type DesktopRetainedSource } from '../scripts/desktop-release-store.ts' + +const roots: string[] = [] +afterEach(async () => { await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))) }) + +async function fixture() { + const root = await mkdtemp(join(tmpdir(), 'desktop-release-store-')) + roots.push(root) + const file = join(root, 'installer.exe') + await writeFile(file, 'signed installer fixture') + const source: DesktopRetainedSource = { + repository: 'owner/repository', runId: '100', attempt: '1', commit: 'a'.repeat(40), runnerName: 'windows-runner', + target: 'win-x64', preset: 'test', productVersion: '1.0.0', version: '1.0.0', testReleaseId: 'b'.repeat(32), + publishRequested: true, qualification: 'signed', + } + return { root: join(root, 'releases'), file, source } +} + +it('retains independent attempts after source removal and rejects replacing an existing attempt', async () => { + const { root, file, source } = await fixture() + const first = await retainDesktopRelease(root, source, [file]) + const second = await retainDesktopRelease(root, { ...source, attempt: '2' }, [file]) + expect(first).not.toBe(second) + await expect(retainDesktopRelease(root, source, [file])).rejects.toThrow(/already exists/u) + await rm(file) + const manifest = await verifyRetainedDesktopRelease(root, '100', '1') + expect(manifest.source).toEqual(source) + expect(await readFile(join(first, 'installer.exe'), 'utf8')).toBe('signed installer fixture') +}) + +it('detects changed bytes even when file length is unchanged', async () => { + const { root, file, source } = await fixture() + const directory = await retainDesktopRelease(root, source, [file]) + const path = join(directory, 'installer.exe') + await writeFile(path, Buffer.alloc((await readFile(path)).length)) + await expect(verifyRetainedDesktopRelease(root, '100', '1')).rejects.toThrow(/changed file/u) +}) + +it('does not expose an incomplete copy and releases its claim after failure', async () => { + const { root, file, source } = await fixture() + await expect(retainDesktopRelease(root, source, [file, join(root, 'missing.zip')])).rejects.toThrow() + await expect(verifyRetainedDesktopRelease(root, '100', '1')).rejects.toThrow() + await retainDesktopRelease(root, source, [file]) + await expect(verifyRetainedDesktopRelease(root, '100', '1')).resolves.toMatchObject({ source }) +}) + +it('permits only one writer for a concurrently retained attempt', async () => { + const { root, file, source } = await fixture() + const results = await Promise.allSettled([ + retainDesktopRelease(root, source, [file]), retainDesktopRelease(root, source, [file]), + ]) + expect(results.filter(result => result.status === 'fulfilled')).toHaveLength(1) + await expect(verifyRetainedDesktopRelease(root, '100', '1')).resolves.toMatchObject({ source }) +}) + +it('rejects traversal and manifest identity or file inventory tampering', async () => { + const { root, file, source } = await fixture() + expect(() => retainedDesktopPath(root, '../100', '1')).toThrow() + expect(() => retainedDesktopPath(root, '100', '0')).toThrow() + const path = await retainDesktopRelease(root, source, [file]) + const manifest = await verifyRetainedDesktopRelease(root, '100', '1') + await writeFile(join(path, 'manifest.json'), JSON.stringify({ ...manifest, source: { ...source, attempt: '2' } })) + await expect(verifyRetainedDesktopRelease(root, '100', '1')).rejects.toThrow(/identity mismatch/u) + await writeFile(join(path, 'manifest.json'), JSON.stringify({ ...manifest, files: [{ ...manifest.files[0], name: '../installer.exe' }] })) + await expect(verifyRetainedDesktopRelease(root, '100', '1')).rejects.toThrow() +}) diff --git a/apps/desktop/tests/desktop-rollback.spec.ts b/apps/desktop/tests/desktop-rollback.spec.ts new file mode 100644 index 0000000000..e279959e7f --- /dev/null +++ b/apps/desktop/tests/desktop-rollback.spec.ts @@ -0,0 +1,56 @@ +import { createHash } from 'node:crypto' +import { expect, it } from 'vitest' +import { rollbackDesktopChannel } from '../scripts/desktop-rollback.ts' +import type { DesktopPublishOperations } from '../scripts/desktop-publish.ts' +import type { DesktopUploadPlan } from '../scripts/desktop-upload-plan.ts' + +function fixture() { + const objects = new Map([['bin/old.exe', 'old installer'], ['feed/nightly.yml', 'version: 2.0.0\n']]) + const writes: string[] = [] + const plan: DesktopUploadPlan = { + environment: 'test', target: 'win-x64', version: '1.0.0', bucket: 'fixture', publicUrl: 'https://example.com', secretIdEnvName: 'ID', secretKeyEnvName: 'KEY', + artifacts: [ + { key: 'bin/old.exe', path: '', filename: 'old.exe', contentType: 'application/octet-stream', channelMetadata: false, contents: 'old installer' }, + { key: 'feed/nightly.yml', path: '', filename: 'nightly.yml', contentType: 'application/yaml', channelMetadata: true, contents: 'version: 1.0.0\n' }, + ], + } + const operations: DesktopPublishOperations = { + transport: { + async read(key) { + const contents = objects.get(key) + return contents === undefined ? null : { contents, size: Buffer.byteLength(contents), sha512: createHash('sha512').update(contents).digest('base64') } + }, + async put(artifact, _digest, forbidOverwrite) { + expect(forbidOverwrite).toBe(false) + writes.push(artifact.key) + objects.set(artifact.key, artifact.contents!) + }, + }, + async record() {}, + async tag() { throw new Error('must not create a tag') }, + } + return { plan, operations, writes, objects } +} + +it('previews the exact YAML change without writes', async () => { + const f = fixture() + const result = await rollbackDesktopChannel(f.plan, '2.0.0', false, f.operations) + expect(result).toMatchObject({ status: 'preview', feeds: [{ before: 'version: 2.0.0\n', after: 'version: 1.0.0\n' }] }) + expect(f.writes).toEqual([]) +}) + +it('only restores the YAML after checking the historical binary', async () => { + const f = fixture() + await expect(rollbackDesktopChannel(f.plan, '2.0.0', true, f.operations)).resolves.toMatchObject({ status: 'complete' }) + expect(f.writes).toEqual(['feed/nightly.yml']) + expect(f.objects.get('bin/old.exe')).toBe('old installer') +}) + +it.each(['missing-file', 'changed-file', 'changed-channel'] as const)('rejects %s without writes', async (problem) => { + const f = fixture() + if (problem === 'missing-file') f.objects.delete('bin/old.exe') + if (problem === 'changed-file') f.objects.set('bin/old.exe', 'changed') + if (problem === 'changed-channel') f.objects.set('feed/nightly.yml', 'version: 3.0.0\n') + await expect(rollbackDesktopChannel(f.plan, '2.0.0', true, f.operations)).rejects.toThrow() + expect(f.writes).toEqual([]) +}) diff --git a/apps/desktop/tests/desktop-upload-plan.spec.ts b/apps/desktop/tests/desktop-upload-plan.spec.ts index f1e2097075..fcdcb68ccf 100644 --- a/apps/desktop/tests/desktop-upload-plan.spec.ts +++ b/apps/desktop/tests/desktop-upload-plan.spec.ts @@ -292,6 +292,20 @@ describe('desktop upload plan', () => { })).rejects.toThrow(/completion record.*test/u) }) + it('validates retained builds against their original product version after the checkout advances', async () => { + const paths = await fixture('mac-arm64', '1.2.3') + await writeFile(join(paths.repositoryRoot, 'package.json'), '{"version":"2.0.0"}\n') + await writeFile(join(paths.appRoot, 'package.json'), '{"version":"2.0.0"}\n') + await expect(createDesktopUploadPlan('mac-arm64', paths)).rejects.toThrow(/completion record/u) + const plan = await createDesktopUploadPlan('mac-arm64', { ...paths, packagedProductVersion: '1.2.3' }) + expect(plan.version).toBe('1.2.3') + await expect(createDesktopUploadPlan('mac-arm64', { ...paths, packagedProductVersion: '1.2.4' })) + .rejects.toThrow(/completion record/u) + await writeFile(join(paths.artifactsRoot, 'deepseek-harness-1.2.3-mac-arm64.zip'), 'tampered') + await expect(createDesktopUploadPlan('mac-arm64', { ...paths, packagedProductVersion: '1.2.3' })) + .rejects.toThrow(/size.*metadata/u) + }) + it('rejects stale architecture metadata and modified updater bytes', async () => { const paths = await fixture('mac-arm64') const metadataPath = join(paths.artifactsRoot, 'nightly-mac.yml') diff --git a/apps/desktop/tests/retain-desktop-ci.spec.ts b/apps/desktop/tests/retain-desktop-ci.spec.ts new file mode 100644 index 0000000000..d0e6f3ae3a --- /dev/null +++ b/apps/desktop/tests/retain-desktop-ci.spec.ts @@ -0,0 +1,37 @@ +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it } from 'vitest' +import { desktopReleasesRoot, retainDesktopCi } from '../scripts/retain-desktop-ci.ts' +import { verifyRetainedDesktopRelease } from '../scripts/desktop-release-store.ts' + +const roots: string[] = [] +afterEach(async () => { await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))) }) + +it.each(['mac-arm64', 'win-x64'] as const)('retains the complete %s release without signing or upload credentials', async (target) => { + const root = await mkdtemp(join(tmpdir(), 'retain-ci-')) + roots.push(root) + const appRoot = join(root, 'checkout') + const config = join(root, 'packaging') + const artifacts = join(appRoot, '.desktop-build', 'targets', target, 'artifacts') + await mkdir(artifacts, { recursive: true }) + await mkdir(join(config, 'presets'), { recursive: true }) + await writeFile(join(appRoot, 'package.json'), '{"version":"1.0.0"}') + await writeFile(join(config, 'presets/test.env'), `DSH_DESKTOP_AUTO_UPDATE_ENV=test\nDOWNLOAD_TEST_RELEASE_ID=${'a'.repeat(32)}\n`) + const names = target === 'win-x64' + ? ['deepseek-harness-1.0.0-win-x64.exe', 'deepseek-harness-1.0.0-win-x64.exe.blockmap', 'win-x64-release.json', 'nightly.yml'] + : ['deepseek-harness-1.0.0-mac-arm64.dmg', 'deepseek-harness-1.0.0-mac-arm64.zip', 'deepseek-harness-1.0.0-mac-arm64.zip.blockmap', 'mac-arm64-release.json', 'nightly-mac.yml'] + await Promise.all(names.map(name => writeFile(join(artifacts, name), 'fixture'))) + const env = { + DSH_PACKAGING_CONFIG_DIR: config, DSH_PACKAGING_PRESET: 'test', DSH_DESKTOP_TARGET_PLATFORM: target === 'win-x64' ? 'win32' : 'darwin', + DSH_DESKTOP_TARGET_ARCH: target === 'win-x64' ? 'x64' : 'arm64', PACKAGE_VERSION: '1.0.0', PACKAGE_PUBLISH: 'true', + PACKAGE_SIGNING: 'signed', PACKAGE_SKIP_NOTARIZATION: 'false', GITHUB_RUN_ID: '123', GITHUB_RUN_ATTEMPT: '2', + GITHUB_REPOSITORY: 'owner/repository', GITHUB_SHA: 'b'.repeat(40), RUNNER_NAME: 'dedicated', + } + await retainDesktopCi(env, appRoot) + const manifest = await verifyRetainedDesktopRelease(desktopReleasesRoot(env), '123', '2') + expect(manifest.files.map(file => file.name)).toEqual(names) + expect(manifest.source).toMatchObject({ target, publishRequested: true, qualification: target === 'win-x64' ? 'signed' : 'notarized' }) + await expect(retainDesktopCi({ ...env, PACKAGE_SIGNING: 'unsigned', PACKAGE_SKIP_NOTARIZATION: 'true' }, appRoot)) + .rejects.toThrow(/publishing requires/u) +}) diff --git a/apps/desktop/tests/windows-package-preset.spec.ts b/apps/desktop/tests/windows-package-preset.spec.ts index d8d6ad6ce7..84cb72921c 100644 --- a/apps/desktop/tests/windows-package-preset.spec.ts +++ b/apps/desktop/tests/windows-package-preset.spec.ts @@ -90,3 +90,23 @@ it('cache maintenance reaches cache path validation without a signing file', asy expect(result.stderr).not.toContain('cannot read') }) }) + +it.each(['win32', 'darwin'] as const)('applies an explicit test channel override on %s without changing the preset file', async (platform) => { + await withPreset(async (root) => { + const path = join(root, 'presets/test.env') + const original = 'a'.repeat(32) + await writeFile(path, `DSH_DESKTOP_AUTO_UPDATE_ENV=test\nDOWNLOAD_TEST_RELEASE_ID=${original}\n`) + const env = { DSH_PACKAGING_CONFIG_DIR: root, DSH_PACKAGING_PRESET: 'test' } + const select = (id: string) => loadDesktopPackageEnvironment(platform, + { ...env, PACKAGE_TEST_RELEASE_ID: id }, root, { unsigned: true }) + expect(select('b'.repeat(32)).DOWNLOAD_TEST_RELEASE_ID).toBe('b'.repeat(32)) + expect(select('').DOWNLOAD_TEST_RELEASE_ID).toBe(original) + expect(select(' ').DOWNLOAD_TEST_RELEASE_ID).toBe(original) + expect(() => select('../other')).toThrow(/32 lowercase hexadecimal/u) + expect(() => select('A'.repeat(32))).toThrow(/32 lowercase hexadecimal/u) + await writeFile(join(root, 'presets/production.env'), 'DSH_DESKTOP_AUTO_UPDATE_ENV=production\n') + expect(() => loadDesktopPackageEnvironment(platform, + { ...env, DSH_PACKAGING_PRESET: 'production', PACKAGE_TEST_RELEASE_ID: original }, root, { unsigned: true })) + .toThrow(/requires the test preset/u) + }) +}) diff --git a/scripts/desktop-publish-workflow.spec.ts b/scripts/desktop-publish-workflow.spec.ts new file mode 100644 index 0000000000..2db19a7dc7 --- /dev/null +++ b/scripts/desktop-publish-workflow.spec.ts @@ -0,0 +1,35 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import yaml from 'js-yaml' +import { expect, it } from 'vitest' + +const workflow = yaml.load(readFileSync(resolve(import.meta.dirname, '../.github/workflows/desktop-publish.yml'), 'utf8')) as { + on: Record + permissions: Record + jobs: Record; with?: Record }> + }> +} + +it('routes only completed package requests and gates production secrets with the production Environment', () => { + expect(workflow.on).toEqual({ workflow_run: { + workflows: ['Package Desktop · macOS', 'Package Desktop · Windows'], types: ['completed'], + } }) + expect(workflow.permissions).toEqual({ contents: 'read', actions: 'read' }) + expect(workflow.jobs.route?.if).toBe("github.event.workflow_run.conclusion == 'success'") + const publish = workflow.jobs.publish! + expect(publish.if).toBe("needs.route.outputs.publish == 'true'") + expect(publish['runs-on']).toEqual(['self-hosted', '${{ needs.route.outputs.runner }}']) + expect(publish.environment).toBe("${{ needs.route.outputs.preset == 'production' && 'desktop-production' || 'desktop-test' }}") + const ids = publish.steps.map(step => step.id) + expect(ids.indexOf('check')).toBeLessThan(ids.indexOf('key')) + expect(ids.indexOf('key')).toBeLessThan(ids.indexOf('app')) + expect(ids.indexOf('app')).toBeLessThan(ids.indexOf('publish')) + for (const id of ['key', 'app']) expect(publish.steps.find(step => step.id === id)?.if) + .toBe("needs.route.outputs.preset == 'production'") + expect(publish.steps.find(step => step.id === 'app')?.with).toMatchObject({ 'permission-contents': 'write' }) + expect(publish.steps.find(step => step.id === 'publish')?.env?.DESKTOP_TAG_TOKEN).toBe('${{ steps.app.outputs.token }}') +}) diff --git a/scripts/macos-package-workflow.spec.ts b/scripts/macos-package-workflow.spec.ts index 8e02578a2c..30891dbddb 100644 --- a/scripts/macos-package-workflow.spec.ts +++ b/scripts/macos-package-workflow.spec.ts @@ -22,10 +22,10 @@ it('confines signed Mac packaging to manual dispatch and a dedicated Apple Silic expect(steps.find(step => step.id === 'checkout')?.with).toMatchObject({ 'persist-credentials': false, clean: true }) for (const step of steps) expect(step.run ?? '').not.toContain('${{ inputs.') const uploads = steps.filter(step => step.uses?.startsWith('actions/upload-artifact@')) - expect(uploads).toHaveLength(2) + expect(uploads).toHaveLength(3) expect(uploads[0]?.if).toBe("always() && steps.checkout.outcome == 'success'") expect(uploads[1]?.if).toBe('success()') - expect(uploads[1]?.with?.path).toBe('apps/desktop/.desktop-build/targets/mac-arm64/${{ steps.settings.outputs.artifacts }}/*.dmg\napps/desktop/.desktop-build/targets/mac-arm64/${{ steps.settings.outputs.artifacts }}/*.zip\n') + expect(uploads[1]?.with?.path).toBe('${{ steps.retained.outputs.directory }}/*') }) it('defaults to notarization and labels the explicit debugging option', () => { @@ -35,5 +35,5 @@ it('defaults to notarization and labels the explicit debugging option', () => { } } }) const steps = workflow.jobs.package!.steps expect(steps.filter(step => step.run?.includes('args+=(--skip-notarization)'))).toHaveLength(2) - expect(steps.at(-1)?.with?.name).toContain('${{ steps.settings.outputs.mode }}') + expect(steps.find(step => step.name === 'Retain installers for the selected mode')?.with?.name).toContain('${{ steps.settings.outputs.mode }}') }) diff --git a/scripts/windows-package-workflow.spec.ts b/scripts/windows-package-workflow.spec.ts index a60d024582..3263ac1b0d 100644 --- a/scripts/windows-package-workflow.spec.ts +++ b/scripts/windows-package-workflow.spec.ts @@ -1,10 +1,9 @@ import { execFileSync } from 'node:child_process' import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' -import { relative, resolve } from 'node:path' +import { resolve } from 'node:path' import yaml from 'js-yaml' import { expect, it } from 'vitest' -import { desktopTargetBuildPaths } from '../apps/desktop/scripts/desktop-build-paths.mjs' const root = resolve(import.meta.dirname, '..') const workflow = yaml.load(readFileSync(resolve(root, '.github/workflows/windows-package.yml'), 'utf8')) as { @@ -63,7 +62,7 @@ it('routes manual packaging to the dedicated runner with explicit preset and sig expect(job['runs-on']).toBe('dsh-win-package-trial') expect(workflow.concurrency).toEqual({ group: 'windows-desktop-package', 'cancel-in-progress': false }) expect(workflow.permissions).toEqual({ contents: 'read' }) - expect(Object.keys(workflow.on.workflow_dispatch!.inputs).sort()).toEqual(['build_version', 'preset', 'signing']) + expect(Object.keys(workflow.on.workflow_dispatch!.inputs).sort()).toEqual(['build_version', 'preset', 'publish', 'signing', 'test_release_id']) expect(workflow.on.workflow_dispatch!.inputs.signing!.default).toBe('unsigned') expect(workflow.on.workflow_dispatch!.inputs.build_version!.description).toContain('prefix must match checkout') expect(job.env.DSH_PACKAGING_PRESET).toBe('${{ inputs.preset }}') @@ -91,11 +90,11 @@ it('validates before building and passes the same mode and version to checks and it('retains failure diagnostics and uploads only the selected successful installer', () => { const uploads = job.steps.filter(step => step.uses?.startsWith('actions/upload-artifact@')) - expect(uploads).toHaveLength(3) + expect(uploads).toHaveLength(4) expect(uploads[0]!.if).toBe("always() && steps.checkout.outcome == 'success'") expect(uploads[0]!.with).toMatchObject({ path: 'apps/desktop/.desktop-build/packaging-runs/', 'include-hidden-files': true }) - for (const [mode, directory] of [['unsigned', desktopTargetBuildPaths('win-x64').unsignedArtifacts], ['signed', desktopTargetBuildPaths('win-x64').artifacts]]) { + for (const mode of ['unsigned', 'signed']) { const upload = uploads.find(step => step.if === `success() && inputs.signing == '${mode}'`)! - expect(upload.with).toMatchObject({ path: `${relative(root, directory!).replaceAll('\\', '/')}/*.exe`, 'if-no-files-found': 'error' }) + expect(upload.with).toMatchObject({ path: '${{ steps.retained.outputs.directory }}/*', 'if-no-files-found': 'error' }) } }) From 04d46c1912b34303d686909753880bb2c2a5200b Mon Sep 17 00:00:00 2001 From: winewill Date: Tue, 13 Oct 2026 14:01:39 +0800 Subject: [PATCH 06/35] fix(desktop): use native shells for cross-platform publication --- .github/workflows/desktop-publish.yml | 14 ++-------- apps/desktop/scripts/read-desktop-app-key.mjs | 14 ++++++++++ .../tests/read-desktop-app-key.spec.ts | 26 +++++++++++++++++++ scripts/desktop-publish-workflow.spec.ts | 3 +++ 4 files changed, 45 insertions(+), 12 deletions(-) create mode 100644 apps/desktop/scripts/read-desktop-app-key.mjs create mode 100644 apps/desktop/tests/read-desktop-app-key.spec.ts diff --git a/.github/workflows/desktop-publish.yml b/.github/workflows/desktop-publish.yml index b5f6d1ad90..919170bdd4 100644 --- a/.github/workflows/desktop-publish.yml +++ b/.github/workflows/desktop-publish.yml @@ -46,9 +46,6 @@ jobs: env: DESKTOP_PUBLISH_ROUTE: ${{ needs.route.outputs.route }} DSH_TELEMETRY_DISABLED: '1' - defaults: - run: - shell: bash steps: - uses: actions/checkout@v7.0.1 with: @@ -73,15 +70,8 @@ jobs: id: key env: APP_KEY_FILE: ${{ steps.check.outputs.app-key-file }} - run: | - node --input-type=module <<'NODE' - import { readFileSync, appendFileSync } from 'node:fs'; - import { randomUUID } from 'node:crypto'; - const key = readFileSync(process.env.APP_KEY_FILE, 'utf8'); - console.log('::add-mask::' + key.replaceAll('%', '%25').replaceAll('\r', '%0D').replaceAll('\n', '%0A')); - const delimiter = randomUUID(); - appendFileSync(process.env.GITHUB_OUTPUT, `private-key<<${delimiter}\n${key}\n${delimiter}\n`); - NODE + working-directory: desktop-publisher + run: node apps/desktop/scripts/read-desktop-app-key.mjs - name: Create short-lived tag token if: needs.route.outputs.preset == 'production' id: app diff --git a/apps/desktop/scripts/read-desktop-app-key.mjs b/apps/desktop/scripts/read-desktop-app-key.mjs new file mode 100644 index 0000000000..590f7898d2 --- /dev/null +++ b/apps/desktop/scripts/read-desktop-app-key.mjs @@ -0,0 +1,14 @@ +/** Hand a machine App key to the token action after Environment approval, masking before output. */ +import { readFileSync, appendFileSync } from 'node:fs' +import { randomUUID } from 'node:crypto' + +try { + if (!process.env.APP_KEY_FILE || !process.env.GITHUB_OUTPUT) throw new Error('Actions key file and output are required') + const key = readFileSync(process.env.APP_KEY_FILE, 'utf8') + console.log('::add-mask::' + key.replaceAll('%', '%25').replaceAll('\r', '%0D').replaceAll('\n', '%0A')) + const delimiter = randomUUID() + appendFileSync(process.env.GITHUB_OUTPUT, `private-key<<${delimiter}\n${key}\n${delimiter}\n`) +} catch { + process.stderr.write('Desktop publication: cannot hand the configured App key to the token action.\n') + process.exitCode = 1 +} diff --git a/apps/desktop/tests/read-desktop-app-key.spec.ts b/apps/desktop/tests/read-desktop-app-key.spec.ts new file mode 100644 index 0000000000..4b67345f7e --- /dev/null +++ b/apps/desktop/tests/read-desktop-app-key.spec.ts @@ -0,0 +1,26 @@ +import { execFile } from 'node:child_process' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { promisify } from 'node:util' +import { expect, it } from 'vitest' + +it('masks multiline key bytes and passes the exact fixture to the token action without a shell', async () => { + const root = await mkdtemp(join(tmpdir(), 'desktop-app-key-')) + try { + const path = join(root, 'fixture.pem') + const output = join(root, 'output') + const key = 'fixture%key\r\nsecond-line\n' + await writeFile(path, key) + const result = await promisify(execFile)(process.execPath, + [fileURLToPath(new URL('../scripts/read-desktop-app-key.mjs', import.meta.url))], { + env: { ...process.env, APP_KEY_FILE: path, GITHUB_OUTPUT: output }, encoding: 'utf8', + }) + expect(result.stdout).toBe('::add-mask::fixture%25key%0D%0Asecond-line%0A\n') + expect(result.stderr).toBe('') + const contents = await readFile(output, 'utf8') + const delimiter = contents.slice('private-key<<'.length, contents.indexOf('\n')) + expect(contents).toBe(`private-key<<${delimiter}\n${key}\n${delimiter}\n`) + } finally { await rm(root, { recursive: true, force: true }) } +}) diff --git a/scripts/desktop-publish-workflow.spec.ts b/scripts/desktop-publish-workflow.spec.ts index 2db19a7dc7..2796f234e2 100644 --- a/scripts/desktop-publish-workflow.spec.ts +++ b/scripts/desktop-publish-workflow.spec.ts @@ -7,6 +7,7 @@ const workflow = yaml.load(readFileSync(resolve(import.meta.dirname, '../.github on: Record permissions: Record jobs: Record step.id === 'key')?.run).toBe('node apps/desktop/scripts/read-desktop-app-key.mjs') expect(publish.if).toBe("needs.route.outputs.publish == 'true'") expect(publish['runs-on']).toEqual(['self-hosted', '${{ needs.route.outputs.runner }}']) expect(publish.environment).toBe("${{ needs.route.outputs.preset == 'production' && 'desktop-production' || 'desktop-test' }}") From 6e629a5ef0ce9287bcbed814c1f0e6537d2ddc52 Mon Sep 17 00:00:00 2001 From: winewill Date: Tue, 13 Oct 2026 14:20:45 +0800 Subject: [PATCH 07/35] fix(desktop): preserve publish diagnostics and interrupted recovery --- .github/AGENTS.md | 2 ++ apps/desktop/publishing/README.i18n.yaml | 4 +-- apps/desktop/publishing/README.md | 2 +- apps/desktop/publishing/README.zh.md | 2 +- apps/desktop/publishing/ci/README.i18n.yaml | 12 +++---- apps/desktop/publishing/ci/README.md | 8 ++--- apps/desktop/publishing/ci/README.zh.md | 8 ++--- .../scripts/desktop-publication-failure.ts | 30 ++++++++++++++++ .../desktop/scripts/desktop-publish-record.ts | 19 ++++++---- apps/desktop/scripts/desktop-publish-route.ts | 2 +- apps/desktop/scripts/desktop-publish-tag.ts | 3 +- apps/desktop/scripts/desktop-publish.ts | 18 ++++++---- apps/desktop/scripts/desktop-release-store.ts | 1 + apps/desktop/scripts/desktop-rollback.ts | 4 +++ apps/desktop/scripts/publish-desktop-ci.ts | 17 ++++++--- .../scripts/rollback-desktop-channel.ts | 11 ++++-- .../tests/desktop-publication-failure.spec.ts | 36 +++++++++++++++++++ .../desktop/tests/desktop-publish-cos.spec.ts | 16 +++++++++ .../tests/desktop-publish-record.spec.ts | 15 +++++++- .../desktop/tests/desktop-publish-tag.spec.ts | 6 ++++ apps/desktop/tests/desktop-publish.spec.ts | 7 ++++ apps/desktop/tests/desktop-rollback.spec.ts | 23 ++++++++++++ scripts/macos-package-workflow.spec.ts | 2 ++ 23 files changed, 208 insertions(+), 40 deletions(-) create mode 100644 apps/desktop/scripts/desktop-publication-failure.ts create mode 100644 apps/desktop/tests/desktop-publication-failure.spec.ts diff --git a/.github/AGENTS.md b/.github/AGENTS.md index 502c1333ea..7358c0f5c4 100644 --- a/.github/AGENTS.md +++ b/.github/AGENTS.md @@ -3,3 +3,5 @@ Run jobs on Windows runners (`windows-*` labels) under native `pwsh`. Native Windows build and process checks contribute to the pull-request `all checks passed` verdict; Wine runs Windows Node on hosted Linux only in `ci-master.yml`. Python runtime CI checks Linux/Windows x64 on pull requests and Linux ARM64 plus both macOS architectures on master pushes; releases retain all five targets ([platform policy](workflows/ci-master.yml)). `ci.yml` is pull-request-only. Master-only platform checks, Linux/Windows self-hosted standbys, and manual runner benchmarks live in `ci-master.yml`, which listens to master pushes and `workflow_dispatch`, not `pull_request`; separating workflow triggers keeps master-only jobs out of PR check panels. The master standbys validate the self-hosted failover targets; preserve the existing per-platform switches (values `selfhosted` for the in-house standbys and `blacksmith` for Blacksmith's hosted runners) and the Dependabot hosted fallback under the `selfhosted` values ([failover runbook](../.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md), [blacksmith failover leg note](../.agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.md)). The manual [Windows packaging workflow](workflows/windows-package.yml) uses the dedicated self-hosted Windows x64 label `dsh-win-package-trial` under native `pwsh`; preserve this routing separately from the automatic CI lanes. + +The Desktop publication workflow routes each retained build through `DESKTOP_PUBLISH_RUNNERS` to its original machine's unique label. Keep Windows run steps on native PowerShell and Mac run steps on the default POSIX shell; shared entrypoints are Node scripts. Production jobs use the separate `desktop-production` approval Environment. diff --git a/apps/desktop/publishing/README.i18n.yaml b/apps/desktop/publishing/README.i18n.yaml index aa8eb8df42..9483eaff54 100644 --- a/apps/desktop/publishing/README.i18n.yaml +++ b/apps/desktop/publishing/README.i18n.yaml @@ -12,8 +12,8 @@ en: 722633597f74ace6 zh: 5303bcf5567a8511 /desktop-publication/table-of-contents: - en: f8b8485c16ef4dbb - zh: e6196274c5a246fe + en: 6217df1d38ff46aa + zh: 22375410c0816130 /desktop-publication/publication-prerequisites: en: b407de63118c9bf9 zh: de19f1ba9feacbbf diff --git a/apps/desktop/publishing/README.md b/apps/desktop/publishing/README.md index 0cd51400cc..71c52c1d75 100644 --- a/apps/desktop/publishing/README.md +++ b/apps/desktop/publishing/README.md @@ -19,7 +19,7 @@ Publish completed Desktop installers and update feeds to the selected download s - [Upload credentials](#credentials) - [Upload audit and recovery](#audit-and-recovery) -For retained self-hosted builds, use the [CI publication workflow](ci/README.md), which keeps installers on their original machine and supports resumable publication. +For retained self-hosted builds, use the [CI publication workflow](ci/README.md), which keeps installers on their original machine and supports resumable publication. The commands below remain the existing manually authorized local uploader: they retain checkout-local audit records and use the operator's Git identity. They do not share the CI publisher's locks, approval or tag-only recovery. Do not run both writers against one channel concurrently. Both use the same upload-plan validation; separate orchestration preserves existing manual commands while adding immutable-attempt recovery for CI. Consolidating or retiring the manual commands is separate work. diff --git a/apps/desktop/publishing/README.zh.md b/apps/desktop/publishing/README.zh.md index 7d11255494..22952efae4 100644 --- a/apps/desktop/publishing/README.zh.md +++ b/apps/desktop/publishing/README.zh.md @@ -19,7 +19,7 @@ description: "将完整 Desktop 安装包与更新清单发布到选定下载服 - [上传凭据](#credentials) - [上传审计与恢复](#audit-and-recovery) -自托管留存构建使用 [CI 发布工作流](ci/README.zh.md),在原机器保留安装包并支持恢复发布。 +自托管留存构建使用 [CI 发布工作流](ci/README.zh.md),在原机器保留安装包并支持恢复发布。 以下命令继续作为现有人工授权的本地上传器使用:它们保留 checkout 内的审计记录,并使用操作员的 Git 身份,不共享 CI 发布器的锁、审批或仅补 tag 恢复机制。不要让两种上传器并发写入同一频道。两者复用相同的上传计划校验;分开编排可保留现有手动命令,同时为 CI 增加不可变 attempt 恢复能力。统一或退役手动命令属于独立工作。 diff --git a/apps/desktop/publishing/ci/README.i18n.yaml b/apps/desktop/publishing/ci/README.i18n.yaml index 971919bd98..e391f93ff5 100644 --- a/apps/desktop/publishing/ci/README.i18n.yaml +++ b/apps/desktop/publishing/ci/README.i18n.yaml @@ -15,17 +15,17 @@ en: 1b6a4d7146879eec zh: f2520fc412cd2d2a /desktop-ci-publication/machine-setup: - en: 9fef91f1957219d7 - zh: df2c2038eb8a72ed + en: b130c91a6a82c71d + zh: 527206eb3bf4b4a8 /desktop-ci-publication/package-and-publish: en: 596fa81b1033ce87 zh: fde2729de6275764 /desktop-ci-publication/recovery-and-records: - en: 7617b2f746783d63 - zh: dcf0b081d740224d + en: 33e6d91a62f858f8 + zh: 4a2286c838f83682 /desktop-ci-publication/restore-a-channel: - en: 5e23b45714a6c8d4 - zh: 882427a071907981 + en: 98620fee90ea1271 + zh: 3517d565ae8d24dd /desktop-ci-publication/dev-note: en: b75c8458928b72b9 zh: 003ba4e17b4b5269 diff --git a/apps/desktop/publishing/ci/README.md b/apps/desktop/publishing/ci/README.md index 64095911d0..cbc65905da 100644 --- a/apps/desktop/publishing/ci/README.md +++ b/apps/desktop/publishing/ci/README.md @@ -27,7 +27,7 @@ Create `publishing/test.json` and, when authorized, `publishing/production.json` Set the repository variable `DESKTOP_PUBLISH_RUNNERS` to a JSON mapping of registered runner names to unique labels and targets, for example `{"mini":{"label":"dsh-mini-publish","target":"mac-arm64"}}`. Assign that label to exactly that runner. Windows uses target `win-x64`. The routing job checks the original successful job, and the publisher checks its own runner name against the recorded name. Machines need no shared filesystem or LAN connection to each other. -An administrator must create `desktop-production` with required reviewers and an allowed `master` deployment branch. The workflow checks that required reviewers exist before scheduling production. The GitHub App needs installation access to this repository and Contents write permission, plus permission under the `desktop-v*` tag ruleset to create release tags. Reviewer approval does not transfer the reviewer's tag permissions to the App. Missing configuration stops publication; there is no personal-token fallback. The App action creates and later revokes a short-lived token. Protect the local private key and COS credential files with the publishing account's filesystem permissions. +An administrator must create `desktop-production` with required reviewers and an allowed `master` deployment branch. The workflow checks that required reviewers exist before scheduling production. The GitHub App needs installation access to this repository and Contents write permission, plus permission under the `desktop-v*` tag ruleset to create release tags. Reviewer approval does not transfer the reviewer's tag permissions to the App. Missing configuration stops publication; there is no personal-token fallback. The App action creates and later revokes a short-lived token. Protect the local private key and COS credential files with the publishing account's filesystem permissions. The token action accepts key contents, so the masked PEM also passes through the runner's temporary `GITHUB_OUTPUT` file during the job; masking hides logs, not local file access. Environment approval gates this workflow, not arbitrary code running under the same machine account. Account isolation and forced source-branch restrictions are separate operational work. Keep existing release Environments unchanged. The test job uses `desktop-test` without requiring production approval. @@ -45,9 +45,9 @@ Before uploading, the publisher hashes all retained files, checks version and de ## Recovery and records -Re-run the publishing workflow after resolving a failure; do not rebuild solely to retry an upload. Its source package run and attempt remain fixed. Missing objects are uploaded, identical objects are skipped, and conflicting same-name bytes stop the operation. Unknown write outcomes are reconciled by reading remote bytes. A newer channel version prevents a stale upload from replacing it. +Re-run the publishing workflow after resolving a failure; do not rebuild solely to retry an upload. Its source package run and attempt remain fixed. Missing objects are uploaded, identical objects are skipped, and conflicting same-name bytes stop the operation. Unknown write outcomes are reconciled by reading remote bytes. A newer channel version prevents a stale upload from replacing it. Stable releases update both nightly and latest feeds: if nightly already contains a higher prerelease (for example 1.1.0-alpha.1), a 1.0.1 stable hotfix is rejected before any upload. Use a higher corrective version; this workflow does not implicitly downgrade nightly. -The sibling `upload-records/` directory retains an operation identity, newline-delimited events and terminal result. Feed-write intents retain both previous and requested YAML. Writes and readback are recorded before proceeding; missing terminal results mean interruption, not success. Actions also keeps its operation record as a diagnostic artifact for 30 days. These local records are not tamper-proof. COS readback verifies storage bytes, not public CDN behavior; public download acceptance is separate. +The sibling `upload-records/` directory retains an operation identity, newline-delimited events and terminal result. Feed-write intents retain both previous and requested YAML. Failures record their stage, safe error classification and diagnostic; raw provider responses are excluded. Audit initialization becomes visible only after its files are complete; abandoned `.initializing-*` directories are not scanned for completed publication. Writes and readback are recorded before proceeding; missing terminal results mean interruption, not success. Actions also keeps its operation record as a diagnostic artifact for 30 days. These local records are not tamper-proof. COS readback verifies storage bytes, not public CDN behavior; public download acceptance is separate. If the feed completed but tagging failed, the result is `published-tag-pending` and the job fails visibly. On retry, durable feed-completion evidence permits only tag recovery, even if a later release has advanced the channel. It never re-publishes old YAML. An existing conflicting tag requires administrator investigation and is never moved automatically. @@ -59,7 +59,7 @@ Normal publication and manual restoration acquire the same local channel lock. A Run the repository script `apps/desktop/scripts/rollback-desktop-channel.ts` through `node --import tsx` on the publishing machine. Keep `GITHUB_REPOSITORY`, `RUNNER_NAME`, `DSH_PACKAGING_CONFIG_DIR` and `DESKTOP_PUBLISH_ROUTE` set to the original package context. The route JSON contains `runId`, `attempt`, `commit`, `runnerName`, `target` and `preset`, available in the retained manifest's source fields. Pass `--record` with that successful publication's audit directory, `--expected-version` with the version currently online, and `--reason` with the operational reason. -Without `--execute`, the script reads remote objects and prints the exact YAML change without writing. Preview requires COS read credentials. With `--execute`, it verifies historical files, preserves before/after YAML in a new audit record, checks the current channel again, and restores only YAML. It neither uploads binaries nor changes tags. A different current version aborts the operation. Restoration is not a multi-object transaction; inspect the audit after any partial failure before continuing. +Without `--execute`, the script reads remote objects and prints the exact YAML change without writing. Preview requires COS read credentials. With `--execute`, it verifies historical files, preserves before/after YAML in a new audit record, checks the current channel again, and restores only YAML. It neither uploads binaries nor changes tags. A channel already matching the target bytes is skipped; other channels must match the expected current version. This permits recovery after one feed was restored before an interruption. A different current version aborts the operation. Restoration is not a multi-object transaction; inspect the audit after any partial failure before continuing. The Desktop client disables automatic downgrade. Restoring an old feed can stop additional clients receiving a bad release; clients already on a newer version do not downgrade. A higher-version corrective release is a separate recovery path. diff --git a/apps/desktop/publishing/ci/README.zh.md b/apps/desktop/publishing/ci/README.zh.md index c648dde54e..7d801493fa 100644 --- a/apps/desktop/publishing/ci/README.zh.md +++ b/apps/desktop/publishing/ci/README.zh.md @@ -27,7 +27,7 @@ description: "在专用 macOS 和 Windows runner 上发布留存的 Desktop 安 将仓库变量 `DESKTOP_PUBLISH_RUNNERS` 配置为已注册 runner 名称到唯一标签和目标的 JSON 映射,例如 `{"mini":{"label":"dsh-mini-publish","target":"mac-arm64"}}`。该标签只能分配给这台 runner。Windows 使用目标 `win-x64`。路由 job 检查原构建成功的 job,发布器再检查自身 runner 名称与记录一致。机器之间不需要共享文件系统或局域网连接。 -管理员必须创建 `desktop-production`,设置必需审批人并允许 `master` 部署分支。工作流调度 production 前会检查必需审批人已配置。GitHub App 需要安装到本仓库、具有 Contents write 权限,并获准在 `desktop-v*` tag ruleset 下创建发布 tag。审批不会把审批人的 tag 权限传给 App。缺少配置会停止发布,不回退到个人 token。App action 创建短期 token,并在结束后撤销。使用发布账户的文件系统权限保护本机私钥及 COS 凭据文件。 +管理员必须创建 `desktop-production`,设置必需审批人并允许 `master` 部署分支。工作流调度 production 前会检查必需审批人已配置。GitHub App 需要安装到本仓库、具有 Contents write 权限,并获准在 `desktop-v*` tag ruleset 下创建发布 tag。审批不会把审批人的 tag 权限传给 App。缺少配置会停止发布,不回退到个人 token。App action 创建短期 token,并在结束后撤销。使用发布账户的文件系统权限保护本机私钥及 COS 凭据文件。token action 需要密钥内容,因此已屏蔽日志的 PEM 在 job 期间也会经过 runner 临时 `GITHUB_OUTPUT` 文件;日志屏蔽不限制本地文件访问。 Environment 审批限制此工作流,不限制同一机器账户运行的任意代码。账户隔离和强制源分支限制属于独立运维工作。保持现有发布 Environment 不变。test job 使用 `desktop-test`,无需 production 审批。 @@ -45,9 +45,9 @@ Environment 审批限制此工作流,不限制同一机器账户运行的任 ## 恢复与记录 -解决故障后重跑发布工作流,不要仅为重试上传而重新打包。原 package run 和 attempt 保持不变。缺失对象会上传,相同对象跳过,同名但字节不同的对象会停止操作。写入结果不明时通过读取远端字节核对。频道已有更高版本时,旧上传不能替换它。 +解决故障后重跑发布工作流,不要仅为重试上传而重新打包。原 package run 和 attempt 保持不变。缺失对象会上传,相同对象跳过,同名但字节不同的对象会停止操作。写入结果不明时通过读取远端字节核对。频道已有更高版本时,旧上传不能替换它。稳定版同时更新 nightly 与 latest:若 nightly 已有更高预发行版(例如 1.1.0-alpha.1),1.0.1 稳定热修复会在任何上传前被拒绝。应发布更高版本的修正版;此工作流不会隐式降级 nightly。 -同级 `upload-records/` 目录保留操作标识、逐行事件和最终结果。频道写入意图同时记录先前和目标 YAML。写入和回读在继续操作前落盘;没有最终结果代表中断,不代表成功。Actions 还将操作记录保存为诊断 artifact,保留 30 天。这些本地记录不防篡改。COS 回读验证存储字节,不代表公开 CDN 行为;公开下载需单独验收。 +同级 `upload-records/` 目录保留操作标识、逐行事件和最终结果。频道写入意图同时记录先前和目标 YAML。失败记录阶段、安全错误分类与诊断,不保存原始服务响应。审计初始化在文件完整后才可见;遗留的 `.initializing-*` 目录不参与已完成发布的扫描。写入和回读在继续操作前落盘;没有最终结果代表中断,不代表成功。Actions 还将操作记录保存为诊断 artifact,保留 30 天。这些本地记录不防篡改。COS 回读验证存储字节,不代表公开 CDN 行为;公开下载需单独验收。 频道更新完成但 tag 失败时,结果为 `published-tag-pending`,job 明确失败。重试时,已落盘的频道完成证据只允许恢复 tag,即使后来发布已推进频道,也不会重新发布旧 YAML。已有冲突 tag 需要管理员调查,不会自动移动。 @@ -59,7 +59,7 @@ Environment 审批限制此工作流,不限制同一机器账户运行的任 在发布机器上通过 `node --import tsx` 运行仓库脚本 `apps/desktop/scripts/rollback-desktop-channel.ts`。保持 `GITHUB_REPOSITORY`、`RUNNER_NAME`、`DSH_PACKAGING_CONFIG_DIR` 和 `DESKTOP_PUBLISH_ROUTE` 对应原打包上下文。route JSON 包含 `runId`、`attempt`、`commit`、`runnerName`、`target` 和 `preset`,可从留存 manifest 的 source 字段取得。`--record` 指定该次成功发布的审计目录,`--expected-version` 指定当前线上版本,`--reason` 填写操作原因。 -不带 `--execute` 时,脚本读取远端对象并打印确切 YAML 变更,不执行写入。预览需要 COS 读取凭据。带 `--execute` 时,脚本校验历史文件,将前后 YAML 保存在新的审计记录中,再次检查当前频道,然后仅恢复 YAML。它不上传二进制文件,也不改变 tag。当前版本不同会中止操作。恢复不是多对象事务;部分失败后需先检查审计记录再继续。 +不带 `--execute` 时,脚本读取远端对象并打印确切 YAML 变更,不执行写入。预览需要 COS 读取凭据。带 `--execute` 时,脚本校验历史文件,将前后 YAML 保存在新的审计记录中,再次检查当前频道,然后仅恢复 YAML。它不上传二进制文件,也不改变 tag。已经与目标字节完全相同的频道会跳过,其余频道必须匹配预期当前版本,因此可以恢复写完一个 feed 后中断的操作。其他当前版本会中止操作。恢复不是多对象事务;部分失败后需先检查审计记录再继续。 Desktop 客户端禁用自动降级。恢复旧 feed 可以阻止更多客户端获得问题版本,已经安装更高版本的客户端不会降级。发布更高版本号的修正版是独立恢复方式。 diff --git a/apps/desktop/scripts/desktop-publication-failure.ts b/apps/desktop/scripts/desktop-publication-failure.ts new file mode 100644 index 0000000000..8f3e6beb55 --- /dev/null +++ b/apps/desktop/scripts/desktop-publication-failure.ts @@ -0,0 +1,30 @@ +/** Safe failure details shared by publication diagnostics and durable journals. */ +import { ZodError } from 'zod' +import { DesktopPublishError } from './desktop-publish.ts' + +const publicCodes = new Set(['AccessDenied', 'InternalError', 'NoSuchBucket', 'BadDigest', 'SignatureDoesNotMatch', + 'RequestTimeout', 'TimeoutError', 'AbortError', 'ECONNRESET', 'ETIMEDOUT', 'ENOTFOUND', 'ENOSPC', 'EACCES', 'EPERM', 'ENOENT']) + +/** + * Keep actionable local validation details without recording SDK response bodies or credentials. + * @param error Failure from validation, filesystem, subprocess or remote transport. + * @param phase Operation stage known by the entrypoint. + * @returns Credential-free diagnostic suitable for stderr and an audit event. + */ +export function desktopPublicationFailure(error: unknown, phase: string): { phase: string; code: string; message: string } { + if (error instanceof DesktopPublishError) return { phase, code: error.code, message: error.message } + if (error instanceof ZodError) return { phase, code: 'invalid-data', + message: `Invalid fields: ${error.issues.map(issue => `${issue.path.join('.') || '(root)'} (${issue.code})`).join(', ')}` } + if (error instanceof SyntaxError) return { phase, code: 'invalid-json', message: 'Invalid JSON; check the configuration or retained record for this stage.' } + const ownedMessage = /^(desktop (?:publish|release store|retention|rollback|tag|upload|auto-update|macOS signing|build)[^\n]*:|Usage:)/u + if (error instanceof Error && ownedMessage.test(error.message)) { + return { phase, code: 'validation', message: error.message } + } + const record = typeof error === 'object' && error !== null ? error : {} + const candidate = 'code' in record ? record.code : 'name' in record ? record.name : undefined + const code = typeof candidate === 'string' && publicCodes.has(candidate) ? candidate : 'operation-failed' + const status = 'statusCode' in record && typeof record.statusCode === 'number' ? ` (HTTP ${record.statusCode})` : '' + const path = ['ENOENT', 'EACCES', 'EPERM', 'ENOSPC'].includes(code) && 'path' in record && typeof record.path === 'string' + ? `: ${record.path}` : '' + return { phase, code, message: `${code}${status}${path}. Check this stage's local files, signature verification or service permissions; raw provider output is omitted.` } +} diff --git a/apps/desktop/scripts/desktop-publish-record.ts b/apps/desktop/scripts/desktop-publish-record.ts index d3094b80bd..77ac3ca698 100644 --- a/apps/desktop/scripts/desktop-publish-record.ts +++ b/apps/desktop/scripts/desktop-publish-record.ts @@ -1,7 +1,7 @@ /** Machine-local channel exclusion and durable, credential-free publication records. */ import { createHash } from 'node:crypto' -import { appendFile, mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises' -import { join } from 'node:path' +import { appendFile, mkdir, mkdtemp, readdir, readFile, rename, rm, writeFile } from 'node:fs/promises' +import { basename, join } from 'node:path' import { z } from 'zod' import type { DesktopPublishEvent } from './desktop-publish.ts' @@ -43,11 +43,18 @@ export async function createDesktopPublicationRecord( identity: { repository: string; runId: string; attempt: string; actor: string; destination: string; operation: 'publish' | 'rollback'; reason: string }, ): Promise { await mkdir(root, { recursive: true, mode: 0o700 }) - const directory = await mkdtemp(join(root, `${identity.operation}-`)) + const staging = await mkdtemp(join(root, '.initializing-')) + const directory = join(root, `${identity.operation}-${basename(staging).slice('.initializing-'.length)}`) const startedAt = new Date().toISOString() - await writeFile(join(directory, 'operation.json'), `${JSON.stringify({ ...identity, - startedAt }, null, 2)}\n`, { flag: 'wx', mode: 0o600, flush: true }) - await writeFile(join(directory, 'events.jsonl'), '', { flag: 'wx', mode: 0o600 }) + try { + await writeFile(join(staging, 'operation.json'), `${JSON.stringify({ ...identity, + startedAt }, null, 2)}\n`, { flag: 'wx', mode: 0o600, flush: true }) + await writeFile(join(staging, 'events.jsonl'), '', { flag: 'wx', mode: 0o600, flush: true }) + await rename(staging, directory) + } catch (error) { + await rm(staging, { recursive: true, force: true }) + throw error + } return { directory, async record(event) { diff --git a/apps/desktop/scripts/desktop-publish-route.ts b/apps/desktop/scripts/desktop-publish-route.ts index 03d4edbbea..b469ca53d9 100644 --- a/apps/desktop/scripts/desktop-publish-route.ts +++ b/apps/desktop/scripts/desktop-publish-route.ts @@ -12,7 +12,7 @@ const requestSchema = z.object({ source: z.object({ runnerName: z.string(), target: z.enum(['mac-arm64', 'win-x64']), preset: z.enum(['test', 'production']), publishRequested: z.literal(true), qualification: z.enum(['signed', 'notarized']), }) }) -const jobsSchema = z.array(z.object({ runner_name: z.string().nullable(), conclusion: z.string().nullable(), labels: z.array(z.string()) })) +const jobsSchema = z.array(z.object({ runner_name: z.string().nullable(), conclusion: z.string().nullable() })) const routesSchema = z.record(z.string(), z.object({ label: z.string().regex(/^dsh-[a-z0-9-]+$/u), target: z.enum(['mac-arm64', 'win-x64']) })) /** Validated values handed to the privileged publisher; no caller-selected shell fragments. */ diff --git a/apps/desktop/scripts/desktop-publish-tag.ts b/apps/desktop/scripts/desktop-publish-tag.ts index 077a4aaa8c..f47cc698a6 100644 --- a/apps/desktop/scripts/desktop-publish-tag.ts +++ b/apps/desktop/scripts/desktop-publish-tag.ts @@ -3,7 +3,7 @@ import { valid } from 'semver' import { z } from 'zod' import { desktopReleaseTag } from './desktop-release-tag.ts' -const refSchema = z.object({ object: z.object({ type: z.literal('commit'), sha: z.string().regex(/^[a-f0-9]{40}$/u) }) }) +const refSchema = z.object({ object: z.object({ type: z.string(), sha: z.string().regex(/^[a-f0-9]{40}$/u) }) }) /** * Create a lightweight release tag, or verify that an existing tag names the same packaged commit. @@ -29,6 +29,7 @@ export async function publishDesktopTag( const matches = async (response: Response) => { if (!response.ok) throw new Error(`desktop tag: cannot verify tag (HTTP ${response.status})`) const ref = refSchema.parse(await response.json()) + if (ref.object.type !== 'commit') throw new Error('desktop tag: existing ref is not a lightweight commit tag; inspect it manually, refusing to replace it') if (ref.object.sha !== commit) throw new Error('desktop tag: existing tag points to another commit; refusing to move it') } const existing = await get() diff --git a/apps/desktop/scripts/desktop-publish.ts b/apps/desktop/scripts/desktop-publish.ts index a1c81bbe7a..1270f7229e 100644 --- a/apps/desktop/scripts/desktop-publish.ts +++ b/apps/desktop/scripts/desktop-publish.ts @@ -19,7 +19,8 @@ export interface DesktopPublishTransport { } /** Credential-free, durably flushed publication event. */ export interface DesktopPublishEvent { - readonly type: 'object-present' | 'put-intent' | 'put-verified' | 'feeds-complete' | 'tag-pending' | 'complete' + readonly type: 'object-present' | 'put-intent' | 'put-verified' | 'feeds-complete' | 'tag-pending' | 'complete' | 'failed' + readonly failure?: { readonly phase: string; readonly code: string; readonly message: string } readonly key?: string readonly before?: string | null readonly after?: string @@ -59,10 +60,13 @@ function same(left: DesktopObjectDigest | null, right: DesktopObjectDigest | nul return left === null || right === null ? left === right : left.size === right.size && left.sha512 === right.sha512 } -function version(contents: string): string { - const parsed = z.object({ version: z.string() }).parse(load(contents)) - if (valid(parsed.version) === null) throw new DesktopPublishError('invalid-plan', 'desktop publish: invalid channel version') - return parsed.version +function version(contents: string, remote = false): string { + try { + const parsed = z.object({ version: z.string() }).parse(load(contents)) + if (valid(parsed.version) !== null) return parsed.version + } catch (error) { /* Discard parser error details, which may include raw remote YAML. */ } + throw new DesktopPublishError(remote ? 'verification-failed' : 'invalid-plan', + `desktop publish: invalid ${remote ? 'remote' : 'planned'} channel version`) } /** @@ -91,8 +95,8 @@ export async function publishDesktopRelease(plan: DesktopUploadPlan, operations: const remote = await operations.transport.read(feed.key, true) if (remote !== null) { if (remote.contents === undefined) throw new DesktopPublishError('verification-failed', 'desktop publish: channel bytes were not returned') - if (gt(version(remote.contents), plan.version)) throw new DesktopPublishError('superseded', 'desktop publish: a newer version already owns this channel') - if (version(remote.contents) === plan.version && !same(remote, await desktopArtifactDigest(feed))) { + if (gt(version(remote.contents, true), plan.version)) throw new DesktopPublishError('superseded', 'desktop publish: a newer version already owns this channel') + if (version(remote.contents, true) === plan.version && !same(remote, await desktopArtifactDigest(feed))) { throw new DesktopPublishError('conflict', 'desktop publish: same version has different channel metadata') } } diff --git a/apps/desktop/scripts/desktop-release-store.ts b/apps/desktop/scripts/desktop-release-store.ts index 465e1af0e9..379ac56bb5 100644 --- a/apps/desktop/scripts/desktop-release-store.ts +++ b/apps/desktop/scripts/desktop-release-store.ts @@ -13,6 +13,7 @@ const sourceSchema = z.strictObject({ attempt: positiveId, commit: z.string().regex(/^[a-f0-9]{40}$/u), runnerName: z.string().min(1), + // Storage accepts every Desktop target; CI routing separately limits currently provisioned platforms. target: z.enum(['mac-arm64', 'mac-x64', 'win-x64']), preset: z.enum(['test', 'production']), productVersion: z.string().min(1), diff --git a/apps/desktop/scripts/desktop-rollback.ts b/apps/desktop/scripts/desktop-rollback.ts index 4f1e4957c2..a96e5d652b 100644 --- a/apps/desktop/scripts/desktop-rollback.ts +++ b/apps/desktop/scripts/desktop-rollback.ts @@ -35,6 +35,10 @@ export async function rollbackDesktopChannel( for (const feed of feeds) { const before = await operations.transport.read(feed.key, true) if (!before || before.contents === undefined || feed.contents === undefined) throw new Error('desktop rollback: channel YAML is unavailable') + if (equal(before, await desktopArtifactDigest(feed))) { + await operations.record({ type: 'object-present', key: feed.key }) + continue + } const current = z.object({ version: z.string() }).parse(load(before.contents)).version if (current !== expectedVersion) throw new Error('desktop rollback: current version differs from the expected version') changes.push({ key: feed.key, before: before.contents, after: feed.contents, observed: before, artifact: feed }) diff --git a/apps/desktop/scripts/publish-desktop-ci.ts b/apps/desktop/scripts/publish-desktop-ci.ts index 041ba9d484..eff6317157 100644 --- a/apps/desktop/scripts/publish-desktop-ci.ts +++ b/apps/desktop/scripts/publish-desktop-ci.ts @@ -9,7 +9,8 @@ import { verifyRetainedDesktopSignatures } from './verify-retained-desktop.ts' import { createDesktopUploadPlan } from './desktop-upload-plan.ts' import { desktopPublishCos } from './desktop-publish-cos.ts' import { createDesktopPublicationRecord, wasDesktopAttemptPublished, withDesktopChannelLock } from './desktop-publish-record.ts' -import { DesktopPublishError, publishDesktopRelease } from './desktop-publish.ts' +import { publishDesktopRelease } from './desktop-publish.ts' +import { desktopPublicationFailure } from './desktop-publication-failure.ts' import { publishDesktopTag } from './desktop-publish-tag.ts' const routeSchema = z.object({ runId: z.string(), attempt: z.string(), commit: z.string(), runnerName: z.string(), @@ -86,6 +87,7 @@ if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.fi return { status: 'published-tag-pending' as const, cause } } } + await journal.record({ type: 'complete' }) return { status: 'complete' as const } })() : await publishDesktopRelease(plan, { @@ -95,13 +97,20 @@ if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.fi status = result.status if (env.GITHUB_STEP_SUMMARY) await appendFile(env.GITHUB_STEP_SUMMARY, `\nDesktop publication: **${status}**\n\n${plan.target} ${plan.version}\n\nSource: ${route.runId}, attempt ${route.attempt}\n`) - if (result.status === 'published-tag-pending') process.exitCode = 1 + if (result.status === 'published-tag-pending') { + const failure = desktopPublicationFailure(result.cause, 'release tag creation') + await journal.record({ type: 'failed', failure }) + process.stderr.write(`${JSON.stringify(failure)}\n`) + process.exitCode = 1 + } + } catch (error) { + await journal.record({ type: 'failed', failure: desktopPublicationFailure(error, phase) }) + throw error } finally { await journal.finish(status) } }) } } catch (error) { - const code = error instanceof DesktopPublishError ? ` (${error.code})` : '' - process.stderr.write(`Desktop publication stopped during ${phase}${code}. Inspect the retained operation record and configuration before retrying; remote effects may be incomplete.\n`) + process.stderr.write(`${JSON.stringify(desktopPublicationFailure(error, phase))}\n`) process.exitCode = 1 } } diff --git a/apps/desktop/scripts/rollback-desktop-channel.ts b/apps/desktop/scripts/rollback-desktop-channel.ts index add593e8ee..b8081da077 100644 --- a/apps/desktop/scripts/rollback-desktop-channel.ts +++ b/apps/desktop/scripts/rollback-desktop-channel.ts @@ -8,8 +8,10 @@ import { readDesktopUploadCredentials } from './desktop-publish-config.ts' import { desktopPublishCos } from './desktop-publish-cos.ts' import { createDesktopPublicationRecord, withDesktopChannelLock } from './desktop-publish-record.ts' import { rollbackDesktopChannel } from './desktop-rollback.ts' +import { desktopPublicationFailure } from './desktop-publication-failure.ts' if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.filename) { + let phase = 'rollback arguments and local publication verification' try { const { values } = parseArgs({ options: { record: { type: 'string' }, 'expected-version': { type: 'string' }, @@ -31,6 +33,7 @@ if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.fi throw new Error('desktop rollback: successful publication record does not match the selected package') } const credentials = await readDesktopUploadCredentials(config) + phase = 'rollback channel lock and audit creation' await withDesktopChannelLock(join(dirname(root), 'publish-locks'), destination, async () => { const journal = await createDesktopPublicationRecord(join(dirname(root), 'upload-records'), { ...operation, actor: process.env.USER || process.env.USERNAME || 'unknown', operation: 'rollback', reason: values.reason!, @@ -38,16 +41,20 @@ if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.fi process.stdout.write(`desktop rollback: audit ${journal.directory}\n`) let complete = false try { + phase = 'historical object verification and channel restoration' const outcome = await rollbackDesktopChannel(plan, values['expected-version']!, values.execute!, { transport: desktopPublishCos(plan, credentials), record: event => journal.record(event), tag: async () => { throw new Error('desktop rollback never changes tags') }, }) process.stdout.write(`${JSON.stringify(outcome, null, 2)}\n`) complete = true + } catch (error) { + await journal.record({ type: 'failed', failure: desktopPublicationFailure(error, phase) }) + throw error } finally { await journal.finish(complete ? (values.execute ? 'complete' : 'preview') : 'failed') } }) - } catch { - process.stderr.write('Desktop publication stopped. Inspect the retained operation record and configuration before retrying; remote effects may be incomplete.\n') + } catch (error) { + process.stderr.write(`${JSON.stringify(desktopPublicationFailure(error, phase))}\n`) process.exitCode = 1 } } diff --git a/apps/desktop/tests/desktop-publication-failure.spec.ts b/apps/desktop/tests/desktop-publication-failure.spec.ts new file mode 100644 index 0000000000..f19e1f76b5 --- /dev/null +++ b/apps/desktop/tests/desktop-publication-failure.spec.ts @@ -0,0 +1,36 @@ +import { spawnSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' +import { expect, it } from 'vitest' +import { z } from 'zod' +import { desktopPublicationFailure } from '../scripts/desktop-publication-failure.ts' +import { DesktopPublishError } from '../scripts/desktop-publish.ts' + +it('preserves actionable validation and filesystem diagnostics', () => { + expect(desktopPublicationFailure(new DesktopPublishError('conflict', 'desktop publish: object differs: bin/app.exe'), 'upload')) + .toMatchObject({ code: 'conflict', message: 'desktop publish: object differs: bin/app.exe' }) + expect(desktopPublicationFailure(new Error('Usage: rollback-desktop-channel.ts --record '), 'arguments').message).toContain('Usage:') + expect(desktopPublicationFailure(Object.assign(new Error('raw'), { code: 'ENOENT', path: '/fixture/config.json' }), 'config').message) + .toContain('/fixture/config.json') +}) + +it('does not retain provider bodies, unknown messages, JSON input or invalid field contents', () => { + const result = z.object({ preset: z.enum(['test', 'production']) }).safeParse({ preset: 'fixture-secret' }) + if (result.success) throw new Error('fixture must reject') + for (const error of [result.error, new SyntaxError('fixture-secret'), new Error('fixture-secret'), + { code: 'AccessDenied', statusCode: 403, body: 'fixture-secret', message: 'fixture-secret' }]) { + expect(JSON.stringify(desktopPublicationFailure(error, 'fixture'))).not.toContain('fixture-secret') + } + expect(desktopPublicationFailure(result.error, 'config').message).toContain('preset') + expect(desktopPublicationFailure({ code: 'AccessDenied', statusCode: 403 }, 'upload').message).toContain('HTTP 403') +}) + +it('prints rollback usage through the real CLI before any audit or remote operation', () => { + const result = spawnSync(process.execPath, ['--import', 'tsx/esm', + fileURLToPath(new URL('../scripts/rollback-desktop-channel.ts', import.meta.url))], { encoding: 'utf8', timeout: 30_000 }) + expect(result.error).toBeUndefined() + expect(result.signal).toBeNull() + expect(result.status).toBe(1) + const diagnostic = z.object({ code: z.string(), message: z.string() }).parse(JSON.parse(result.stderr)) + expect(diagnostic.code).toBe('validation') + expect(diagnostic.message).toContain('Usage: rollback-desktop-channel.ts') +}) diff --git a/apps/desktop/tests/desktop-publish-cos.spec.ts b/apps/desktop/tests/desktop-publish-cos.spec.ts index 1d6fd1798a..848e37f71c 100644 --- a/apps/desktop/tests/desktop-publish-cos.spec.ts +++ b/apps/desktop/tests/desktop-publish-cos.spec.ts @@ -38,3 +38,19 @@ it('uses overwrite prevention, streams verified bytes, and hashes remote readbac expect(server.requests.filter(request => request.method === 'PUT')).toHaveLength(1) } finally { await server.close() } }) + +it('propagates an oversized channel stream error through the SDK and closes the request', async () => { + const server = await startCosLoopback((_request, response) => { answer(response, 200, Buffer.alloc(1024 * 1024 + 1, 65)) }) + try { + const artifact = { path: '', filename: 'nightly.yml', key: 'feed/nightly.yml', contents: 'version: 1.0.0\n', contentType: 'application/yaml', channelMetadata: true } + const plan: DesktopUploadPlan = { environment: 'test', target: 'win-x64', version: '1.0.0', bucket: 'fixture-1250000000', + publicUrl: 'https://example.com/', secretIdEnvName: 'ID', secretKeyEnvName: 'KEY', artifacts: [artifact] } + const transport = desktopPublishCos(plan, { secretId: 'fixture-id', secretKey: 'fixture-secret' }, (credentials) => { + const client = createDesktopCos(credentials) + server.redirect(client) + return client + }) + await expect(transport.read(artifact.key, true)).rejects.toThrow(/exceeds 1 MiB/u) + expect(server.requests).toHaveLength(1) + } finally { await server.close() } +}) diff --git a/apps/desktop/tests/desktop-publish-record.spec.ts b/apps/desktop/tests/desktop-publish-record.spec.ts index b0e73923df..87646edbe1 100644 --- a/apps/desktop/tests/desktop-publish-record.spec.ts +++ b/apps/desktop/tests/desktop-publish-record.spec.ts @@ -1,4 +1,4 @@ -import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' import { execFile } from 'node:child_process' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -65,3 +65,16 @@ it('recognizes feed completion even when the later tag failed', async () => { expect(await wasDesktopAttemptPublished(root, { ...identity, attempt: '1' })).toBe(false) } finally { await rm(root, { recursive: true, force: true }) } }) + +it('ignores interrupted initialization before an audit directory becomes visible', async () => { + const root = await mkdtemp(join(tmpdir(), 'desktop-init-')) + try { + await mkdir(join(root, '.initializing-interrupted')) + await writeFile(join(root, '.initializing-interrupted/operation.json'), '{') + const identity = { repository: 'owner/repo', runId: '1', attempt: '1', destination: 'bucket/mac' } + expect(await wasDesktopAttemptPublished(root, identity)).toBe(false) + const record = await createDesktopPublicationRecord(root, { ...identity, actor: 'fixture', operation: 'publish', reason: 'fixture' }) + await record.record({ type: 'feeds-complete' }) + expect(await wasDesktopAttemptPublished(root, identity)).toBe(true) + } finally { await rm(root, { recursive: true, force: true }) } +}) diff --git a/apps/desktop/tests/desktop-publish-tag.spec.ts b/apps/desktop/tests/desktop-publish-tag.spec.ts index 868befe015..7a13daba54 100644 --- a/apps/desktop/tests/desktop-publish-tag.spec.ts +++ b/apps/desktop/tests/desktop-publish-tag.spec.ts @@ -51,3 +51,9 @@ it('rejects missing credentials and malformed inputs before any request', async await expect(publishDesktopTag('owner/repo/other', '1.2.3', commit, 'fixture', http.request)).rejects.toThrow(/invalid/u) expect(http.calls).toHaveLength(0) }) + +it('explains an existing annotated tag without replacing it', async () => { + const http = transport([{ status: 200, body: { object: { type: 'tag', sha: commit } } }]) + await expect(publishDesktopTag('owner/repo', '1.2.3', commit, 'fixture-token', http.request)).rejects.toThrow(/not a lightweight commit tag/u) + expect(http.calls).toHaveLength(1) +}) diff --git a/apps/desktop/tests/desktop-publish.spec.ts b/apps/desktop/tests/desktop-publish.spec.ts index e3067a0838..bc5cba2166 100644 --- a/apps/desktop/tests/desktop-publish.spec.ts +++ b/apps/desktop/tests/desktop-publish.spec.ts @@ -105,3 +105,10 @@ it('does not update the channel when readback cannot confirm a binary', async () await expect(publishDesktopRelease(f.plan, f.operations)).rejects.toMatchObject({ code: 'verification-failed' }) expect(f.events.some(event => event.type === 'feeds-complete')).toBe(false) }) + +it('classifies corrupted remote channel metadata separately from an invalid local plan', async () => { + const f = fixture() + f.objects.set('feed/nightly.yml', observed('version: invalid\n')) + await expect(publishDesktopRelease(f.plan, f.operations)).rejects.toMatchObject({ code: 'verification-failed' }) + expect(f.writes).toEqual([]) +}) diff --git a/apps/desktop/tests/desktop-rollback.spec.ts b/apps/desktop/tests/desktop-rollback.spec.ts index e279959e7f..2bf2151626 100644 --- a/apps/desktop/tests/desktop-rollback.spec.ts +++ b/apps/desktop/tests/desktop-rollback.spec.ts @@ -54,3 +54,26 @@ it.each(['missing-file', 'changed-file', 'changed-channel'] as const)('rejects % await expect(rollbackDesktopChannel(f.plan, '2.0.0', true, f.operations)).rejects.toThrow() expect(f.writes).toEqual([]) }) + +it('restores nightly when latest already matches and resumes a partially restored pair', async () => { + for (const latestAlreadyMatches of [true, false]) { + const f = fixture() + const nightly = f.plan.artifacts[1]! + const plan = { ...f.plan, artifacts: [...f.plan.artifacts, { ...nightly, key: 'feed/latest.yml', filename: 'latest.yml' }] } + f.objects.set('feed/latest.yml', latestAlreadyMatches ? nightly.contents! : 'version: 2.0.0\n') + if (!latestAlreadyMatches) { + const put = f.operations.transport.put.bind(f.operations.transport) + const interrupted = { ...f.operations, transport: { ...f.operations.transport, + async put(...args: Parameters) { + if (args[0].key === 'feed/latest.yml') throw new Error('fixture interruption') + return put(...args) + }, + } } + await expect(rollbackDesktopChannel(plan, '2.0.0', true, interrupted)).rejects.toThrow('fixture interruption') + } + await expect(rollbackDesktopChannel(plan, '2.0.0', true, f.operations)).resolves.toMatchObject({ status: 'complete' }) + expect(f.objects.get('feed/nightly.yml')).toBe(nightly.contents) + expect(f.objects.get('feed/latest.yml')).toBe(nightly.contents) + expect(f.writes.filter(key => key === 'feed/nightly.yml')).toHaveLength(1) + } +}) diff --git a/scripts/macos-package-workflow.spec.ts b/scripts/macos-package-workflow.spec.ts index 30891dbddb..bf7bec10c8 100644 --- a/scripts/macos-package-workflow.spec.ts +++ b/scripts/macos-package-workflow.spec.ts @@ -15,6 +15,8 @@ const workflow = yaml.load(readFileSync(resolve(import.meta.dirname, '../.github it('confines signed Mac packaging to manual dispatch and a dedicated Apple Silicon runner', () => { expect(Object.keys(workflow.on)).toEqual(['workflow_dispatch']) + expect(workflow.on.workflow_dispatch).toMatchObject({ inputs: { publish: { type: 'boolean', default: false }, + test_release_id: { type: 'string', default: '', required: false } } }) expect(workflow.permissions).toEqual({ contents: 'read' }) expect(workflow.concurrency).toEqual({ group: 'macos-desktop-package', 'cancel-in-progress': false }) expect(workflow.jobs.package!['runs-on']).toEqual(['self-hosted', 'macOS', 'ARM64', 'dsh-mac-package-trial']) From 60328899e5d6526d51e495bef8701f0a9d94f908 Mon Sep 17 00:00:00 2001 From: "dsh-portal[bot]" <341800657+dsh-portal[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:26:57 +0800 Subject: [PATCH 08/35] fix(web): pin a plugin enabled on the More page to the main plugins list Switching on an experimental bundle from the More page left it only on that page, so a plugin the profile had enabled was missing from the main Experimental plugins group. Both lists now read one order over the offered bundles: the main list holds the always-held ones plus the held-back ones the profile enabled, and a pinned bundle keeps its More-list position. A bundle's details opened outside the lists return to the list that shows it. --- .../client/ui-plugin-manager/README.i18n.yaml | 4 +- packages/client/ui-plugin-manager/README.md | 2 +- .../client/ui-plugin-manager/README.zh.md | 2 +- .../src/client/PluginManagerPage.tsx | 49 ++++++++++++--- .../tests/components.client.spec.tsx | 63 +++++++++++++------ 5 files changed, 87 insertions(+), 33 deletions(-) diff --git a/packages/client/ui-plugin-manager/README.i18n.yaml b/packages/client/ui-plugin-manager/README.i18n.yaml index ad44971a01..a69be350ba 100644 --- a/packages/client/ui-plugin-manager/README.i18n.yaml +++ b/packages/client/ui-plugin-manager/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 09388d293f9be9cb /deepseek-ai-dsh-client-ui-plugin-manager/use-this-package: - en: 005ae2cf873c0a2e - zh: bc7ca9472105a6a0 + en: c0ec2417ce428285 + zh: 257122d7920d49c5 /deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/installing-a-bundle: en: fcb2e69c8b028c0a zh: d505a075679974eb diff --git a/packages/client/ui-plugin-manager/README.md b/packages/client/ui-plugin-manager/README.md index 9f2236ff7b..c953bde19d 100644 --- a/packages/client/ui-plugin-manager/README.md +++ b/packages/client/ui-plugin-manager/README.md @@ -27,7 +27,7 @@ Use the **Plugins** entry in the Web sidebar to manage the profile's installed b ## Use this package -Select **Plugins** in the sidebar. The information button uses an 11px caption-colored icon in a 20px square hover area, with a 4px gap after the subtitle. It explains that this page configures official plugins and installs or manages other plugins, while **Settings → Built-in plugins** shows the built-in inventory and runtime status. Hover or focus shows the shared tooltip; clicking keeps it open until another click, Escape, Tab, or an outside click. The page reads the inventory and the bundles through `api-remotes` when first opened; a Host without a managed profile shows the page as unavailable. **Built-in plugin settings** lists official configuration pages by their configured order: Subagent, web search, agent loop, then shell. **Experimental plugins** shows the primary optional bundles, with **More** beside its heading opening a separate page of the same title. That page lists all offered official bundles, including those on the main list, with Codex and Claude Code subagents last. Its back button returns to the main list, where **Installed plugins** remains below Experimental plugins. Bundle details return to the list they were opened from. Catalog entries keep their position after installation or removal. **Installed plugins** lists other profile bundles. Installation-carried optional bundles have no uninstall action. Experimental packages retain the **Experimental** marker on both lists. Switching a bundle on or off does not change its group or position. A dependency without a bundle patch is not a plugin and is not listed unless the profile selects it, in which case it carries a problem tag. Global configuration remains in the Settings **Built-in plugins** section. +Select **Plugins** in the sidebar. The information button uses an 11px caption-colored icon in a 20px square hover area, with a 4px gap after the subtitle. It explains that this page configures official plugins and installs or manages other plugins, while **Settings → Built-in plugins** shows the built-in inventory and runtime status. Hover or focus shows the shared tooltip; clicking keeps it open until another click, Escape, Tab, or an outside click. The page reads the inventory and the bundles through `api-remotes` when first opened; a Host without a managed profile shows the page as unavailable. **Built-in plugin settings** lists official configuration pages by their configured order: Subagent, web search, agent loop, then shell. **Experimental plugins** shows the primary optional bundles, with **More** beside its heading opening a separate page of the same title. That page lists all offered official bundles, including those on the main list, with Codex and Claude Code subagents last. Its back button returns to the main list, where **Installed plugins** remains below Experimental plugins. Bundle details return to the list they were opened from. Catalog entries keep their position after installation or removal. **Installed plugins** lists other profile bundles. Installation-carried optional bundles have no uninstall action. Experimental packages retain the **Experimental** marker on both lists. Switching on a bundle that page holds back adds it to the main list in the position that page shows it, and switching it off leaves it on that page alone; other bundles keep their place on both. A dependency without a bundle patch is not a plugin and is not listed unless the profile selects it, in which case it carries a problem tag. Global configuration remains in the Settings **Built-in plugins** section. The heading moves its complete action group to another line when space is insufficient. Action labels stay on one line, and the information button follows the subtitle text. diff --git a/packages/client/ui-plugin-manager/README.zh.md b/packages/client/ui-plugin-manager/README.zh.md index a2d96ba2f2..79748156ed 100644 --- a/packages/client/ui-plugin-manager/README.zh.md +++ b/packages/client/ui-plugin-manager/README.zh.md @@ -27,7 +27,7 @@ kind: "package-reference" ## 使用本包 -在侧栏选择**插件**。信息按钮使用 11px 的 caption 色图标和 20px 方形悬停区域,与副标题间隔 4px。它说明当前页用于配置官方插件、安装和管理其他插件,**设置 → 内置插件**则展示内置插件列表及运行状态。悬停或聚焦显示共享 tooltip;点击后保持展开,直至再次点击、Escape、Tab 或外部点击。页面首次打开时通过 `api-remotes` 读取清单与组合包;没有受管 profile 的 Host 上页面显示为不可用。**内置插件配置**按配置的顺序列出官方配置页:子智能体、网页搜索、Agent 循环、终端。**实验性插件**展示主要的可选组合包,标题旁的**更多**打开同名的独立页面。该页列出全部官方组合包,也包含主列表已有的条目,Codex 和 Claude Code 子智能体排在最后。返回按钮回到主列表,**已安装插件**保持在实验性插件下方。组合包详情返回打开它的列表。目录条目在安装或移除后保持原位置。**已安装插件**列出其他 profile 组合包。安装随附的可选组合包没有卸载操作。实验性包在两个列表中均保留**实验性**标签。启停组合包不会改变它的分组或位置。没有组合包 patch 的依赖不是插件,除非 profile 选中了它才会带异常标签列出。全局配置仍在设置的**内置插件**分区中编辑。 +在侧栏选择**插件**。信息按钮使用 11px 的 caption 色图标和 20px 方形悬停区域,与副标题间隔 4px。它说明当前页用于配置官方插件、安装和管理其他插件,**设置 → 内置插件**则展示内置插件列表及运行状态。悬停或聚焦显示共享 tooltip;点击后保持展开,直至再次点击、Escape、Tab 或外部点击。页面首次打开时通过 `api-remotes` 读取清单与组合包;没有受管 profile 的 Host 上页面显示为不可用。**内置插件配置**按配置的顺序列出官方配置页:子智能体、网页搜索、Agent 循环、终端。**实验性插件**展示主要的可选组合包,标题旁的**更多**打开同名的独立页面。该页列出全部官方组合包,也包含主列表已有的条目,Codex 和 Claude Code 子智能体排在最后。返回按钮回到主列表,**已安装插件**保持在实验性插件下方。组合包详情返回打开它的列表。目录条目在安装或移除后保持原位置。**已安装插件**列出其他 profile 组合包。安装随附的可选组合包没有卸载操作。实验性包在两个列表中均保留**实验性**标签。开启仅在该页列出的组合包会按该页中的位置把它加入主列表,关闭后它只留在该页;其他组合包在两个列表中均保持原位。没有组合包 patch 的依赖不是插件,除非 profile 选中了它才会带异常标签列出。全局配置仍在设置的**内置插件**分区中编辑。 空间不足时,页头将整个操作组移到下一行。操作文案保持单行,信息按钮紧随副标题文字。 diff --git a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx index 12e877ece3..26fab6011b 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx @@ -103,13 +103,14 @@ const BUILTIN_PROFILE_BUNDLES = new Set([ '@deepseek-ai/dsh-sdk-minimal', ]) -/** Display order on the main and full optional plugin lists; new bundles join the main list. */ +/** The optional bundles the main list always holds, in this order; new bundles join the main list. */ const EXTENSION_BUNDLES = [ '@deepseek-ai/dsh-experimental-agent-team-profile', '@deepseek-ai/dsh-experimental-voice-input-bundle', '@deepseek-ai/dsh-experimental-auto-review', '@deepseek-ai/dsh-experimental-inspector-profile', ] +/** The optional bundles the main list holds back until the profile picks them; the More list shows them from the start. */ const MORE_BUNDLES = [ '@deepseek-ai/dsh-experimental-tool-worktree', '@deepseek-ai/dsh-experimental-cot-translation-bundle', @@ -121,13 +122,36 @@ const MORE_BUNDLES = [ '@deepseek-ai/dsh-subagent-codex', '@deepseek-ai/dsh-subagent-claude-code', ] +/** + * Whether the main list shows an official optional bundle: the always-held ones, and any held-back one the profile enabled. + * @param pkg - the official bundle as the Host reports it. + * @returns whether the main list shows it. + */ +function pinnedToMainList(pkg: PackageView): boolean { + return !MORE_BUNDLES.includes(pkg.name) || pkg.enabled +} -function orderPackages(packages: readonly PackageView[], order: readonly string[]): PackageView[] { - const rank = (name: string): number => { - const index = order.indexOf(name) - return index < 0 ? order.length : index - } - return [...packages].sort((a, b) => rank(a.name) - rank(b.name)) +/** + * Where an optional bundle sits on both lists: the always-held ones in their order, then the bundles neither list + * ranks, then the held-back ones in theirs, so Codex and Claude Code stay last on the More list. + * @param name - the bundle's package name. + * @returns the rank; bundles of equal rank keep the order the Host reported. + */ +function optionalBundleRank(name: string): number { + const alwaysHeld = EXTENSION_BUNDLES.indexOf(name) + if (alwaysHeld >= 0) return alwaysHeld + const heldBack = MORE_BUNDLES.indexOf(name) + return heldBack < 0 ? EXTENSION_BUNDLES.length : EXTENSION_BUNDLES.length + 1 + heldBack +} + +/** + * Order the official optional bundles as both lists show them, so a bundle the profile picks from the More list + * keeps its place among the neighbours the main list already holds. + * @param packages - the official bundles the Host offers. + * @returns the bundles in display order. + */ +function orderOptionalBundles(packages: readonly PackageView[]): PackageView[] { + return [...packages].sort((a, b) => optionalBundleRank(a.name) - optionalBundleRank(b.name)) } /** How long a toast holds: long enough to read a failure that names what broke. */ @@ -1422,8 +1446,13 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode { && (pkg.installed || pkg.official || pkg.error !== undefined)) const mine = listed.filter(pkg => !pkg.official) const official = listed.filter(pkg => pkg.official) - const extensions = orderPackages(official.filter(pkg => !MORE_BUNDLES.includes(pkg.name)), EXTENSION_BUNDLES) - const more = [...extensions, ...orderPackages(official.filter(pkg => MORE_BUNDLES.includes(pkg.name)), MORE_BUNDLES)] + // Both lists read one order over every offered bundle: the main list holds the always-held ones plus the held-back + // ones the profile enabled, and the More list holds all of them, so enabling a bundle from the More list pins it + // to the main list without moving it among its neighbours there. + const ordered = orderOptionalBundles(official) + const pinned = new Set(official.filter(pinnedToMainList).map(pkg => pkg.name)) + const extensions = ordered.filter(pkg => pinned.has(pkg.name)) + const more = ordered const loaded = state.status === 'ready' || state.status === 'error' const refreshing = state.refreshStatus === 'refreshing' const openPkg = view.kind === 'package' || view.kind === 'row' ? listed.find(pkg => pkg.name === view.name) : undefined @@ -1431,7 +1460,7 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode { const openRow = view.kind === 'row' && openPkg !== undefined ? openPkg.rows.find(row => row.rowId === view.rowId) : undefined const showsCards = openPkg === undefined && openItem === undefined const detailFromMore = (view.kind === 'package' || view.kind === 'row') - && (view.from === 'more' || MORE_BUNDLES.includes(view.name)) + && (view.from === 'more' || (MORE_BUNDLES.includes(view.name) && !pinned.has(view.name))) const showsMoreList = showsCards && (view.kind === 'more' || detailFromMore) const showsMainList = showsCards && !showsMoreList const openPkgInMore = openPkg !== undefined && openPkg.official && detailFromMore diff --git a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx index add1b8b8b2..98e7efa54e 100644 --- a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx +++ b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx @@ -865,11 +865,11 @@ describe('PluginManagerPage', () => { const team = '@deepseek-ai/dsh-experimental-agent-team-profile' renderTab( { packages: [ - pkg({ name: '@deepseek-ai/dsh-experimental-badge-skill-bundle', official: true }), + pkg({ name: '@deepseek-ai/dsh-experimental-badge-skill-bundle', official: true, enabled: false }), pkg({ name: '@deepseek-ai/dsh-experimental-agent-team-profile', installed: false, optional: true, removable: false, enabled: false }), - pkg({ name: '@deepseek-ai/dsh-subagent-claude-code', official: true, availability: 'missing', installed: false }), - pkg({ name: '@deepseek-ai/dsh-subagent-codex', official: true }), - pkg({ name: '@deepseek-ai/dsh-experimental-tool-worktree', official: true }), + pkg({ name: '@deepseek-ai/dsh-subagent-claude-code', official: true, availability: 'missing', installed: false, enabled: false }), + pkg({ name: '@deepseek-ai/dsh-subagent-codex', official: true, enabled: false }), + pkg({ name: '@deepseek-ai/dsh-experimental-tool-worktree', official: true, enabled: false }), pkg({ name: '@acme/new-official', official: true }), pkg(), ] }, @@ -919,7 +919,7 @@ describe('PluginManagerPage', () => { it('returns from package details to the secondary page and retains it across React remounts', () => { const name = '@deepseek-ai/dsh-experimental-terminal-bundle' - const b = renderTab({ packages: [pkg({ name, official: true })] }) + const b = renderTab({ packages: [pkg({ name, official: true, enabled: false })] }) fireEvent.click(screen.getByRole('button', { name: en.moreAction })) expect(screen.getByRole('heading', { name: en.moreTitle, level: 1 })).toBeTruthy() fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', name) })) @@ -933,19 +933,44 @@ describe('PluginManagerPage', () => { expect(document.querySelector(`[data-plugin-package="${name}"]`)).toBeNull() }) - it('keeps toggled plugins in their group and position', () => { - const names = ['@deepseek-ai/dsh-experimental-cot-translation-bundle', '@deepseek-ai/dsh-experimental-terminal-bundle'] as const - const packages = names.map(name => pkg({ name, official: true, enabled: false })) - const b = renderTab({ packages }) + it('pins a bundle the profile picks onto the main list, in its full-list place', () => { + const team = '@deepseek-ai/dsh-experimental-agent-team-profile' + const first = '@deepseek-ai/dsh-experimental-tool-worktree' + const second = '@deepseek-ai/dsh-experimental-cot-translation-bundle' + const entry = (name: string, enabled: boolean): PackageView => pkg({ name, official: true, enabled }) + const b = renderTab({ packages: [entry(second, true), entry(team, true), entry(first, false)] }) + const cards = (group: 'extensions' | 'more'): (string | null)[] => { + const list = document.querySelector(`[data-plugin-group="${group}"]`) as HTMLElement + return within(list).getAllByRole('listitem').map(card => card.getAttribute('data-plugin-package')) + } + expect(cards('extensions')).toEqual([team, second]) fireEvent.click(screen.getByRole('button', { name: en.moreAction })) - const group = document.querySelector('[data-plugin-group="more"]') as HTMLElement - const cards = () => within(group).getAllByRole('listitem').map(card => card.getAttribute('data-plugin-package')) - expect(cards()).toEqual(names) - fireEvent.click(within(group).getByRole('switch', { name: en.enableToggle.replace('{name}', names[1]) })) - expect(b.actions.setEnabled).toHaveBeenCalledWith(names[1], true) - b.set({ packages: packages.map((entry, index) => ({ ...entry, enabled: index === 1 })) }) - expect(cards()).toEqual(names) + expect(cards('more')).toEqual([team, first, second]) + fireEvent.click(screen.getByRole('switch', { name: en.enableToggle.replace('{name}', first) })) + expect(b.actions.setEnabled).toHaveBeenCalledWith(first, true) + b.set({ packages: [entry(second, true), entry(team, true), entry(first, true)] }) + expect(cards('more')).toEqual([team, first, second]) + fireEvent.click(screen.getByRole('button', { name: en.backToList })) + expect(cards('extensions')).toEqual([team, first, second]) + fireEvent.click(screen.getByRole('switch', { name: en.enableToggle.replace('{name}', first) })) + expect(b.actions.setEnabled).toHaveBeenCalledWith(first, false) + b.set({ packages: [entry(second, true), entry(team, true), entry(first, false)] }) + expect(cards('extensions')).toEqual([team, second]) + fireEvent.click(screen.getByRole('button', { name: en.moreAction })) + expect(cards('more')).toEqual([team, first, second]) + }) + + it('returns a held-back bundle opened outside the lists to the list that shows it', () => { + const name = '@deepseek-ai/dsh-experimental-terminal-bundle' + const b = renderTab({ packages: [pkg({ name, official: true, enabled: false })] }) + act(() => { b.navigation.actions.setView({ kind: 'package', name }) }) + expect(document.querySelector(`[data-plugin-detail="${name}"]`)).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: en.backToPackage.replace('{name}', en.moreTitle) })) expect(screen.getByRole('heading', { name: en.moreTitle, level: 1 })).toBeTruthy() + b.set({ packages: [pkg({ name, official: true, enabled: true })] }) + act(() => { b.navigation.actions.setView({ kind: 'package', name }) }) + fireEvent.click(screen.getByRole('button', { name: en.backToList })) + expect(screen.getByRole('heading', { name: en.title, level: 1 })).toBeTruthy() }) it('starts on the main list when the navigation store is recreated', () => { @@ -1914,8 +1939,8 @@ describe('PluginManagerPage', () => { Object.defineProperty(Element.prototype, 'scrollIntoView', { value: scrollIntoView, configurable: true }) try { const { actions, set } = renderTab({ packages: [ - pkg({ name, official: true }), - pkg({ name: '@deepseek-ai/dsh-subagent-codex', official: true }), + pkg({ name, official: true, enabled: false }), + pkg({ name: '@deepseek-ai/dsh-subagent-codex', official: true, enabled: false }), ] }) expect(document.querySelector(`[data-plugin-package="${name}"]`)).toBeNull() set({ highlight: name }) @@ -1932,7 +1957,7 @@ describe('PluginManagerPage', () => { expect(screen.getByRole('heading', { name: en.title, level: 1 })).toBeTruthy() expect(document.querySelector(`[data-plugin-package="${name}"]`)).toBeNull() expect(scrollIntoView).toHaveBeenCalledTimes(1) - set({ packages: [pkg({ name, official: true })] }) + set({ packages: [pkg({ name, official: true, enabled: false })] }) act(() => { vi.advanceTimersByTime(799) }) expect(actions.clearHighlight).not.toHaveBeenCalled() act(() => { vi.advanceTimersByTime(1) }) From fdbe2bce4756435e9437df65501a6a8f9e040f13 Mon Sep 17 00:00:00 2001 From: winewill Date: Tue, 13 Oct 2026 16:14:41 +0800 Subject: [PATCH 09/35] feat(desktop): separate update channels and confirm releases before approval --- .github/AGENTS.md | 2 +- .github/workflows/desktop-publish.yml | 35 +++++++++++++++++- .github/workflows/macos-package.yml | 37 +++++++++++++++++-- .github/workflows/windows-package.yml | 37 +++++++++++++++++-- apps/desktop/packaging/README.i18n.yaml | 4 +- apps/desktop/packaging/README.md | 2 +- apps/desktop/packaging/README.zh.md | 2 +- apps/desktop/publishing/ci/README.i18n.yaml | 20 +++++----- apps/desktop/publishing/ci/README.md | 14 +++---- apps/desktop/publishing/ci/README.zh.md | 14 +++---- .../desktop-auto-update-environment.d.mts | 9 +++++ .../desktop-auto-update-environment.mjs | 25 ++++++++++--- apps/desktop/scripts/desktop-ci-version.mjs | 10 ++++- .../scripts/desktop-package-environment.mjs | 1 + apps/desktop/scripts/desktop-publish-route.ts | 12 ++++-- apps/desktop/scripts/desktop-release-store.ts | 3 ++ apps/desktop/scripts/desktop-upload-plan.ts | 9 ++--- .../scripts/electron-builder-config.mjs | 4 +- .../scripts/macos-app-update-config.d.mts | 3 +- .../scripts/macos-app-update-config.mjs | 22 +++++------ apps/desktop/scripts/package-macos.ts | 2 +- apps/desktop/scripts/package-target.ts | 1 + apps/desktop/scripts/prepare-macos-ci.mjs | 3 +- apps/desktop/scripts/prepare-windows-ci.mjs | 3 +- apps/desktop/scripts/publish-desktop-ci.ts | 20 +++++++--- .../scripts/resolve-desktop-publish-ci.ts | 15 ++++++-- apps/desktop/scripts/retain-desktop-ci.ts | 8 ++-- .../scripts/rollback-desktop-channel.ts | 4 +- apps/desktop/src/main.ts | 7 +++- apps/desktop/src/mandatory-update-policy.ts | 3 +- apps/desktop/src/update-channel.ts | 17 +++++++++ apps/desktop/src/update-coordinator.ts | 7 +++- .../desktop-auto-update-environment.spec.ts | 2 +- .../desktop-publication-preflight.spec.ts | 7 ++-- .../tests/desktop-publish-route.spec.ts | 12 +++++- .../tests/desktop-release-store.spec.ts | 1 + .../desktop/tests/desktop-upload-plan.spec.ts | 22 ++++++++--- .../test-release-upload-mac-arm64.json | 4 -- .../expected/test-release-upload-mac-x64.json | 4 -- .../expected/test-release-upload-win-x64.json | 4 -- .../tests/macos-app-update-config.spec.ts | 13 ++++++- .../tests/mandatory-update-policy.spec.ts | 2 +- apps/desktop/tests/prepare-windows-ci.spec.ts | 9 +++++ apps/desktop/tests/retain-desktop-ci.spec.ts | 2 +- apps/desktop/tests/update-channel.spec.ts | 10 +++++ apps/desktop/tests/update-coordinator.spec.ts | 19 +++++++++- scripts/desktop-publish-workflow.spec.ts | 13 +++++++ 47 files changed, 362 insertions(+), 117 deletions(-) create mode 100644 apps/desktop/src/update-channel.ts create mode 100644 apps/desktop/tests/update-channel.spec.ts diff --git a/.github/AGENTS.md b/.github/AGENTS.md index 7358c0f5c4..cb71142444 100644 --- a/.github/AGENTS.md +++ b/.github/AGENTS.md @@ -4,4 +4,4 @@ Run jobs on Windows runners (`windows-*` labels) under native `pwsh`. Native Win The manual [Windows packaging workflow](workflows/windows-package.yml) uses the dedicated self-hosted Windows x64 label `dsh-win-package-trial` under native `pwsh`; preserve this routing separately from the automatic CI lanes. -The Desktop publication workflow routes each retained build through `DESKTOP_PUBLISH_RUNNERS` to its original machine's unique label. Keep Windows run steps on native PowerShell and Mac run steps on the default POSIX shell; shared entrypoints are Node scripts. Production jobs use the separate `desktop-production` approval Environment. +The Desktop publication workflow routes each retained build through `DESKTOP_PUBLISH_RUNNERS` to its original machine's unique label. Keep Windows run steps on native PowerShell and Mac run steps on the default POSIX shell; shared entrypoints are Node scripts. Test and production jobs use separate `desktop-test` and `desktop-production` approval Environments after a credential-free preparation job. diff --git a/.github/workflows/desktop-publish.yml b/.github/workflows/desktop-publish.yml index 919170bdd4..8e494386e2 100644 --- a/.github/workflows/desktop-publish.yml +++ b/.github/workflows/desktop-publish.yml @@ -34,16 +34,49 @@ jobs: DESKTOP_PUBLISH_RUNNERS: ${{ vars.DESKTOP_PUBLISH_RUNNERS }} run: node --import tsx apps/desktop/scripts/resolve-desktop-publish-ci.ts - publish: + prepare: needs: route if: needs.route.outputs.publish == 'true' runs-on: [self-hosted, '${{ needs.route.outputs.runner }}'] + timeout-minutes: 30 + outputs: + approval: ${{ steps.check.outputs.approval }} + label: ${{ steps.check.outputs.label }} + env: + DESKTOP_PUBLISH_ROUTE: ${{ needs.route.outputs.route }} + DSH_TELEMETRY_DISABLED: '1' + steps: + - uses: actions/checkout@v7.0.1 + with: + persist-credentials: false + clean: false + path: desktop-publisher + - uses: pnpm/action-setup@v5.0.0 + with: + package_json_file: desktop-publisher/package.json + dest: ${{ runner.temp }}/publish-pnpm-${{ github.run_id }}-${{ github.run_attempt }} + - uses: actions/setup-node@v7.0.0 + with: + node-version: '24' + - run: pnpm install --frozen-lockfile + working-directory: desktop-publisher + - name: Verify local package without upload credentials + id: check + working-directory: desktop-publisher + run: node --import tsx apps/desktop/scripts/publish-desktop-ci.ts --check + + publish: + name: Approve ${{ needs.prepare.outputs.label }} + needs: [route, prepare] + if: needs.route.outputs.publish == 'true' + runs-on: [self-hosted, '${{ needs.route.outputs.runner }}'] environment: ${{ needs.route.outputs.preset == 'production' && 'desktop-production' || 'desktop-test' }} concurrency: group: desktop-publish-${{ needs.route.outputs.runner }}-${{ needs.route.outputs.preset }} cancel-in-progress: false timeout-minutes: 90 env: + DESKTOP_APPROVED_PACKAGE: ${{ needs.prepare.outputs.approval }} DESKTOP_PUBLISH_ROUTE: ${{ needs.route.outputs.route }} DSH_TELEMETRY_DISABLED: '1' steps: diff --git a/.github/workflows/macos-package.yml b/.github/workflows/macos-package.yml index 2bb5f8fdf2..c88de36c66 100644 --- a/.github/workflows/macos-package.yml +++ b/.github/workflows/macos-package.yml @@ -15,7 +15,7 @@ on: default: false required: true publish: - description: 'Publish successful signed packages and update the feed; production requires approval' + description: 'Publish successful signed packages and update the feed; test and production require approval' type: boolean default: false required: true @@ -24,11 +24,28 @@ on: type: string default: '' required: false - build_version: - description: 'Version: auto recommended; explicit version must match the checkout product prefix' + source_ref: + description: 'Source: master or a dsh-v* tag containing the Desktop CI adapters' type: string - default: auto + default: master required: true + update_channel: + description: 'Installer update channel; automatic updates stay on this channel' + type: choice + options: [nightly, latest] + default: nightly + required: true + version_mode: + description: 'default: production follows product, test auto; manual uses build_version' + type: choice + options: [default, product, auto, manual] + default: default + required: true + build_version: + description: 'Manual version only: product version or its supported prerelease suffix' + type: string + default: '' + required: false permissions: contents: read @@ -46,6 +63,9 @@ jobs: shell: bash env: PACKAGE_SKIP_NOTARIZATION: ${{ inputs.skip_notarization }} + PACKAGE_SOURCE_REF: ${{ inputs.source_ref }} + PACKAGE_VERSION_MODE: ${{ inputs.version_mode }} + DSH_DESKTOP_UPDATE_CHANNEL: ${{ inputs.update_channel }} DSH_TELEMETRY_DISABLED: '1' DSH_PACKAGING_PRESET: ${{ inputs.preset }} PACKAGE_PUBLISH: ${{ inputs.publish }} @@ -56,6 +76,14 @@ jobs: with: persist-credentials: false clean: true + ref: ${{ inputs.source_ref }} + - name: Validate selected source and adapter support + run: >- + node -e "const fs=require('node:fs'); const ref=process.env.PACKAGE_SOURCE_REF; + if(ref!=='master' && !/^dsh-v[0-9][0-9A-Za-z.-]*$/.test(ref)) throw Error('Select master or dsh-v*'); + const v=JSON.parse(fs.readFileSync('package.json','utf8')).version; + if(ref!=='master' && ref!=='dsh-v'+v) throw Error('Source tag must match product version'); + if(!fs.readFileSync('apps/desktop/scripts/desktop-ci-version.mjs','utf8').includes('PACKAGE_VERSION_MODE')) throw Error('Source lacks Desktop channel/version CI support; select a newer release');" - uses: pnpm/action-setup@v5.0.0 with: dest: ${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.run_attempt }} @@ -86,6 +114,7 @@ jobs: - name: Retain complete package outside the checkout id: retained env: + PACKAGE_SOURCE_SHA: ${{ steps.checkout.outputs.commit }} PACKAGE_VERSION: ${{ steps.settings.outputs.version }} run: node --import tsx apps/desktop/scripts/retain-desktop-ci.ts - name: Retain packaging diagnostics diff --git a/.github/workflows/windows-package.yml b/.github/workflows/windows-package.yml index 9b3cbdb7a3..320f77265b 100644 --- a/.github/workflows/windows-package.yml +++ b/.github/workflows/windows-package.yml @@ -16,7 +16,7 @@ on: default: unsigned required: true publish: - description: 'Publish successful signed packages and update the feed; production requires approval' + description: 'Publish successful signed packages and update the feed; test and production require approval' type: boolean default: false required: true @@ -25,11 +25,28 @@ on: type: string default: '' required: false - build_version: - description: 'Version: auto recommended; or checkout product version / e.g. 0.2.1-alpha.1.20261009.1 (prefix must match checkout)' + source_ref: + description: 'Source: master or a dsh-v* tag containing the Desktop CI adapters' type: string - default: auto + default: master required: true + update_channel: + description: 'Installer update channel; automatic updates stay on this channel' + type: choice + options: [nightly, latest] + default: nightly + required: true + version_mode: + description: 'default: production follows product, test auto; manual uses build_version' + type: choice + options: [default, product, auto, manual] + default: default + required: true + build_version: + description: 'Manual version only: product version or its supported prerelease suffix' + type: string + default: '' + required: false permissions: contents: read @@ -46,6 +63,9 @@ jobs: run: shell: pwsh env: + PACKAGE_SOURCE_REF: ${{ inputs.source_ref }} + PACKAGE_VERSION_MODE: ${{ inputs.version_mode }} + DSH_DESKTOP_UPDATE_CHANNEL: ${{ inputs.update_channel }} DSH_TELEMETRY_DISABLED: '1' DSH_PACKAGING_PRESET: ${{ inputs.preset }} PACKAGE_PUBLISH: ${{ inputs.publish }} @@ -68,6 +88,14 @@ jobs: with: persist-credentials: false clean: true + ref: ${{ inputs.source_ref }} + - name: Validate selected source and adapter support + run: >- + node -e "const fs=require('node:fs'); const ref=process.env.PACKAGE_SOURCE_REF; + if(ref!=='master' && !/^dsh-v[0-9][0-9A-Za-z.-]*$/.test(ref)) throw Error('Select master or dsh-v*'); + const v=JSON.parse(fs.readFileSync('package.json','utf8')).version; + if(ref!=='master' && ref!=='dsh-v'+v) throw Error('Source tag must match product version'); + if(!fs.readFileSync('apps/desktop/scripts/desktop-ci-version.mjs','utf8').includes('PACKAGE_VERSION_MODE')) throw Error('Source lacks Desktop channel/version CI support; select a newer release');" - uses: pnpm/action-setup@v5.0.0 with: dest: ${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.run_attempt }} @@ -102,6 +130,7 @@ jobs: - name: Retain complete package outside the checkout id: retained env: + PACKAGE_SOURCE_SHA: ${{ steps.checkout.outputs.commit }} PACKAGE_VERSION: ${{ steps.settings.outputs.version }} run: node --import tsx apps/desktop/scripts/retain-desktop-ci.ts - name: Retain packaging diagnostics diff --git a/apps/desktop/packaging/README.i18n.yaml b/apps/desktop/packaging/README.i18n.yaml index a4eff8cc20..40103064d6 100644 --- a/apps/desktop/packaging/README.i18n.yaml +++ b/apps/desktop/packaging/README.i18n.yaml @@ -18,8 +18,8 @@ en: 17c6bcdefa05a8f8 zh: 4ee18f832276144d /desktop-packaging/release-versions: - en: d80a92c02a583b80 - zh: b27f75ff29fcea1a + en: 3f38ab26ee195b39 + zh: f00eddafa8f3b9ca /desktop-packaging/configuration-and-local-builds: en: f5dc36baf414ecc5 zh: 8984741b356ac1d3 diff --git a/apps/desktop/packaging/README.md b/apps/desktop/packaging/README.md index b6548275c5..9cf46b68e1 100644 --- a/apps/desktop/packaging/README.md +++ b/apps/desktop/packaging/README.md @@ -61,7 +61,7 @@ pnpm --dir apps/desktop run package:win:x64 --build-version 0.1.6-alpha.1.202609 A production release publishes the product version and takes no `--build-version`. Uploading one tags the packaged commit as `desktop-v` once the artifacts are public; a build from a modified checkout is not tagged, and a tagging failure prints the command to run by hand rather than failing a completed upload. Test and local builds are deliberately left untagged, and every artifact records `dshBuildCommit` and `dshBuildDirty` in its manifest so a build handed over directly remains traceable. -Version derivation does not change the fixed update channel or `nightly.yml` / `nightly-mac.yml` filenames. SemVer orders `0.1.6-alpha.1 < 0.1.6-alpha.1.20260916.1 < 0.1.6-alpha.2`, and a stable base's test version precedes that stable release. Clients only accept a greater version: replacing a feed cannot move an installed higher version to a lower corrected version. Such clients need manual installation; keep automatic downgrade disabled. The [archived version decision](../../../.agents/notes/archived/process/2026-09-16-desktop-release-version-derivation.md) explains why the channel does not supply the prerelease identifier. +The installer embeds `DSH_DESKTOP_UPDATE_CHANNEL` (`nightly` by default, or `latest`) independently of version and deployment. Automatic updates remain on that channel even for a stable version; manually installing the other channel switches it. Latest packages reject prerelease versions. A stable product can produce a nightly `-test..` build, but that version ranks below the stable base and cannot upgrade an already installed higher version. Keep automatic downgrade disabled; stable corrective releases require a product version bump. CI version selection and publication are documented in [CI publication](../publishing/ci/README.md#package-and-publish). diff --git a/apps/desktop/packaging/README.zh.md b/apps/desktop/packaging/README.zh.md index 83df32e75c..125b480ce5 100644 --- a/apps/desktop/packaging/README.zh.md +++ b/apps/desktop/packaging/README.zh.md @@ -61,7 +61,7 @@ pnpm --dir apps/desktop run package:win:x64 --build-version 0.1.6-alpha.1.202609 production 发布使用产品版本本身,不传 `--build-version`。其上传成功后会把打包所用 commit 打成 `desktop-v<版本>` 标签;来自有改动工作区的构建不打标签,打标签失败也只打印手工命令,不会让已完成的上传变成失败。test 与本地构建有意不留标签,而所有产物的清单都记录 `dshBuildCommit` 与 `dshBuildDirty`,直接分发的构建同样可溯源。 -版本派生不改变固定更新通道,也不改变 `nightly.yml` / `nightly-mac.yml` 文件名。SemVer 排序为 `0.1.6-alpha.1 < 0.1.6-alpha.1.20260916.1 < 0.1.6-alpha.2`,稳定基础版本的测试版低于该稳定版。客户端只接受更高版本:替换 feed 无法让已安装的较高版本更新到较低的纠正版。这类客户端需要手动安装;保持自动降级关闭。[已归档的版本决策](../../../.agents/notes/archived/process/2026-09-16-desktop-release-version-derivation.md)解释为什么不能用通道名替换预发布标识。 +安装包独立于版本和发布环境内置 `DSH_DESKTOP_UPDATE_CHANNEL`(默认 `nightly`,也可选择 `latest`)。自动更新始终保留该通道,即使安装的是稳定版本;手动安装另一通道的包才会切换。latest 包拒绝预发布版本。稳定产品可生成 nightly 的 `-test..` 构建,但该版本低于稳定基准,不能升级已安装的更高版本。保持自动降级关闭;稳定修复版需要提升产品版本。CI 版本选择与发布见 [CI 发布](../publishing/ci/README.zh.md#package-and-publish)。 diff --git a/apps/desktop/publishing/ci/README.i18n.yaml b/apps/desktop/publishing/ci/README.i18n.yaml index e391f93ff5..b5f7e587e8 100644 --- a/apps/desktop/publishing/ci/README.i18n.yaml +++ b/apps/desktop/publishing/ci/README.i18n.yaml @@ -9,23 +9,23 @@ en: a610b66106849734 zh: bc1760791f56fa28 /desktop-ci-publication/summary: - en: 51eb8286f4a0ae4c - zh: 9afc15bdcf1068c5 + en: 470bdf1487a739f9 + zh: cd80330eaa8f730c /desktop-ci-publication/table-of-contents: en: 1b6a4d7146879eec zh: f2520fc412cd2d2a /desktop-ci-publication/machine-setup: - en: b130c91a6a82c71d - zh: 527206eb3bf4b4a8 + en: 990ff7e56dd6005a + zh: 18f9dc23c520dc8c /desktop-ci-publication/package-and-publish: - en: 596fa81b1033ce87 - zh: fde2729de6275764 + en: 6e29eb0dff23e2c8 + zh: 65d5d57b63b86904 /desktop-ci-publication/recovery-and-records: - en: 33e6d91a62f858f8 - zh: 4a2286c838f83682 + en: 370f21dc25fc1098 + zh: cf7f433b8eaa3dcd /desktop-ci-publication/restore-a-channel: - en: 98620fee90ea1271 - zh: 3517d565ae8d24dd + en: d82e2ccf70cce397 + zh: d93dcb77cc4b463e /desktop-ci-publication/dev-note: en: b75c8458928b72b9 zh: 003ba4e17b4b5269 diff --git a/apps/desktop/publishing/ci/README.md b/apps/desktop/publishing/ci/README.md index cbc65905da..98bbb3cb3c 100644 --- a/apps/desktop/publishing/ci/README.md +++ b/apps/desktop/publishing/ci/README.md @@ -8,7 +8,7 @@ English | [中文](README.zh.md) ## Summary -Publish a completed package from its original machine without downloading its installer again. Both test and production publish binaries followed by update YAML. Production requires GitHub Environment approval and a GitHub App tag. Real COS, CDN and administrator-permission acceptance remain deployment checks; local fixtures do not establish those permissions. +Publish a completed package from its original machine without downloading its installer again. Both test and production publish binaries followed by update YAML. Both deployments require GitHub Environment approval; only production creates a GitHub App tag. Real COS, CDN and administrator-permission acceptance remain deployment checks; local fixtures do not establish those permissions. ## Table of Contents @@ -27,25 +27,25 @@ Create `publishing/test.json` and, when authorized, `publishing/production.json` Set the repository variable `DESKTOP_PUBLISH_RUNNERS` to a JSON mapping of registered runner names to unique labels and targets, for example `{"mini":{"label":"dsh-mini-publish","target":"mac-arm64"}}`. Assign that label to exactly that runner. Windows uses target `win-x64`. The routing job checks the original successful job, and the publisher checks its own runner name against the recorded name. Machines need no shared filesystem or LAN connection to each other. -An administrator must create `desktop-production` with required reviewers and an allowed `master` deployment branch. The workflow checks that required reviewers exist before scheduling production. The GitHub App needs installation access to this repository and Contents write permission, plus permission under the `desktop-v*` tag ruleset to create release tags. Reviewer approval does not transfer the reviewer's tag permissions to the App. Missing configuration stops publication; there is no personal-token fallback. The App action creates and later revokes a short-lived token. Protect the local private key and COS credential files with the publishing account's filesystem permissions. The token action accepts key contents, so the masked PEM also passes through the runner's temporary `GITHUB_OUTPUT` file during the job; masking hides logs, not local file access. +An administrator must create `desktop-test` and `desktop-production`, each with required reviewers and an allowed `master` deployment branch. The workflow checks reviewers for either deployment. Test can allow self-approval. The GitHub App needs installation access to this repository and Contents write permission, plus permission under the `desktop-v*` tag ruleset to create release tags. Reviewer approval does not transfer the reviewer's tag permissions to the App. Missing configuration stops publication; there is no personal-token fallback. The App action creates and later revokes a short-lived token. Protect the local private key and COS credential files with the publishing account's filesystem permissions. The token action accepts key contents, so the masked PEM also passes through the runner's temporary `GITHUB_OUTPUT` file during the job; masking hides logs, not local file access. -Environment approval gates this workflow, not arbitrary code running under the same machine account. Account isolation and forced source-branch restrictions are separate operational work. Keep existing release Environments unchanged. The test job uses `desktop-test` without requiring production approval. +Environment approval gates this workflow, not arbitrary code running under the same machine account. Account isolation and forced source-branch restrictions are separate operational work. Keep existing release Environments unchanged. The test job uses its own reviewers and never creates a real release tag; local simulated transports exercise tag creation and recovery without verifying App permissions. ## Package and publish -Select the platform's `Package Desktop` workflow, preset and version. The optional `test_release_id` is the test URL prefix: blank retains the machine default, successive upgrade tests on that platform need the same value, and defaults should be rotated periodically. It is not a GitHub run ID. See [packaging configuration](../../packaging/README.md#configuration-and-local-builds) for its validation and rotation limits. +Select the platform's `Package Desktop` workflow, preset, source, channel and version mode. `source_ref` accepts `master` or `dsh-v*` matching the selected product version; the selected source must contain these CI adapters, so older tags fail before dependency installation. The publisher runs from the default branch while recording the actual source commit separately. `version_mode=default` selects product for production and auto for test; product preserves the selected checkout version, auto appends date/run/attempt, and manual requires `build_version`. A latest package requires a stable version, so test/latest needs product or a stable manual version. The optional `test_release_id` is the test URL prefix: blank retains the machine default, successive upgrade tests on that platform need the same value, and defaults should be rotated periodically. It is not a GitHub run ID. See [packaging configuration](../../packaging/README.md#configuration-and-local-builds) for its validation and rotation limits. Select `publish` to request publication after packaging succeeds. Windows must be signed; macOS must finish notarization and stapling. The separate `Publish Desktop · COS` workflow reads the small request manifest from GitHub, binds it to the source run and attempt, and routes to the original machine. It uses the locally retained installers. A failed build or one without a publication request produces no upload. -Before uploading, the publisher hashes all retained files, checks version and destination metadata, and rechecks the platform signatures. macOS verifies the DMG and extracts the ZIP to verify the app's ticket; Windows checks Authenticode, the configured signer thumbprint and the timestamp. The production job waits for a reviewer to select **Review deployments → Approve and deploy** before running. Both deployments upload missing binaries, verify remote bytes, then update YAML. There is no files-only mode in this workflow. Production finally creates `desktop-v` at the recorded packaging commit; an existing tag must name the same commit. +Before uploading, the publisher hashes all retained files, checks version and destination metadata, and rechecks the platform signatures. macOS verifies the DMG and extracts the ZIP to verify the app's ticket; Windows checks Authenticode, the configured signer thumbprint and the timestamp. Preparation writes an approval summary with version, channel, platform, deployment, source ref/SHA, package run/attempt, object keys and proposed tag. Both publishing jobs wait for **Review deployments → Approve and deploy**, then recheck the approved manifest and upload plan digest. Changed bytes or destinations reject. Approval cannot edit a version; reject and rebuild instead. Both deployments upload missing binaries, verify remote bytes, then update YAML. There is no files-only mode in this workflow. Production finally creates `desktop-v` at the recorded packaging commit; an existing tag must name the same commit. ## Recovery and records -Re-run the publishing workflow after resolving a failure; do not rebuild solely to retry an upload. Its source package run and attempt remain fixed. Missing objects are uploaded, identical objects are skipped, and conflicting same-name bytes stop the operation. Unknown write outcomes are reconciled by reading remote bytes. A newer channel version prevents a stale upload from replacing it. Stable releases update both nightly and latest feeds: if nightly already contains a higher prerelease (for example 1.1.0-alpha.1), a 1.0.1 stable hotfix is rejected before any upload. Use a higher corrective version; this workflow does not implicitly downgrade nightly. +Re-run the publishing workflow after resolving a failure; do not rebuild solely to retry an upload. Its source package run and attempt remain fixed. Missing objects are uploaded, identical objects are skipped, and conflicting same-name bytes stop the operation. Unknown write outcomes are reconciled by reading remote bytes. A newer channel version prevents a stale upload from replacing it. Each package updates only its embedded channel: nightly writes `nightly.yml` / `nightly-mac.yml`, latest writes `latest.yml` / `latest-mac.yml`. Latest binaries use a `latest/` subdirectory under the target binary prefix, separate from existing nightly objects. Publishing the same source/version to both channels requires two packages; an existing release tag is reused only for the same source commit. The sibling `upload-records/` directory retains an operation identity, newline-delimited events and terminal result. Feed-write intents retain both previous and requested YAML. Failures record their stage, safe error classification and diagnostic; raw provider responses are excluded. Audit initialization becomes visible only after its files are complete; abandoned `.initializing-*` directories are not scanned for completed publication. Writes and readback are recorded before proceeding; missing terminal results mean interruption, not success. Actions also keeps its operation record as a diagnostic artifact for 30 days. These local records are not tamper-proof. COS readback verifies storage bytes, not public CDN behavior; public download acceptance is separate. @@ -57,7 +57,7 @@ Normal publication and manual restoration acquire the same local channel lock. A ## Restore a channel -Run the repository script `apps/desktop/scripts/rollback-desktop-channel.ts` through `node --import tsx` on the publishing machine. Keep `GITHUB_REPOSITORY`, `RUNNER_NAME`, `DSH_PACKAGING_CONFIG_DIR` and `DESKTOP_PUBLISH_ROUTE` set to the original package context. The route JSON contains `runId`, `attempt`, `commit`, `runnerName`, `target` and `preset`, available in the retained manifest's source fields. Pass `--record` with that successful publication's audit directory, `--expected-version` with the version currently online, and `--reason` with the operational reason. +Run the repository script `apps/desktop/scripts/rollback-desktop-channel.ts` through `node --import tsx` on the publishing machine. Keep `GITHUB_REPOSITORY`, `RUNNER_NAME`, `DSH_PACKAGING_CONFIG_DIR` and `DESKTOP_PUBLISH_ROUTE` set to the original package context. The route JSON contains `runId`, `attempt`, `commit`, `runnerName`, `target`, `preset`, `sourceRef` and `channel`, available in the retained manifest's source fields. Pass `--record` with that successful publication's audit directory, `--expected-version` with the version currently online, and `--reason` with the operational reason. Without `--execute`, the script reads remote objects and prints the exact YAML change without writing. Preview requires COS read credentials. With `--execute`, it verifies historical files, preserves before/after YAML in a new audit record, checks the current channel again, and restores only YAML. It neither uploads binaries nor changes tags. A channel already matching the target bytes is skipped; other channels must match the expected current version. This permits recovery after one feed was restored before an interruption. A different current version aborts the operation. Restoration is not a multi-object transaction; inspect the audit after any partial failure before continuing. diff --git a/apps/desktop/publishing/ci/README.zh.md b/apps/desktop/publishing/ci/README.zh.md index 7d801493fa..d689d2a85e 100644 --- a/apps/desktop/publishing/ci/README.zh.md +++ b/apps/desktop/publishing/ci/README.zh.md @@ -8,7 +8,7 @@ description: "在专用 macOS 和 Windows runner 上发布留存的 Desktop 安 ## 概述 -在原打包机器上发布完整安装包,无需再次下载安装包。test 和 production 都先发布二进制文件,再更新 YAML。production 需要 GitHub Environment 审批,并通过 GitHub App 创建 tag。真实 COS、CDN 和管理员权限仍需部署验收;本地 fixture 不证明这些权限可用。 +在原打包机器上发布完整安装包,无需再次下载安装包。test 和 production 都先发布二进制文件,再更新 YAML。两种环境都需要 GitHub Environment 审批;只有 production 通过 GitHub App 创建 tag。真实 COS、CDN 和管理员权限仍需部署验收;本地 fixture 不证明这些权限可用。 ## 目录 @@ -27,25 +27,25 @@ description: "在专用 macOS 和 Windows runner 上发布留存的 Desktop 安 将仓库变量 `DESKTOP_PUBLISH_RUNNERS` 配置为已注册 runner 名称到唯一标签和目标的 JSON 映射,例如 `{"mini":{"label":"dsh-mini-publish","target":"mac-arm64"}}`。该标签只能分配给这台 runner。Windows 使用目标 `win-x64`。路由 job 检查原构建成功的 job,发布器再检查自身 runner 名称与记录一致。机器之间不需要共享文件系统或局域网连接。 -管理员必须创建 `desktop-production`,设置必需审批人并允许 `master` 部署分支。工作流调度 production 前会检查必需审批人已配置。GitHub App 需要安装到本仓库、具有 Contents write 权限,并获准在 `desktop-v*` tag ruleset 下创建发布 tag。审批不会把审批人的 tag 权限传给 App。缺少配置会停止发布,不回退到个人 token。App action 创建短期 token,并在结束后撤销。使用发布账户的文件系统权限保护本机私钥及 COS 凭据文件。token action 需要密钥内容,因此已屏蔽日志的 PEM 在 job 期间也会经过 runner 临时 `GITHUB_OUTPUT` 文件;日志屏蔽不限制本地文件访问。 +管理员必须创建 `desktop-test` 和 `desktop-production`,分别设置必需审批人并允许 `master` 部署分支。工作流会检查两种环境的审批人配置。test 可允许发起者自行审批。GitHub App 需要安装到本仓库、具有 Contents write 权限,并获准在 `desktop-v*` tag ruleset 下创建发布 tag。审批不会把审批人的 tag 权限传给 App。缺少配置会停止发布,不回退到个人 token。App action 创建短期 token,并在结束后撤销。使用发布账户的文件系统权限保护本机私钥及 COS 凭据文件。token action 需要密钥内容,因此已屏蔽日志的 PEM 在 job 期间也会经过 runner 临时 `GITHUB_OUTPUT` 文件;日志屏蔽不限制本地文件访问。 -Environment 审批限制此工作流,不限制同一机器账户运行的任意代码。账户隔离和强制源分支限制属于独立运维工作。保持现有发布 Environment 不变。test job 使用 `desktop-test`,无需 production 审批。 +Environment 审批限制此工作流,不限制同一机器账户运行的任意代码。账户隔离和强制源分支限制属于独立运维工作。保持现有发布 Environment 不变。test job 使用自己的审批人,不创建真实发布 tag;本地模拟接口覆盖 tag 创建与恢复,但不验证 App 权限。 ## 打包与发布 -选择对应平台的 `Package Desktop` 工作流、preset 和版本。可选 `test_release_id` 是测试 URL 前缀:留空沿用机器默认值,同一平台连续升级测试需保持相同值,并应定期轮换默认值。它不是 GitHub run ID。格式校验和轮换限制见[打包配置](../../packaging/README.zh.md#configuration-and-local-builds)。 +选择对应平台的 `Package Desktop` 工作流、preset、源码、通道和版本模式。`source_ref` 接受 `master` 或与所选产品版本一致的 `dsh-v*`;源码必须包含这些 CI 适配脚本,因此旧 tag 会在安装依赖前失败。发布器从默认分支运行,单独记录实际源码提交。`version_mode=default` 对 production 选择 product,对 test 选择 auto;product 保留所选源码版本,auto 追加日期/run/attempt,manual 要求填写 `build_version`。latest 包要求稳定版本,因此 test/latest 需选择 product 或稳定的手填版本。可选 `test_release_id` 是测试 URL 前缀:留空沿用机器默认值,同一平台连续升级测试需保持相同值,并应定期轮换默认值。它不是 GitHub run ID。格式校验和轮换限制见[打包配置](../../packaging/README.zh.md#configuration-and-local-builds)。 选中 `publish`,请求在打包成功后发布。Windows 必须签名;macOS 必须完成公证和 stapling。独立的 `Publish Desktop · COS` 工作流从 GitHub 读取小型请求 manifest,将其与原 run 和 attempt 核对,再路由到原机器。安装包使用本机留存文件。构建失败或未请求发布时不会上传。 -上传前,发布器校验全部留存文件的哈希、版本及目标信息,再复核平台签名。macOS 校验 DMG,并解压 ZIP 校验 app 票据;Windows 校验 Authenticode、配置的签名证书指纹及时间戳。production job 等待审批人选择 **Review deployments → Approve and deploy** 后才运行。两种环境都上传缺失文件、校验远端字节,再更新 YAML。此工作流没有只上传文件的模式。production 最后为记录的打包提交创建 `desktop-v`;已有 tag 必须指向同一提交。 +上传前,发布器校验全部留存文件的哈希、版本及目标信息,再复核平台签名。macOS 校验 DMG,并解压 ZIP 校验 app 票据;Windows 校验 Authenticode、配置的签名证书指纹及时间戳。准备阶段生成审批摘要,列出版本、通道、平台、环境、源码 ref/SHA、package run/attempt、对象路径及拟创建的 tag。两种发布 job 均等待 **Review deployments → Approve and deploy**,随后重新核对已批准的 manifest 与上传计划摘要。文件或目标变化会被拒绝。审批不能修改版本;应拒绝并重新打包。两种环境都上传缺失文件、校验远端字节,再更新 YAML。此工作流没有只上传文件的模式。production 最后为记录的打包提交创建 `desktop-v`;已有 tag 必须指向同一提交。 ## 恢复与记录 -解决故障后重跑发布工作流,不要仅为重试上传而重新打包。原 package run 和 attempt 保持不变。缺失对象会上传,相同对象跳过,同名但字节不同的对象会停止操作。写入结果不明时通过读取远端字节核对。频道已有更高版本时,旧上传不能替换它。稳定版同时更新 nightly 与 latest:若 nightly 已有更高预发行版(例如 1.1.0-alpha.1),1.0.1 稳定热修复会在任何上传前被拒绝。应发布更高版本的修正版;此工作流不会隐式降级 nightly。 +解决故障后重跑发布工作流,不要仅为重试上传而重新打包。原 package run 和 attempt 保持不变。缺失对象会上传,相同对象跳过,同名但字节不同的对象会停止操作。写入结果不明时通过读取远端字节核对。频道已有更高版本时,旧上传不能替换它。每个包只更新自身内置通道:nightly 写入 `nightly.yml` / `nightly-mac.yml`,latest 写入 `latest.yml` / `latest-mac.yml`。latest 二进制位于目标二进制前缀下的 `latest/` 子目录,与已有 nightly 对象分离。同一源码和版本发布到两个通道需要分别打包;已有发布 tag 仅在源码提交相同时复用。 同级 `upload-records/` 目录保留操作标识、逐行事件和最终结果。频道写入意图同时记录先前和目标 YAML。失败记录阶段、安全错误分类与诊断,不保存原始服务响应。审计初始化在文件完整后才可见;遗留的 `.initializing-*` 目录不参与已完成发布的扫描。写入和回读在继续操作前落盘;没有最终结果代表中断,不代表成功。Actions 还将操作记录保存为诊断 artifact,保留 30 天。这些本地记录不防篡改。COS 回读验证存储字节,不代表公开 CDN 行为;公开下载需单独验收。 @@ -57,7 +57,7 @@ Environment 审批限制此工作流,不限制同一机器账户运行的任 ## 恢复频道 -在发布机器上通过 `node --import tsx` 运行仓库脚本 `apps/desktop/scripts/rollback-desktop-channel.ts`。保持 `GITHUB_REPOSITORY`、`RUNNER_NAME`、`DSH_PACKAGING_CONFIG_DIR` 和 `DESKTOP_PUBLISH_ROUTE` 对应原打包上下文。route JSON 包含 `runId`、`attempt`、`commit`、`runnerName`、`target` 和 `preset`,可从留存 manifest 的 source 字段取得。`--record` 指定该次成功发布的审计目录,`--expected-version` 指定当前线上版本,`--reason` 填写操作原因。 +在发布机器上通过 `node --import tsx` 运行仓库脚本 `apps/desktop/scripts/rollback-desktop-channel.ts`。保持 `GITHUB_REPOSITORY`、`RUNNER_NAME`、`DSH_PACKAGING_CONFIG_DIR` 和 `DESKTOP_PUBLISH_ROUTE` 对应原打包上下文。route JSON 包含 `runId`、`attempt`、`commit`、`runnerName`、`target`、`preset`、`sourceRef` 和 `channel`,可从留存 manifest 的 source 字段取得。`--record` 指定该次成功发布的审计目录,`--expected-version` 指定当前线上版本,`--reason` 填写操作原因。 不带 `--execute` 时,脚本读取远端对象并打印确切 YAML 变更,不执行写入。预览需要 COS 读取凭据。带 `--execute` 时,脚本校验历史文件,将前后 YAML 保存在新的审计记录中,再次检查当前频道,然后仅恢复 YAML。它不上传二进制文件,也不改变 tag。已经与目标字节完全相同的频道会跳过,其余频道必须匹配预期当前版本,因此可以恢复写完一个 feed 后中断的操作。其他当前版本会中止操作。恢复不是多对象事务;部分失败后需先检查审计记录再继续。 diff --git a/apps/desktop/scripts/desktop-auto-update-environment.d.mts b/apps/desktop/scripts/desktop-auto-update-environment.d.mts index 00ebf8c5c0..f656f9baaf 100644 --- a/apps/desktop/scripts/desktop-auto-update-environment.d.mts +++ b/apps/desktop/scripts/desktop-auto-update-environment.d.mts @@ -10,6 +10,7 @@ export type DesktopAutoUpdateTarget = 'mac-arm64' | 'mac-x64' | 'win-x64' /** Public updater URL and object prefixes for one release target. */ export interface DesktopAutoUpdateConfig { readonly environment: DesktopAutoUpdateEnvironment + readonly channel: 'nightly' | 'latest' readonly target: DesktopAutoUpdateTarget readonly origin: string readonly publicUrl: string @@ -60,6 +61,7 @@ export function desktopBuildRecordFilename(target: DesktopAutoUpdateTarget): str export function desktopUpdateMetadataFilename( version: string, platform: NodeJS.Platform, + channel?: 'nightly' | 'latest', ): string /** @@ -89,3 +91,10 @@ export function resolveDesktopUploadConfig( platform: NodeJS.Platform, arch: string, ): DesktopUploadConfig + +/** + * Resolve the packaged update channel, defaulting omitted local settings to nightly. + * @param env Packaging settings. + * @returns Validated channel. + */ +export function resolveDesktopUpdateChannel(env: NodeJS.ProcessEnv): 'nightly' | 'latest' diff --git a/apps/desktop/scripts/desktop-auto-update-environment.mjs b/apps/desktop/scripts/desktop-auto-update-environment.mjs index 464d079b4d..a1973a9c64 100644 --- a/apps/desktop/scripts/desktop-auto-update-environment.mjs +++ b/apps/desktop/scripts/desktop-auto-update-environment.mjs @@ -5,6 +5,17 @@ import { valid } from 'semver' /** Environment variable that selects the Desktop update deployment. */ export const DESKTOP_AUTO_UPDATE_ENV = 'DSH_DESKTOP_AUTO_UPDATE_ENV' +/** + * Resolve the channel embedded in the installer; omitted local settings retain nightly. + * @param {NodeJS.ProcessEnv} env Packaging or upload settings. + * @returns {'nightly' | 'latest'} Validated channel. + */ +export function resolveDesktopUpdateChannel(env) { + const channel = env.DSH_DESKTOP_UPDATE_CHANNEL || 'nightly' + if (channel !== 'nightly' && channel !== 'latest') throw new Error('desktop auto-update: invalid update channel') + return channel +} + const UPDATE_ENVIRONMENTS = { test: { originEnvName: 'DOWNLOAD_TEST_ORIGIN', @@ -68,16 +79,18 @@ export function desktopBuildRecordFilename(target) { * Return the electron-builder channel metadata filename for an application version. * @param {string} version - Desktop semantic version. * @param {NodeJS.Platform} platform - Target platform. + * @param {'nightly' | 'latest'} channel - Selected channel; omitted means nightly. * @returns {string} Channel metadata filename emitted for the target. */ -export function desktopUpdateMetadataFilename(version, platform) { +export function desktopUpdateMetadataFilename(version, platform, channel = 'nightly') { if (valid(version) === null) { throw new Error(`desktop auto-update: invalid Desktop version ${JSON.stringify(version)}`) } if (platform !== 'darwin' && platform !== 'win32') { throw new Error(`desktop auto-update: unsupported metadata platform ${platform}`) } - return `nightly${platform === 'darwin' ? '-mac' : ''}.yml` + resolveDesktopUpdateChannel({ DSH_DESKTOP_UPDATE_CHANNEL: channel }) + return `${channel}${platform === 'darwin' ? '-mac' : ''}.yml` } /** @@ -124,10 +137,11 @@ function httpsOrigin(value, name) { * @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, binaryKeyPrefix: string }} Resolved updater configuration. + * @returns {{ environment: 'test' | 'production', channel: 'nightly' | 'latest', 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 channel = resolveDesktopUpdateChannel(env) const environment = resolveDesktopAutoUpdateEnvironment(env) const target = resolveDesktopAutoUpdateTarget(platform, arch) const deployment = UPDATE_ENVIRONMENTS[environment] @@ -148,10 +162,11 @@ export function resolveDesktopAutoUpdateConfig(env, platform, arch) { const keyPrefix = `${releasePrefix}/feeds/${target}` return { environment, + channel, target, origin, keyPrefix, - binaryKeyPrefix: `${releasePrefix}/bin/${target}`, + binaryKeyPrefix: `${releasePrefix}/bin/${target}${channel === 'latest' ? '/latest' : ''}`, publicUrl: `${origin}/${keyPrefix}/`, } } @@ -161,7 +176,7 @@ 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, binaryKeyPrefix: string, bucket: string, secretIdEnvName: string, secretKeyEnvName: string }} Resolved upload configuration. + * @returns {{ environment: 'test' | 'production', channel: 'nightly' | 'latest', 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) { diff --git a/apps/desktop/scripts/desktop-ci-version.mjs b/apps/desktop/scripts/desktop-ci-version.mjs index 294d00ef3b..8fef629287 100644 --- a/apps/desktop/scripts/desktop-ci-version.mjs +++ b/apps/desktop/scripts/desktop-ci-version.mjs @@ -9,7 +9,11 @@ import { desktopBuildVersionPrefix, validateDesktopBuildVersion } from './deskto * @returns {string} Validated build version. */ export function desktopCiVersion(environment, productVersion, now = new Date()) { - const requested = environment.PACKAGE_BUILD_VERSION?.trim() + const mode = environment.PACKAGE_VERSION_MODE + const selected = mode === 'default' ? (environment.DSH_PACKAGING_PRESET === 'production' ? 'product' : 'auto') : mode + if (selected !== undefined && !['product', 'auto', 'manual'].includes(selected)) throw new Error('Invalid version mode') + const requested = selected === 'product' ? productVersion : selected === 'auto' ? 'auto' : environment.PACKAGE_BUILD_VERSION?.trim() + if (selected === 'manual' && (!requested || requested === 'auto')) throw new Error('Manual version mode requires an explicit version') const date = now.toISOString().slice(0, 10).replaceAll('-', '') const example = `${desktopBuildVersionPrefix(productVersion)}${date}.1` if (!requested) throw new Error(`Build version is required: use auto, ${productVersion}, or e.g. ${example}`) @@ -20,8 +24,10 @@ export function desktopCiVersion(environment, productVersion, now = new Date()) } candidate = `${desktopBuildVersionPrefix(productVersion)}${date}.${environment.GITHUB_RUN_NUMBER}.${environment.GITHUB_RUN_ATTEMPT}` } + if (environment.DSH_DESKTOP_UPDATE_CHANNEL === 'latest' && candidate.includes('-')) throw new Error('Latest channel requires a stable version; select product or manual stable version') try { - return validateDesktopBuildVersion(candidate, productVersion) + const resolved = validateDesktopBuildVersion(candidate, productVersion) + return resolved } catch { throw new Error(`Invalid build version for this checkout: use auto, ${productVersion}, or e.g. ${example}`) } diff --git a/apps/desktop/scripts/desktop-package-environment.mjs b/apps/desktop/scripts/desktop-package-environment.mjs index 89540b7363..8116dea245 100644 --- a/apps/desktop/scripts/desktop-package-environment.mjs +++ b/apps/desktop/scripts/desktop-package-environment.mjs @@ -77,6 +77,7 @@ export function loadDesktopPackageEnvironment(platform, environment = process.en return { ...Object.fromEntries(Object.entries(environment).filter(([name]) => !AMBIENT_RELEASE_SETTING.test(name))), ...settings, + ...(environment.DSH_DESKTOP_UPDATE_CHANNEL === undefined ? {} : { DSH_DESKTOP_UPDATE_CHANNEL: environment.DSH_DESKTOP_UPDATE_CHANNEL }), ...(preset === undefined ? {} : { DSH_PACKAGING_PRESET: preset }), } } diff --git a/apps/desktop/scripts/desktop-publish-route.ts b/apps/desktop/scripts/desktop-publish-route.ts index b469ca53d9..46c869c4be 100644 --- a/apps/desktop/scripts/desktop-publish-route.ts +++ b/apps/desktop/scripts/desktop-publish-route.ts @@ -8,7 +8,9 @@ const runSchema = z.object({ repository: z.object({ full_name: z.string() }), }) const requestSchema = z.object({ source: z.object({ - repository: z.string(), runId: z.string(), attempt: z.string(), commit: z.string(), + repository: z.string(), runId: z.string(), attempt: z.string(), commit: z.string().regex(/^[a-f0-9]{40}$/u), + workflowCommit: z.string(), sourceRef: z.string().regex(/^(?:master|dsh-v[0-9][0-9A-Za-z.-]*)$/u), + channel: z.enum(['nightly', 'latest']), runnerName: z.string(), target: z.enum(['mac-arm64', 'win-x64']), preset: z.enum(['test', 'production']), publishRequested: z.literal(true), qualification: z.enum(['signed', 'notarized']), }) }) @@ -20,6 +22,8 @@ export interface DesktopPublishRoute { readonly runId: string readonly attempt: string readonly commit: string + readonly sourceRef: string + readonly channel: 'nightly' | 'latest' readonly runnerName: string readonly runnerLabel: string readonly target: 'mac-arm64' | 'win-x64' @@ -44,7 +48,8 @@ export function resolveDesktopPublishRoute( const routes = routesSchema.parse(routesValue) const workflow = source.target === 'win-x64' ? '.github/workflows/windows-package.yml' : '.github/workflows/macos-package.yml' if (run.repository.full_name !== repository || source.repository !== repository || run.path !== workflow - || source.runId !== String(run.id) || source.attempt !== String(run.run_attempt) || source.commit !== run.head_sha + || source.runId !== String(run.id) || source.attempt !== String(run.run_attempt) + || source.workflowCommit !== run.head_sha || source.qualification !== (source.target === 'win-x64' ? 'signed' : 'notarized')) { throw new Error('desktop publish: request does not match the successful packaging attempt') } @@ -55,6 +60,7 @@ export function resolveDesktopPublishRoute( } const matches = jobs.filter(job => job.runner_name === source.runnerName && job.conclusion === 'success') if (matches.length !== 1) throw new Error('desktop publish: original job does not establish a single successful packaging runner') - return { runId: source.runId, attempt: source.attempt, commit: source.commit, runnerName: source.runnerName, + return { runId: source.runId, attempt: source.attempt, commit: source.commit, sourceRef: source.sourceRef, channel: source.channel, + runnerName: source.runnerName, runnerLabel: route.label, target: source.target, preset: source.preset } } diff --git a/apps/desktop/scripts/desktop-release-store.ts b/apps/desktop/scripts/desktop-release-store.ts index 379ac56bb5..904d65af07 100644 --- a/apps/desktop/scripts/desktop-release-store.ts +++ b/apps/desktop/scripts/desktop-release-store.ts @@ -12,6 +12,9 @@ const sourceSchema = z.strictObject({ runId: positiveId, attempt: positiveId, commit: z.string().regex(/^[a-f0-9]{40}$/u), + workflowCommit: z.string().regex(/^[a-f0-9]{40}$/u), + sourceRef: z.string().regex(/^(?:master|dsh-v[0-9][0-9A-Za-z.-]*)$/u), + channel: z.enum(['nightly', 'latest']), runnerName: z.string().min(1), // Storage accepts every Desktop target; CI routing separately limits currently provisioned platforms. target: z.enum(['mac-arm64', 'mac-x64', 'win-x64']), diff --git a/apps/desktop/scripts/desktop-upload-plan.ts b/apps/desktop/scripts/desktop-upload-plan.ts index a9d61d1ef5..aa05966c4a 100644 --- a/apps/desktop/scripts/desktop-upload-plan.ts +++ b/apps/desktop/scripts/desktop-upload-plan.ts @@ -5,7 +5,6 @@ import { createReadStream } from 'node:fs' import { readFile, stat } from 'node:fs/promises' import { basename, join, resolve } from 'node:path' import { dump, load } from 'js-yaml' -import { prerelease } from 'semver' import type { DesktopPackageTargetName } from './package-target.ts' import { desktopBuildRecordFilename, @@ -213,12 +212,14 @@ export async function createDesktopUploadPlan( } if (buildRecord.schemaVersion !== 1 || buildRecord.target !== targetName + || (buildRecord.channel ?? 'nightly') !== update.channel || buildRecord.environment !== update.environment || buildRecord.publicUrl !== update.publicUrl) { throw new Error(`desktop upload: ${targetName} package completion record for ${buildVersion} does not match the ${update.environment} update destination`) } - const metadataFilename = desktopUpdateMetadataFilename(buildVersion, target.platform) + if (update.channel === 'latest' && buildVersion.includes('-')) throw new Error('desktop upload: latest requires a stable version') + const metadataFilename = desktopUpdateMetadataFilename(buildVersion, target.platform, update.channel) const metadataPath = join(artifactsRoot, metadataFilename) let metadataValue: unknown try { @@ -276,10 +277,6 @@ export async function createDesktopUploadPlan( contents: dump(published), } artifacts.push(channelArtifact) - if (prerelease(buildVersion) === null) { - const stableFilename = metadataFilename.replace('nightly', 'latest') - artifacts.push({ ...channelArtifact, filename: stableFilename, key: `${update.keyPrefix}/${stableFilename}` }) - } const latestFilename = `dsh-latest-${target.platform === 'darwin' ? 'macos' : 'windows'}-${target.arch}.${target.platform === 'darwin' ? 'dmg' : 'exe'}` const latestKey = `desktop/${latestFilename}` return { diff --git a/apps/desktop/scripts/electron-builder-config.mjs b/apps/desktop/scripts/electron-builder-config.mjs index 2fa3759ed3..4b9cd27a8d 100644 --- a/apps/desktop/scripts/electron-builder-config.mjs +++ b/apps/desktop/scripts/electron-builder-config.mjs @@ -101,12 +101,14 @@ export function createElectronBuilderConfig( // the artifact names, the update feed, and the installed app.getVersion() the updater compares against. const productVersion = JSON.parse(readFileSync(fileURLToPath(new URL('../package.json', import.meta.url)), 'utf8')).version const buildVersion = resolveDesktopBuildVersion(env, productVersion) + if (update?.channel === 'latest' && buildVersion.includes('-')) throw new Error('desktop package: latest requires a stable version') const packaged = resolveDesktopBuildCommit(env) return { appId, protocols: [{ name: 'DeepSeek Harness', schemes: ['dsh'] }], extraMetadata: { dshDesktopAppId: appId, + dshDesktopUpdateChannel: update?.channel ?? 'nightly', ...(policy === undefined ? {} : { dshMandatoryUpdatePolicy: policy }), ...buildVersion === productVersion ? {} : { version: buildVersion }, ...packaged === undefined ? {} : { dshBuildCommit: packaged.commit, dshBuildDirty: packaged.dirty }, @@ -259,6 +261,6 @@ export function createElectronBuilderConfig( differentialPackage: true, }, detectUpdateChannel: false, - publish: update === undefined ? null : [{ provider: 'generic', url: update.publicUrl, channel: 'nightly' }], + publish: update === undefined ? null : [{ provider: 'generic', url: update.publicUrl, channel: update.channel }], } } diff --git a/apps/desktop/scripts/macos-app-update-config.d.mts b/apps/desktop/scripts/macos-app-update-config.d.mts index 624ab0d341..ce18266c48 100644 --- a/apps/desktop/scripts/macos-app-update-config.d.mts +++ b/apps/desktop/scripts/macos-app-update-config.d.mts @@ -1,13 +1,14 @@ /** Resolved fields required to embed a macOS updater feed. */ export interface MacOSAppUpdateFeed { readonly publicUrl: string + readonly channel?: 'nightly' | 'latest' } /** Packaged electron-updater configuration for macOS. */ export interface MacOSAppUpdateConfig { readonly provider: 'generic' readonly url: string - readonly channel: 'nightly' + readonly channel: 'nightly' | 'latest' readonly updaterCacheDirName: string } diff --git a/apps/desktop/scripts/macos-app-update-config.mjs b/apps/desktop/scripts/macos-app-update-config.mjs index 3b9e0019c6..d6b4132c71 100644 --- a/apps/desktop/scripts/macos-app-update-config.mjs +++ b/apps/desktop/scripts/macos-app-update-config.mjs @@ -5,7 +5,7 @@ import { join } from 'node:path' import { dump, load } from 'js-yaml' const CONFIG_FILENAME = 'app-update.yml' -const CHANNEL = 'nightly' +import { resolveDesktopUpdateChannel } from './desktop-auto-update-environment.mjs' function object(value, label) { if (typeof value !== 'object' || value === null || Array.isArray(value)) { @@ -24,30 +24,30 @@ function nonEmptyString(value, label) { /** * Resolve the one generic macOS feed from the final electron-builder configuration. * @param {unknown} publish - Final electron-builder publish setting. - * @returns {{ publicUrl: string }} Resolved feed used by the packaged App. + * @returns {{ publicUrl: string, channel?: 'nightly' | 'latest' }} Resolved feed used by the packaged App. */ export function resolveMacOSAppUpdateFeed(publish) { if (!Array.isArray(publish) || publish.length !== 1) { throw new Error('desktop macOS update config: publish must contain exactly one provider') } const provider = object(publish[0], 'publish provider') - if (provider.provider !== 'generic' || provider.channel !== CHANNEL) { - throw new Error('desktop macOS update config: publish provider must be generic Nightly') + if (provider.provider !== 'generic' || !['nightly', 'latest'].includes(provider.channel)) { + throw new Error('desktop macOS update config: publish provider must be generic with nightly or latest channel') } - return { publicUrl: nonEmptyString(provider.url, 'publish provider URL') } + return { publicUrl: nonEmptyString(provider.url, 'publish provider URL'), channel: resolveDesktopUpdateChannel({ DSH_DESKTOP_UPDATE_CHANNEL: provider.channel }) } } /** * Create the electron-updater configuration embedded before code signing. - * @param {{ publicUrl: string }} update - Resolved update feed. + * @param {{ publicUrl: string, channel?: 'nightly' | 'latest' }} update - Resolved update feed. * @param {string} updaterCacheDirName - electron-builder application cache directory. - * @returns {{ provider: 'generic', url: string, channel: 'nightly', updaterCacheDirName: string }} Packaged updater fields. + * @returns {{ provider: 'generic', url: string, channel: 'nightly' | 'latest', updaterCacheDirName: string }} Packaged updater fields. */ export function createMacOSAppUpdateConfig(update, updaterCacheDirName) { return { provider: 'generic', url: nonEmptyString(update.publicUrl, 'public URL'), - channel: CHANNEL, + channel: resolveDesktopUpdateChannel({ DSH_DESKTOP_UPDATE_CHANNEL: update.channel }), updaterCacheDirName: nonEmptyString(updaterCacheDirName, 'updater cache directory'), } } @@ -55,7 +55,7 @@ export function createMacOSAppUpdateConfig(update, updaterCacheDirName) { /** * Write the updater configuration into an assembled App before signing. * @param {string} resourcesDir - App Contents/Resources directory. - * @param {{ publicUrl: string }} update - Resolved update feed. + * @param {{ publicUrl: string, channel?: 'nightly' | 'latest' }} update - Resolved update feed. * @param {string} updaterCacheDirName - electron-builder application cache directory. * @returns {Promise} Resolves after the configuration is durable. */ @@ -67,7 +67,7 @@ export async function writeMacOSAppUpdateConfig(resourcesDir, update, updaterCac /** * Verify the updater configuration inside an assembled macOS App. * @param {string} appPath - Application bundle path. - * @param {{ publicUrl: string }} update - Expected update feed. + * @param {{ publicUrl: string, channel?: 'nightly' | 'latest' }} update - Expected update feed. * @param {string | undefined} updaterCacheDirName - Exact cache directory when known. * @returns {Promise} Resolves when the packaged configuration matches the release destination. */ @@ -81,7 +81,7 @@ export async function verifyMacOSAppUpdateConfig(appPath, update, updaterCacheDi throw new Error(`desktop macOS update config: cannot read ${path}: ${error instanceof Error ? error.message : String(error)}`) } const config = object(parsed, CONFIG_FILENAME) - if (config.provider !== 'generic' || config.url !== update.publicUrl || config.channel !== CHANNEL) { + if (config.provider !== 'generic' || config.url !== update.publicUrl || config.channel !== resolveDesktopUpdateChannel({ DSH_DESKTOP_UPDATE_CHANNEL: update.channel })) { throw new Error(`desktop macOS update config: ${path} does not match ${update.publicUrl}`) } const actualCacheDirName = nonEmptyString(config.updaterCacheDirName, `${CONFIG_FILENAME}.updaterCacheDirName`) diff --git a/apps/desktop/scripts/package-macos.ts b/apps/desktop/scripts/package-macos.ts index c1a2cb5c71..4ba0fd12ca 100644 --- a/apps/desktop/scripts/package-macos.ts +++ b/apps/desktop/scripts/package-macos.ts @@ -115,7 +115,7 @@ export async function packageMacOSArtifacts( [dmgOutput, `${base}.dmg`], [zipOutput, `${base}.zip`], [zipOutput, `${base}.zip.blockmap`], - [zipOutput, desktopUpdateMetadataFilename(version, 'darwin')], + [zipOutput, desktopUpdateMetadataFilename(version, 'darwin', update.channel)], ] as const for (const [output, filename] of artifacts) { const file = join(output, filename) diff --git a/apps/desktop/scripts/package-target.ts b/apps/desktop/scripts/package-target.ts index 99b1a4c5af..be2f577e1b 100644 --- a/apps/desktop/scripts/package-target.ts +++ b/apps/desktop/scripts/package-target.ts @@ -153,6 +153,7 @@ function writeReleaseRecord( target: target.name, version: buildVersion, environment: update.environment, + channel: update.channel, publicUrl: update.publicUrl, // Upload reads this to tag the commit a production release was packaged from. ...packaged === undefined ? {} : { commit: packaged.commit, dirty: packaged.dirty }, diff --git a/apps/desktop/scripts/prepare-macos-ci.mjs b/apps/desktop/scripts/prepare-macos-ci.mjs index 6974610a8c..5b51052cb6 100644 --- a/apps/desktop/scripts/prepare-macos-ci.mjs +++ b/apps/desktop/scripts/prepare-macos-ci.mjs @@ -1,6 +1,7 @@ /** Validate machine-owned macOS signing settings and record a manual Actions build version. */ import { appendFileSync, readFileSync } from 'node:fs' import { resolve } from 'node:path' +import { execFileSync } from 'node:child_process' import { desktopCiVersion } from './desktop-ci-version.mjs' import { loadDesktopPackageEnvironment, validateDesktopPackageEnvironment } from './desktop-package-environment.mjs' @@ -21,5 +22,5 @@ for (const [name, value] of Object.entries(environment)) { validateDesktopPackageEnvironment(environment, { platform: 'darwin', arch: 'arm64' }, { skipNotarization }) if (!process.env.GITHUB_OUTPUT || !process.env.GITHUB_STEP_SUMMARY) throw new Error('Run this adapter from GitHub Actions') appendFileSync(process.env.GITHUB_OUTPUT, `version=${version}\nartifacts=${skipNotarization ? 'unnotarized-artifacts' : 'artifacts'}\nmode=${skipNotarization ? 'unnotarized-debug' : 'signed'}\n`) -appendFileSync(process.env.GITHUB_STEP_SUMMARY, `macOS arm64 signed installer\n\nVersion: ${version}\n\nCommit: ${process.env.GITHUB_SHA}\n\nPreset: ${process.env.DSH_PACKAGING_PRESET}\n`) +appendFileSync(process.env.GITHUB_STEP_SUMMARY, `macOS arm64 signed installer\n\nVersion: ${version}\n\nCommit: ${execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim()}\n\nChannel: ${process.env.DSH_DESKTOP_UPDATE_CHANNEL}\n\nSource: ${process.env.PACKAGE_SOURCE_REF}\n\nPreset: ${process.env.DSH_PACKAGING_PRESET}\n`) if (skipNotarization) appendFileSync(process.env.GITHUB_STEP_SUMMARY, '\nWARNING: signed but NOT notarized or stapled. Internal debugging only; Gatekeeper may block downloaded copies. Not qualified for distribution or update publishing.\n') diff --git a/apps/desktop/scripts/prepare-windows-ci.mjs b/apps/desktop/scripts/prepare-windows-ci.mjs index f99e313180..307d9b0fcf 100644 --- a/apps/desktop/scripts/prepare-windows-ci.mjs +++ b/apps/desktop/scripts/prepare-windows-ci.mjs @@ -1,6 +1,7 @@ /** Resolve a manual Windows build version and validate the selected machine preset. */ import { appendFileSync, readFileSync } from 'node:fs' import { resolve } from 'node:path' +import { execFileSync } from 'node:child_process' import { desktopCiVersion as windowsCiVersion } from './desktop-ci-version.mjs' export { desktopCiVersion as windowsCiVersion } from './desktop-ci-version.mjs' import { loadDesktopPackageEnvironment, validateDesktopPackageEnvironment } from './desktop-package-environment.mjs' @@ -24,5 +25,5 @@ if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.fi validateDesktopPackageEnvironment(environment, { platform: 'win32', arch: 'x64' }, { unsigned: mode === 'unsigned' }) if (!process.env.GITHUB_OUTPUT || !process.env.GITHUB_STEP_SUMMARY) throw new Error('Run this adapter from GitHub Actions') appendFileSync(process.env.GITHUB_OUTPUT, `version=${version}\n`) - appendFileSync(process.env.GITHUB_STEP_SUMMARY, `Windows x64 ${mode} installer\n\nVersion: ${version}\n\nCommit: ${process.env.GITHUB_SHA}\n\nPreset: ${process.env.DSH_PACKAGING_PRESET}\n`) + appendFileSync(process.env.GITHUB_STEP_SUMMARY, `Windows x64 ${mode} installer\n\nVersion: ${version}\n\nCommit: ${execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim()}\n\nChannel: ${process.env.DSH_DESKTOP_UPDATE_CHANNEL}\n\nSource: ${process.env.PACKAGE_SOURCE_REF}\n\nPreset: ${process.env.DSH_PACKAGING_PRESET}\n`) } diff --git a/apps/desktop/scripts/publish-desktop-ci.ts b/apps/desktop/scripts/publish-desktop-ci.ts index eff6317157..0abc317463 100644 --- a/apps/desktop/scripts/publish-desktop-ci.ts +++ b/apps/desktop/scripts/publish-desktop-ci.ts @@ -1,4 +1,5 @@ /** Publish a retained package selected by the routing job; --check never reads secrets or contacts COS. */ +import { createHash } from 'node:crypto' import { appendFile } from 'node:fs/promises' import { dirname, join, resolve } from 'node:path' import { z } from 'zod' @@ -14,6 +15,7 @@ import { desktopPublicationFailure } from './desktop-publication-failure.ts' import { publishDesktopTag } from './desktop-publish-tag.ts' const routeSchema = z.object({ runId: z.string(), attempt: z.string(), commit: z.string(), runnerName: z.string(), + sourceRef: z.string(), channel: z.enum(['nightly', 'latest']), target: z.enum(['mac-arm64', 'win-x64']), preset: z.enum(['test', 'production']) }) /** @@ -29,7 +31,8 @@ export async function prepareDesktopPublication(env: NodeJS.ProcessEnv, verifySi const manifest = await verifyRetainedDesktopRelease(root, route.runId, route.attempt) const source = manifest.source if (source.repository !== env.GITHUB_REPOSITORY || source.runnerName !== route.runnerName || source.target !== route.target - || source.commit !== route.commit || source.preset !== route.preset || !source.publishRequested) { + || source.channel !== route.channel || source.sourceRef !== route.sourceRef || source.commit !== route.commit + || source.preset !== route.preset || !source.publishRequested) { throw new Error('desktop publish: local package differs from the routed request') } if (source.qualification !== (source.target === 'win-x64' ? 'signed' : 'notarized')) throw new Error('desktop publish: debugging artifacts cannot be published') @@ -40,10 +43,12 @@ export async function prepareDesktopPublication(env: NodeJS.ProcessEnv, verifySi DOWNLOAD_TEST_RELEASE_ID: source.testReleaseId ?? '', DOWNLOAD_TEST_COS_BUCKET: config.bucket } : { DSH_DESKTOP_AUTO_UPDATE_ENV: 'production', DOWNLOAD_PROD_COS_BUCKET: config.bucket } const plan = await createDesktopUploadPlan(source.target, { artifactsRoot: directory, - packagedProductVersion: source.productVersion, environment }) + packagedProductVersion: source.productVersion, environment: { ...environment, DSH_DESKTOP_UPDATE_CHANNEL: source.channel } }) if (plan.version !== source.version || plan.commit !== source.commit || plan.dirty !== false) throw new Error('desktop publish: completion record differs from retained source or has uncommitted changes') await verifySignatures(directory, manifest, config) - return { route, manifest, config, plan, root } + const approval = createHash('sha256').update(JSON.stringify({ manifest, plan })).digest('hex') + if (env.DESKTOP_APPROVED_PACKAGE && env.DESKTOP_APPROVED_PACKAGE !== approval) throw new Error('desktop publish: package or destination changed after approval') + return { route, manifest, config, plan, root, approval } } if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.filename) { @@ -52,8 +57,13 @@ if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.fi const mode = process.argv[2] if (process.argv.length !== 3 || (mode !== '--check' && mode !== '--publish')) throw new Error('Usage: publish-desktop-ci.ts --check|--publish') const env = process.env - const { route, manifest, config, plan, root } = await prepareDesktopPublication(env) + const { route, manifest, config, plan, root, approval } = await prepareDesktopPublication(env) + if (mode === '--publish' && !env.DESKTOP_APPROVED_PACKAGE) throw new Error('desktop publish: approved package digest is required') if (mode === '--check') { + if (env.GITHUB_OUTPUT) await appendFile(env.GITHUB_OUTPUT, `approval=${approval}\nlabel=${plan.version} → ${manifest.source.channel} / ${plan.target} / ${plan.environment}\n`) + if (env.GITHUB_STEP_SUMMARY) await appendFile(env.GITHUB_STEP_SUMMARY, + `\n## Confirm Desktop publication\n\nVersion: **${plan.version}**; channel: **${manifest.source.channel}**; environment: **${plan.environment}**; platform: **${plan.target}**\n\nSource: ${manifest.source.sourceRef} @ ${route.commit}\n\nPackage: ${route.runId}, attempt ${route.attempt}\n\nTag: ${plan.environment === 'production' ? `desktop-v${plan.version}` : 'none (test)'}\n\nApproved package SHA-256: ${approval}\n\n` + + plan.artifacts.map(file => `- ${file.channelMetadata ? 'Feed' : 'Artifact'}: ${file.key}`).join('\n') + '\n') if (env.GITHUB_OUTPUT && config.githubApp) await appendFile(env.GITHUB_OUTPUT, `app-client-id=${config.githubApp.clientId}\napp-key-file=${config.githubApp.privateKeyFile}\n`) process.stdout.write(`desktop publish: verified ${plan.target} ${plan.version}; no credentials read or COS requests made\n`) @@ -61,7 +71,7 @@ if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.fi if (plan.environment === 'production' && !env.DESKTOP_TAG_TOKEN) throw new Error('desktop publish: GitHub App token is required before production upload') phase = 'upload credential loading' const credentials = await readDesktopUploadCredentials(config) - const destination = `${plan.bucket}/${plan.publicUrl}` + const destination = `${plan.bucket}/${plan.publicUrl}${manifest.source.channel}` const recordsRoot = join(dirname(root), 'upload-records') phase = 'channel lock and publication journal acquisition' await withDesktopChannelLock(join(dirname(root), 'publish-locks'), destination, async () => { diff --git a/apps/desktop/scripts/resolve-desktop-publish-ci.ts b/apps/desktop/scripts/resolve-desktop-publish-ci.ts index e08b01f606..cd1326dceb 100644 --- a/apps/desktop/scripts/resolve-desktop-publish-ci.ts +++ b/apps/desktop/scripts/resolve-desktop-publish-ci.ts @@ -35,12 +35,21 @@ if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.fi const jobs = z.object({ jobs: z.array(z.unknown()) }).parse(api(`${base}/attempts/${attempt}/jobs?per_page=100`)) const route = resolveDesktopPublishRoute(repository, api(`${base}/attempts/${attempt}`), request, jobs.jobs, JSON.parse(env.DESKTOP_PUBLISH_RUNNERS || '{}')) - if (route.preset === 'production') { + const source = z.object({ sha: z.string() }).parse(api(`repos/${repository}/commits/${route.commit}`)) + if (source.sha !== route.commit) throw new Error('desktop publish: source commit not found') + if (route.sourceRef === 'master') { + const comparison = z.object({ status: z.string() }).parse(api(`repos/${repository}/compare/${route.commit}...master`)) + if (!['ahead', 'identical'].includes(comparison.status)) throw new Error('desktop publish: source is not on master') + } else { + const tagged = z.object({ sha: z.string() }).parse(api(`repos/${repository}/commits/${route.sourceRef}`)) + if (tagged.sha !== route.commit) throw new Error('desktop publish: source tag changed') + } + { const environment = z.object({ protection_rules: z.array(z.object({ type: z.string(), reviewers: z.array(z.unknown()).optional() })) }) - .parse(api(`repos/${repository}/environments/desktop-production`)) + .parse(api(`repos/${repository}/environments/desktop-${route.preset}`)) if (!environment.protection_rules.some(rule => rule.type === 'required_reviewers' && (rule.reviewers?.length ?? 0) > 0)) { - throw new Error('desktop publish: desktop-production requires configured reviewers') + throw new Error(`desktop publish: desktop-${route.preset} requires configured reviewers`) } } await appendFile(env.GITHUB_OUTPUT, `publish=true\nroute=${JSON.stringify(route)}\nrunner=${route.runnerLabel}\npreset=${route.preset}\n`) diff --git a/apps/desktop/scripts/retain-desktop-ci.ts b/apps/desktop/scripts/retain-desktop-ci.ts index feee5247cd..9fddc8d995 100644 --- a/apps/desktop/scripts/retain-desktop-ci.ts +++ b/apps/desktop/scripts/retain-desktop-ci.ts @@ -2,7 +2,7 @@ import { appendFile, readFile } from 'node:fs/promises' import { dirname, isAbsolute, join, resolve } from 'node:path' import { desktopTargetBuildPaths, resolveDesktopBuildTarget } from './desktop-build-paths.mjs' -import { desktopBuildRecordFilename, desktopUpdateMetadataFilename } from './desktop-auto-update-environment.mjs' +import { desktopBuildRecordFilename, desktopUpdateMetadataFilename, resolveDesktopUpdateChannel } from './desktop-auto-update-environment.mjs' import { loadDesktopPackageEnvironment } from './desktop-package-environment.mjs' import { retainDesktopRelease, type DesktopRetainedSource } from './desktop-release-store.ts' import { validateDesktopBuildVersion } from './desktop-build-version.mjs' @@ -51,7 +51,9 @@ export async function retainDesktopCi(env: NodeJS.ProcessEnv, appRoot = resolve( const version = validateDesktopBuildVersion(required(env, 'PACKAGE_VERSION'), product.version) const source: DesktopRetainedSource = { repository: required(env, 'GITHUB_REPOSITORY'), runId: required(env, 'GITHUB_RUN_ID'), - attempt: required(env, 'GITHUB_RUN_ATTEMPT'), commit: required(env, 'GITHUB_SHA'), + attempt: required(env, 'GITHUB_RUN_ATTEMPT'), commit: required(env, 'PACKAGE_SOURCE_SHA'), + workflowCommit: required(env, 'GITHUB_SHA'), sourceRef: required(env, 'PACKAGE_SOURCE_REF'), + channel: resolveDesktopUpdateChannel(settings), runnerName: required(env, 'RUNNER_NAME'), target, preset, productVersion: product.version, version, testReleaseId: preset === 'test' ? (settings.DOWNLOAD_TEST_RELEASE_ID || null) : null, publishRequested: publish, qualification, @@ -65,7 +67,7 @@ export async function retainDesktopCi(env: NodeJS.ProcessEnv, appRoot = resolve( const debugSuffix = qualification === 'unnotarized' ? '-unnotarized' : qualification === 'unsigned' ? '-unsigned' : '' const names = windows ? [`${base}${debugSuffix}.exe`] : [`${base}${debugSuffix}.dmg`, `${base}${debugSuffix}.zip`] if (qualification === 'signed' || qualification === 'notarized') { - names.push(`${base}.${windows ? 'exe' : 'zip'}.blockmap`, desktopBuildRecordFilename(target), desktopUpdateMetadataFilename(version, platform)) + names.push(`${base}.${windows ? 'exe' : 'zip'}.blockmap`, desktopBuildRecordFilename(target), desktopUpdateMetadataFilename(version, platform, source.channel)) } const retained = await retainDesktopRelease(desktopReleasesRoot(env), source, names.map(name => join(artifacts, name))) if (env.GITHUB_OUTPUT) await appendFile(env.GITHUB_OUTPUT, `directory=${retained}\n`) diff --git a/apps/desktop/scripts/rollback-desktop-channel.ts b/apps/desktop/scripts/rollback-desktop-channel.ts index b8081da077..fbefbe4f8b 100644 --- a/apps/desktop/scripts/rollback-desktop-channel.ts +++ b/apps/desktop/scripts/rollback-desktop-channel.ts @@ -25,9 +25,9 @@ if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.fi .parse(JSON.parse(await readFile(join(values.record, 'operation.json'), 'utf8'))) z.object({ status: z.enum(['complete', 'published-tag-pending']) }) .parse(JSON.parse(await readFile(join(values.record, 'result.json'), 'utf8'))) - const { plan, config, root } = await prepareDesktopPublication(process.env) + const { plan, config, root, manifest } = await prepareDesktopPublication(process.env) const route = z.object({ runId: z.string(), attempt: z.string() }).parse(JSON.parse(process.env.DESKTOP_PUBLISH_ROUTE || '{}')) - const destination = `${plan.bucket}/${plan.publicUrl}` + const destination = `${plan.bucket}/${plan.publicUrl}${manifest.source.channel}` if (operation.runId !== route.runId || operation.attempt !== route.attempt || operation.repository !== process.env.GITHUB_REPOSITORY || operation.destination !== destination) { throw new Error('desktop rollback: successful publication record does not match the selected package') diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 1202670a3e..2afdfd7194 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -33,6 +33,7 @@ import { DESKTOP_IPC, SCHEME, assertDesktopSender, type DesktopUpdateState } fro import { readDeviceInfo } from './device-info.ts' import { desktopUpdateReadyConfirmation, formatDesktopMessage, resolveDesktopLocale, resolveDesktopStartupLocale } from './locale.ts' import { claimDesktopSingleInstance } from './single-instance.ts' +import { desktopUpdateChannel } from './update-channel.ts' import { DesktopUpdateCoordinator } from './update-coordinator.ts' import { DesktopCommandManager } from './command-management.ts' import { serveWebDocument, authenticateWebHost, forwardWebRequest } from './web-document.ts' @@ -583,6 +584,8 @@ async function main(): Promise { return startup } + const manifest: unknown = JSON.parse(await readFile(join(app.getAppPath(), 'package.json'), 'utf8')) + const updateChannel = desktopUpdateChannel(manifest) const updates = new DesktopUpdateCoordinator( publishUpdate, async () => { @@ -636,7 +639,7 @@ async function main(): Promise { }, undefined, undefined, undefined, (success, reason) => { void track('desktop_upgrade_download_result', { is_success: success, ...reason === undefined ? {} : { error_reason: reason } }) }, - + updateChannel, ) const updateSchedule = new DesktopUpdateSchedule(updates, resolveDesktopUpdateScheduleConfig(process.env)) @@ -1285,7 +1288,6 @@ async function main(): Promise { }) mainWindow = createMainWindow() - const manifest: unknown = JSON.parse(await readFile(join(app.getAppPath(), 'package.json'), 'utf8')) if (typeof manifest !== 'object' || manifest === null) throw new Error('desktop policy: invalid application manifest') const developmentPolicy = app.isPackaged ? undefined : process.env.DSH_DESKTOP_MANDATORY_UPDATE_CONFIG const policyInput: unknown = app.isPackaged @@ -1302,6 +1304,7 @@ async function main(): Promise { let wasBlocking = false mandatoryPolicy = new DesktopMandatoryUpdatePolicy(policyConfig, { platform: process.platform as 'win32' | 'darwin', arch: process.arch as 'x64' | 'arm64', + channel: updateChannel, bundledDshVersion: app.isPackaged ? readDesktopRuntime(resources.dsh).release.version : app.getVersion(), }, (state) => { if (state.error !== 'authentication-required') policyAuthenticationQueued = false diff --git a/apps/desktop/src/mandatory-update-policy.ts b/apps/desktop/src/mandatory-update-policy.ts index 27fd51bd44..92168a7ea3 100644 --- a/apps/desktop/src/mandatory-update-policy.ts +++ b/apps/desktop/src/mandatory-update-policy.ts @@ -8,6 +8,7 @@ export interface DesktopPolicyIdentity { readonly platform: 'win32' | 'darwin' readonly bundledDshVersion: string readonly arch: 'x64' | 'arm64' + readonly channel: 'nightly' | 'latest' } /** Validated deployment choices; test authentication is explicitly enabled, never inferred from a redirect. */ @@ -158,7 +159,7 @@ export class DesktopMandatoryUpdatePolicy { private requestHeaders(): Record { return { ...platformClientHeaders(this.identity.platform, this.client()), - 'x-client-arch': this.identity.arch, 'x-client-update-channel': 'nightly', + 'x-client-arch': this.identity.arch, 'x-client-update-channel': this.identity.channel, 'x-client-bundled-dsh-version': this.identity.bundledDshVersion, } } diff --git a/apps/desktop/src/update-channel.ts b/apps/desktop/src/update-channel.ts new file mode 100644 index 0000000000..ac767ea642 --- /dev/null +++ b/apps/desktop/src/update-channel.ts @@ -0,0 +1,17 @@ +/** Resolve the update channel sealed into the installed application manifest. */ + +/** Installer-owned channel, independent of release version and upload environment. */ +export type DesktopUpdateChannel = 'nightly' | 'latest' + +/** + * Read the packaged channel; older manifests retain their existing nightly behavior. + * @param manifest Parsed application manifest. + * @returns Validated channel. Invalid explicit values reject instead of changing feeds. + */ +export function desktopUpdateChannel(manifest: unknown): DesktopUpdateChannel { + if (typeof manifest !== 'object' || manifest === null) throw new Error('desktop update: invalid application manifest') + if (!('dshDesktopUpdateChannel' in manifest)) return 'nightly' + const channel = manifest.dshDesktopUpdateChannel + if (channel !== 'nightly' && channel !== 'latest') throw new Error('desktop update: invalid packaged channel') + return channel +} diff --git a/apps/desktop/src/update-coordinator.ts b/apps/desktop/src/update-coordinator.ts index 8d6eb808e7..3b83cea08f 100644 --- a/apps/desktop/src/update-coordinator.ts +++ b/apps/desktop/src/update-coordinator.ts @@ -45,6 +45,7 @@ export class DesktopUpdateCoordinator { * @param updater - Process-owned Electron updater, replaceable at the network/platform test boundary. * @param enabled - Whether this process has a packaged update source. * @param currentVersion - Actual installed application version. + * @param channel - Installed package channel; omitted test/local callers retain nightly. * @param downloadResult - Once per completed download attempt, including platform preparation failures. */ constructor( @@ -54,6 +55,7 @@ export class DesktopUpdateCoordinator { private readonly enabled: () => boolean = () => app.isPackaged && existsSync(join(process.resourcesPath, 'app-update.yml')), private readonly currentVersion: () => string = () => app.getVersion(), private readonly downloadResult?: (success: boolean, reason?: string) => void, + private readonly channel: 'nightly' | 'latest' = 'nightly', ) { if (updater === autoUpdater) { // electron-updater omits this internal transport property from its public declarations. @@ -66,8 +68,8 @@ export class DesktopUpdateCoordinator { } this.updater.autoDownload = false this.updater.autoInstallOnAppQuit = false - this.updater.channel = 'nightly' - this.updater.allowPrerelease = true + this.updater.channel = channel + this.updater.allowPrerelease = channel === 'nightly' // Selecting a channel can enable downgrade in electron-updater. this.updater.allowDowngrade = false this.updater.on('download-progress', this.onProgress) @@ -187,6 +189,7 @@ export class DesktopUpdateCoordinator { if (result === null) throw new Error('desktop update: no check result was returned') const version = result.updateInfo.version if (valid(version) === null) throw new Error('desktop update: feed version is invalid') + if (this.channel === 'latest' && version.includes('-')) throw new Error('desktop update: latest feed contains a prerelease') this.candidate = result.isUpdateAvailable && gt(version, this.currentVersion()) ? version : undefined return this.setState(this.candidate === undefined ? { phase: 'idle' } : { phase: 'available', version }) } catch (error) { diff --git a/apps/desktop/tests/desktop-auto-update-environment.spec.ts b/apps/desktop/tests/desktop-auto-update-environment.spec.ts index c33a9384df..9fbe629c89 100644 --- a/apps/desktop/tests/desktop-auto-update-environment.spec.ts +++ b/apps/desktop/tests/desktop-auto-update-environment.spec.ts @@ -40,7 +40,7 @@ describe('desktop auto-update environment', () => { DOWNLOAD_TEST_ORIGIN: 'https://desktop-updates.example.com/', DOWNLOAD_TEST_RELEASE_ID: RELEASE_ID, }, 'darwin', 'arm64')).toEqual({ - environment: 'test', + environment: 'test', channel: 'nightly', target: 'mac-arm64', origin: 'https://desktop-updates.example.com', publicUrl: `https://desktop-updates.example.com/dsh-desk/${RELEASE_ID}/feeds/mac-arm64/`, diff --git a/apps/desktop/tests/desktop-publication-preflight.spec.ts b/apps/desktop/tests/desktop-publication-preflight.spec.ts index 268017a498..bf4d4e8876 100644 --- a/apps/desktop/tests/desktop-publication-preflight.spec.ts +++ b/apps/desktop/tests/desktop-publication-preflight.spec.ts @@ -39,9 +39,9 @@ it.each(['mac-arm64', 'win-x64'] as const)('preflights a complete retained %s bu const env = { DSH_PACKAGING_CONFIG_DIR: configRoot, DSH_PACKAGING_PRESET: 'test', DSH_DESKTOP_TARGET_PLATFORM: target === 'win-x64' ? 'win32' : 'darwin', DSH_DESKTOP_TARGET_ARCH: target === 'win-x64' ? 'x64' : 'arm64', - PACKAGE_VERSION: '1.0.0', PACKAGE_PUBLISH: 'true', PACKAGE_SIGNING: 'signed', PACKAGE_SKIP_NOTARIZATION: 'false', + PACKAGE_SOURCE_REF: 'master', PACKAGE_SOURCE_SHA: 'b'.repeat(40), PACKAGE_VERSION: '1.0.0', PACKAGE_PUBLISH: 'true', PACKAGE_SIGNING: 'signed', PACKAGE_SKIP_NOTARIZATION: 'false', GITHUB_RUN_ID: '10', GITHUB_RUN_ATTEMPT: '2', GITHUB_REPOSITORY: 'owner/repo', GITHUB_SHA: commit, RUNNER_NAME: 'fixture-runner', - DESKTOP_PUBLISH_ROUTE: JSON.stringify({ runId: '10', attempt: '2', commit, runnerName: 'fixture-runner', target, preset: 'test' }), + DESKTOP_PUBLISH_ROUTE: JSON.stringify({ sourceRef: 'master', channel: 'nightly', runId: '10', attempt: '2', commit, runnerName: 'fixture-runner', target, preset: 'test' }), } const retained = await retainDesktopCi(env, app) await rm(app, { recursive: true }) @@ -51,9 +51,10 @@ it.each(['mac-arm64', 'win-x64'] as const)('preflights a complete retained %s bu expect(prepared.plan.version).toBe('1.0.0') expect(prepared.plan.artifacts.every(file => file.path.startsWith(retained))).toBe(true) expect(signatureChecks).toBe(1) + await expect(prepareDesktopPublication({ ...env, DESKTOP_APPROVED_PACKAGE: 'wrong' }, signature)).rejects.toThrow(/changed after approval/u) await expect(prepareDesktopPublication({ ...env, RUNNER_NAME: 'other-machine' }, signature)).rejects.toThrow(/different machine/u) await writeFile(join(retained, `${base}.${extension}`), 'changed') await expect(prepareDesktopPublication(env, signature)).rejects.toThrow(/changed file/u) - expect(signatureChecks).toBe(1) + expect(signatureChecks).toBe(2) } finally { await rm(root, { recursive: true, force: true }) } }) diff --git a/apps/desktop/tests/desktop-publish-route.spec.ts b/apps/desktop/tests/desktop-publish-route.spec.ts index c83aa7d981..0db2cb45d6 100644 --- a/apps/desktop/tests/desktop-publish-route.spec.ts +++ b/apps/desktop/tests/desktop-publish-route.spec.ts @@ -5,6 +5,7 @@ function fixture() { const run = { id: 42, run_attempt: 2, head_sha: 'a'.repeat(40), path: '.github/workflows/macos-package.yml', conclusion: 'success', event: 'workflow_dispatch', repository: { full_name: 'owner/repo' } } const source = { repository: 'owner/repo', runId: '42', attempt: '2', commit: run.head_sha, runnerName: 'mini', + workflowCommit: run.head_sha, sourceRef: 'master', channel: 'nightly', target: 'mac-arm64', preset: 'test', publishRequested: true, qualification: 'notarized' } const jobs = [{ runner_name: 'mini', conclusion: 'success', labels: ['self-hosted', 'dsh-mini'] }] const routes = { mini: { label: 'dsh-mini', target: 'mac-arm64' } } @@ -21,7 +22,7 @@ it.each(['attempt', 'repository', 'commit', 'mode', 'request', 'runner', 'workfl const f = fixture() if (field === 'attempt') f.source.attempt = '1' if (field === 'repository') f.source.repository = 'other/repo' - if (field === 'commit') f.source.commit = 'b'.repeat(40) + if (field === 'commit') f.source.workflowCommit = 'b'.repeat(40) if (field === 'mode') f.source.qualification = 'unnotarized' if (field === 'request') f.source.publishRequested = false if (field === 'runner') f.jobs[0]!.runner_name = 'laptop' @@ -35,3 +36,12 @@ it('fails closed when a publishing route or its unique label is missing', () => const duplicates = { ...f.routes, laptop: { label: 'dsh-mini', target: 'mac-arm64' } } expect(() => resolveDesktopPublishRoute('owner/repo', f.run, { source: f.source }, f.jobs, duplicates)).toThrow(/unique runner label/u) }) + + +it('keeps the artifact source separate from the workflow commit', () => { + const f = fixture() + f.source.commit = 'b'.repeat(40) + f.source.sourceRef = 'dsh-v1.0.0' + expect(resolveDesktopPublishRoute('owner/repo', f.run, { source: f.source }, f.jobs, f.routes)) + .toMatchObject({ commit: 'b'.repeat(40), sourceRef: 'dsh-v1.0.0', channel: 'nightly' }) +}) diff --git a/apps/desktop/tests/desktop-release-store.spec.ts b/apps/desktop/tests/desktop-release-store.spec.ts index 7ab2bb7e40..7c717aa90e 100644 --- a/apps/desktop/tests/desktop-release-store.spec.ts +++ b/apps/desktop/tests/desktop-release-store.spec.ts @@ -15,6 +15,7 @@ async function fixture() { const source: DesktopRetainedSource = { repository: 'owner/repository', runId: '100', attempt: '1', commit: 'a'.repeat(40), runnerName: 'windows-runner', target: 'win-x64', preset: 'test', productVersion: '1.0.0', version: '1.0.0', testReleaseId: 'b'.repeat(32), + workflowCommit: 'a'.repeat(40), sourceRef: 'master', channel: 'nightly', publishRequested: true, qualification: 'signed', } return { root: join(root, 'releases'), file, source } diff --git a/apps/desktop/tests/desktop-upload-plan.spec.ts b/apps/desktop/tests/desktop-upload-plan.spec.ts index fcdcb68ccf..def8cb19bf 100644 --- a/apps/desktop/tests/desktop-upload-plan.spec.ts +++ b/apps/desktop/tests/desktop-upload-plan.spec.ts @@ -38,6 +38,7 @@ async function fixture( target: DesktopPackageTargetName, version = '1.2.3', environment: 'test' | 'production' = 'test', + channel: 'nightly' | 'latest' = 'nightly', ): Promise { const root = await mkdtemp(join(tmpdir(), 'dsh-desktop-upload-')) temporaryDirectories.push(root) @@ -55,6 +56,7 @@ async function fixture( : 'https://download.deepseek.com' await writeFile(join(artifactsRoot, `${target}-release.json`), `${JSON.stringify({ schemaVersion: 1, + channel, target, version, environment, @@ -66,7 +68,7 @@ async function fixture( await writeFile(join(artifactsRoot, `${base}.zip`), zip) await writeFile(join(artifactsRoot, `${base}.zip.blockmap`), 'blockmap') await writeFile(join(artifactsRoot, `${base}.dmg`), 'notarized DMG fixture') - await writeFile(join(artifactsRoot, desktopUpdateMetadataFilename(version, 'darwin')), `${JSON.stringify({ + await writeFile(join(artifactsRoot, desktopUpdateMetadataFilename(version, 'darwin', channel)), `${JSON.stringify({ version, path: `${base}.zip`, files: [{ url: `${base}.zip`, size: Buffer.byteLength(zip), sha512: digest(zip) }], @@ -78,7 +80,7 @@ async function fixture( const info = await createBlockmap(join(artifactsRoot, `${base}.exe`), {}, { info: { emitArtifactBuildCompleted: async () => {} } }, `${base}.exe`) expect(Object.hasOwn(info, 'blockMapSize')).toBe(false) - await writeFile(join(artifactsRoot, desktopUpdateMetadataFilename(version, 'win32')), `${JSON.stringify({ + await writeFile(join(artifactsRoot, desktopUpdateMetadataFilename(version, 'win32', channel)), `${JSON.stringify({ version, path: `${base}.exe`, files: [{ @@ -93,12 +95,14 @@ async function fixture( artifactsRoot, environment: environment === 'test' ? { + DSH_DESKTOP_UPDATE_CHANNEL: channel, DSH_DESKTOP_AUTO_UPDATE_ENV: 'test', DOWNLOAD_TEST_ORIGIN: TEST_ORIGIN, DOWNLOAD_TEST_RELEASE_ID: RELEASE_ID, DOWNLOAD_TEST_COS_BUCKET: TEST_BUCKET, } : { + DSH_DESKTOP_UPDATE_CHANNEL: channel, DSH_DESKTOP_AUTO_UPDATE_ENV: 'production', DOWNLOAD_PROD_COS_BUCKET: PRODUCTION_BUCKET, }, @@ -171,6 +175,16 @@ describe('desktop upload plan', () => { .rejects.toThrow(failure === 'checksum' ? /SHA-512/u : /completion record/u) }) + it.each(['test', 'production'] as const)('isolates stable latest packages from nightly in %s', async (environment) => { + const nightly = await createDesktopUploadPlan('win-x64', await fixture('win-x64', '1.2.3', environment, 'nightly')) + const latest = await createDesktopUploadPlan('win-x64', await fixture('win-x64', '1.2.3', environment, 'latest')) + expect(latest.artifacts.filter(file => file.channelMetadata).map(file => file.filename)).toEqual(['latest.yml']) + expect(nightly.artifacts.filter(file => file.channelMetadata).map(file => file.filename)).toEqual(['nightly.yml']) + expect(latest.artifacts.every(file => !nightly.artifacts.some(other => other.key === file.key))).toBe(true) + const prerelease = await fixture('win-x64', '1.2.3-alpha.1', environment, 'latest') + await expect(createDesktopUploadPlan('win-x64', prerelease)).rejects.toThrow(/latest requires a stable version/u) + }) + it('publishes fixed feeds referencing versioned binaries without overriding CDN cache policy', async () => { const paths = await fixture('win-x64', '1.2.3', 'production') const plan = await createDesktopUploadPlan('win-x64', paths) @@ -178,7 +192,6 @@ describe('desktop upload plan', () => { 'dsh-desk/bin/win-x64/deepseek-harness-1.2.3-win-x64.exe', 'dsh-desk/bin/win-x64/deepseek-harness-1.2.3-win-x64.exe.blockmap', 'dsh-desk/feeds/win-x64/nightly.yml', - 'dsh-desk/feeds/win-x64/latest.yml', ]) expect(load(plan.artifacts[2]!.contents!)).toMatchObject({ version: '1.2.3', @@ -187,7 +200,6 @@ describe('desktop upload plan', () => { sha512: digest('signed NSIS executable fixture'), }], }) - expect(plan.artifacts[2]!.contents).toBe(plan.artifacts[3]!.contents) expect(plan.artifacts.every(artifact => !('cacheControl' in artifact))).toBe(true) }) @@ -205,7 +217,6 @@ describe('desktop upload plan', () => { 'deepseek-harness-1.2.3-mac-arm64.zip', 'deepseek-harness-1.2.3-mac-arm64.zip.blockmap', 'nightly-mac.yml', - 'latest-mac.yml', ]) expect(plan.artifacts.at(-1)).toMatchObject({ channelMetadata: true, @@ -258,7 +269,6 @@ describe('desktop upload plan', () => { 'deepseek-harness-2.0.0-win-x64.exe', 'deepseek-harness-2.0.0-win-x64.exe.blockmap', 'nightly.yml', - 'latest.yml', ]) expect(plan).toMatchObject({ publicUrl: 'https://download.deepseek.com/dsh-desk/feeds/win-x64/', diff --git a/apps/desktop/tests/expected/test-release-upload-mac-arm64.json b/apps/desktop/tests/expected/test-release-upload-mac-arm64.json index 515f0234f9..69af29ec01 100644 --- a/apps/desktop/tests/expected/test-release-upload-mac-arm64.json +++ b/apps/desktop/tests/expected/test-release-upload-mac-arm64.json @@ -11,9 +11,5 @@ { "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 index 5039a3aede..2c085140ce 100644 --- a/apps/desktop/tests/expected/test-release-upload-mac-x64.json +++ b/apps/desktop/tests/expected/test-release-upload-mac-x64.json @@ -11,9 +11,5 @@ { "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 index cb60e0ce29..9c7db67400 100644 --- a/apps/desktop/tests/expected/test-release-upload-win-x64.json +++ b/apps/desktop/tests/expected/test-release-upload-win-x64.json @@ -8,9 +8,5 @@ { "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/macos-app-update-config.spec.ts b/apps/desktop/tests/macos-app-update-config.spec.ts index 72a003734a..bba859fa7c 100644 --- a/apps/desktop/tests/macos-app-update-config.spec.ts +++ b/apps/desktop/tests/macos-app-update-config.spec.ts @@ -28,9 +28,9 @@ afterEach(async () => { describe('macOS packaged updater configuration', () => { it('uses the final generic Nightly provider configured for the build', () => { expect(resolveMacOSAppUpdateFeed([{ provider: 'generic', url: update.publicUrl, channel: 'nightly' }])) - .toEqual(update) + .toEqual({ ...update, channel: 'nightly' }) for (const publish of [undefined, [], [{ provider: 'github', channel: 'nightly', url: update.publicUrl }], - [{ provider: 'generic', channel: 'latest', url: update.publicUrl }]]) { + [{ provider: 'generic', channel: 'invalid', url: update.publicUrl }]]) { expect(() => resolveMacOSAppUpdateFeed(publish)).toThrow(/macOS update config/u) } }) @@ -64,3 +64,12 @@ describe('macOS packaged updater configuration', () => { .rejects.toThrow(/expected deepseek-harness-updater/u) }) }) + + +it('seals latest into the macOS updater file and rejects a different expected channel', async () => { + const paths = await fixture() + const latest = resolveMacOSAppUpdateFeed([{ provider: 'generic', url: update.publicUrl, channel: 'latest' }]) + await writeMacOSAppUpdateConfig(paths.resourcesDir, latest, 'fixture-updater') + await expect(verifyMacOSAppUpdateConfig(paths.appPath, latest)).resolves.toBeUndefined() + await expect(verifyMacOSAppUpdateConfig(paths.appPath, update)).rejects.toThrow(/does not match/u) +}) diff --git a/apps/desktop/tests/mandatory-update-policy.spec.ts b/apps/desktop/tests/mandatory-update-policy.spec.ts index 84a584389b..a04353810d 100644 --- a/apps/desktop/tests/mandatory-update-policy.spec.ts +++ b/apps/desktop/tests/mandatory-update-policy.spec.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { AccountClientMetadata } from '@deepseek-ai/dsh-deepseek-account/types' import { DesktopMandatoryUpdatePolicy, desktopPolicyPage, resolveDesktopPolicyConfig, type DesktopPolicyState } from '../src/mandatory-update-policy.ts' -const identity = { platform: 'win32', arch: 'x64', bundledDshVersion: '0.1.5-rc.1' } as const +const identity = { platform: 'win32', arch: 'x64', channel: 'nightly', bundledDshVersion: '0.1.5-rc.1' } as const const client: AccountClientMetadata = { version: '1.2.3', locale: 'zh-CN', timezoneOffsetSeconds: 28_800 } const force = { code: 40005, data: { show_content: { title: 'Update', detail: 'Required upgrade' }, desktop_app_link: 'https://downloads.example.com/desktop?os=win' } } diff --git a/apps/desktop/tests/prepare-windows-ci.spec.ts b/apps/desktop/tests/prepare-windows-ci.spec.ts index 67d62bbe19..f445682467 100644 --- a/apps/desktop/tests/prepare-windows-ci.spec.ts +++ b/apps/desktop/tests/prepare-windows-ci.spec.ts @@ -20,3 +20,12 @@ describe('manual Windows CI version', () => { expect(() => windowsCiVersion({ ...inputs, ...override }, '0.2.1', now)).toThrow(/positive integer/u) }) }) + + +it('resolves the selected version policy independently of the update channel', () => { + expect(windowsCiVersion({ ...inputs, PACKAGE_VERSION_MODE: 'default', DSH_PACKAGING_PRESET: 'production' }, '1.2.3', now)).toBe('1.2.3') + expect(windowsCiVersion({ ...inputs, PACKAGE_VERSION_MODE: 'default', DSH_PACKAGING_PRESET: 'test' }, '1.2.3', now)).toBe('1.2.3-test.20261009.42.2') + expect(windowsCiVersion({ PACKAGE_VERSION_MODE: 'product' }, '1.2.3-alpha.1', now)).toBe('1.2.3-alpha.1') + expect(() => windowsCiVersion({ PACKAGE_VERSION_MODE: 'manual', PACKAGE_BUILD_VERSION: 'auto' }, '1.2.3', now)).toThrow(/explicit version/u) + expect(() => windowsCiVersion({ PACKAGE_VERSION_MODE: 'invalid' }, '1.2.3', now)).toThrow(/version mode/u) +}) diff --git a/apps/desktop/tests/retain-desktop-ci.spec.ts b/apps/desktop/tests/retain-desktop-ci.spec.ts index d0e6f3ae3a..0fb2b14bc5 100644 --- a/apps/desktop/tests/retain-desktop-ci.spec.ts +++ b/apps/desktop/tests/retain-desktop-ci.spec.ts @@ -24,7 +24,7 @@ it.each(['mac-arm64', 'win-x64'] as const)('retains the complete %s release with await Promise.all(names.map(name => writeFile(join(artifacts, name), 'fixture'))) const env = { DSH_PACKAGING_CONFIG_DIR: config, DSH_PACKAGING_PRESET: 'test', DSH_DESKTOP_TARGET_PLATFORM: target === 'win-x64' ? 'win32' : 'darwin', - DSH_DESKTOP_TARGET_ARCH: target === 'win-x64' ? 'x64' : 'arm64', PACKAGE_VERSION: '1.0.0', PACKAGE_PUBLISH: 'true', + DSH_DESKTOP_TARGET_ARCH: target === 'win-x64' ? 'x64' : 'arm64', PACKAGE_SOURCE_REF: 'master', PACKAGE_SOURCE_SHA: 'b'.repeat(40), PACKAGE_VERSION: '1.0.0', PACKAGE_PUBLISH: 'true', PACKAGE_SIGNING: 'signed', PACKAGE_SKIP_NOTARIZATION: 'false', GITHUB_RUN_ID: '123', GITHUB_RUN_ATTEMPT: '2', GITHUB_REPOSITORY: 'owner/repository', GITHUB_SHA: 'b'.repeat(40), RUNNER_NAME: 'dedicated', } diff --git a/apps/desktop/tests/update-channel.spec.ts b/apps/desktop/tests/update-channel.spec.ts new file mode 100644 index 0000000000..8ea7bb98a4 --- /dev/null +++ b/apps/desktop/tests/update-channel.spec.ts @@ -0,0 +1,10 @@ +import { expect, it } from 'vitest' +import { desktopUpdateChannel } from '../src/update-channel.ts' + +it('reads the installed channel independently from the current version', () => { + expect(desktopUpdateChannel({ version: '1.0.0', dshDesktopUpdateChannel: 'nightly' })).toBe('nightly') + expect(desktopUpdateChannel({ version: '1.0.0', dshDesktopUpdateChannel: 'latest' })).toBe('latest') + expect(desktopUpdateChannel({ version: '1.0.0' })).toBe('nightly') + expect(() => desktopUpdateChannel({ dshDesktopUpdateChannel: 'beta' })).toThrow(/channel/u) + expect(() => desktopUpdateChannel(null)).toThrow(/manifest/u) +}) diff --git a/apps/desktop/tests/update-coordinator.spec.ts b/apps/desktop/tests/update-coordinator.spec.ts index 790ad63fcf..1c6e9da1c7 100644 --- a/apps/desktop/tests/update-coordinator.spec.ts +++ b/apps/desktop/tests/update-coordinator.spec.ts @@ -47,7 +47,7 @@ describe('desktop release metadata', () => { const coordinators: InstanceType[] = [] afterEach(() => { for (const item of coordinators.splice(0)) item.dispose() }) -function fixture() { +function fixture(channel: 'nightly' | 'latest' = 'nightly', version = '1.1.0-alpha.1') { const events = new EventEmitter() const checkForUpdates = vi.fn(async () => ({ isUpdateAvailable: true, @@ -66,7 +66,7 @@ function fixture() { const updater = Object.assign(events, { checkForUpdates, downloadUpdate, quitAndInstall }) as unknown as AppUpdater const coordinator = new DesktopUpdateCoordinator( (state) => { states.push(state); return state }, - beforeRestart, updater, () => true, () => '1.1.0-alpha.1', downloadResult, + beforeRestart, updater, () => true, () => version, downloadResult, channel, ) coordinators.push(coordinator) return { coordinator, updater, events, states, checkForUpdates, downloadUpdate, quitAndInstall, beforeRestart, downloadResult } @@ -242,3 +242,18 @@ it.each([new DesktopUpdatePreparationError('stop-failed', 'private diagnostic'), expect(f.downloadResult).toHaveBeenCalledExactlyOnceWith(false, error instanceof DesktopUpdatePreparationError ? 'stop-failed' : 'download_failed') expect(JSON.stringify(f.downloadResult.mock.calls)).not.toContain('private') }) + + +it('keeps a stable-version nightly installation on nightly and rejects prereleases on latest', async () => { + const nightly = fixture('nightly', '1.0.0') + expect(nightly.updater.channel).toBe('nightly') + expect(nightly.updater.allowPrerelease).toBe(true) + expect((await nightly.coordinator.check()).phase).toBe('available') + expect(nightly.updater.channel).toBe('nightly') + const latest = fixture('latest', '1.0.0') + expect(latest.updater.channel).toBe('latest') + expect(latest.updater.allowPrerelease).toBe(false) + expect(latest.updater.allowDowngrade).toBe(false) + expect((await latest.coordinator.check()).phase).toBe('error') + expect(latest.downloadUpdate).not.toHaveBeenCalled() +}) diff --git a/scripts/desktop-publish-workflow.spec.ts b/scripts/desktop-publish-workflow.spec.ts index 2796f234e2..6cd65965b1 100644 --- a/scripts/desktop-publish-workflow.spec.ts +++ b/scripts/desktop-publish-workflow.spec.ts @@ -8,6 +8,8 @@ const workflow = yaml.load(readFileSync(resolve(import.meta.dirname, '../.github permissions: Record jobs: Record if?: string environment?: string 'runs-on': string | string[] @@ -36,3 +38,14 @@ it('routes only completed package requests and gates production secrets with the expect(publish.steps.find(step => step.id === 'app')?.with).toMatchObject({ 'permission-contents': 'write' }) expect(publish.steps.find(step => step.id === 'publish')?.env?.DESKTOP_TAG_TOKEN).toBe('${{ steps.app.outputs.token }}') }) + + +it('prepares an approval digest without an Environment and binds the approved job to it', () => { + const prepare = workflow.jobs.prepare! + const publish = workflow.jobs.publish! + expect(prepare.environment).toBeUndefined() + expect(prepare.steps.find(step => step.id === 'check')?.run).toContain('--check') + expect(prepare.steps.some(step => step.id === 'key' || step.id === 'app')).toBe(false) + expect(publish.needs).toEqual(['route', 'prepare']) + expect(publish.env?.DESKTOP_APPROVED_PACKAGE).toBe('${{ needs.prepare.outputs.approval }}') +}) From e637eecaa935c54332b18ad8298b9affa83bed42 Mon Sep 17 00:00:00 2001 From: winewill Date: Tue, 13 Oct 2026 16:58:18 +0800 Subject: [PATCH 10/35] test(desktop): cover channel and source packaging inputs --- scripts/windows-package-workflow.spec.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/scripts/windows-package-workflow.spec.ts b/scripts/windows-package-workflow.spec.ts index 3263ac1b0d..4ada0923aa 100644 --- a/scripts/windows-package-workflow.spec.ts +++ b/scripts/windows-package-workflow.spec.ts @@ -62,12 +62,20 @@ it('routes manual packaging to the dedicated runner with explicit preset and sig expect(job['runs-on']).toBe('dsh-win-package-trial') expect(workflow.concurrency).toEqual({ group: 'windows-desktop-package', 'cancel-in-progress': false }) expect(workflow.permissions).toEqual({ contents: 'read' }) - expect(Object.keys(workflow.on.workflow_dispatch!.inputs).sort()).toEqual(['build_version', 'preset', 'publish', 'signing', 'test_release_id']) + expect(Object.keys(workflow.on.workflow_dispatch!.inputs).sort()).toEqual([ + 'build_version', 'preset', 'publish', 'signing', 'source_ref', 'test_release_id', 'update_channel', 'version_mode', + ]) expect(workflow.on.workflow_dispatch!.inputs.signing!.default).toBe('unsigned') - expect(workflow.on.workflow_dispatch!.inputs.build_version!.description).toContain('prefix must match checkout') + expect(workflow.on.workflow_dispatch!.inputs.build_version!.required).toBe(false) + expect(workflow.on.workflow_dispatch!.inputs.version_mode!.default).toBe('default') + expect(workflow.on.workflow_dispatch!.inputs.source_ref!.default).toBe('master') + expect(workflow.on.workflow_dispatch!.inputs.update_channel!.default).toBe('nightly') + expect(job.env.PACKAGE_VERSION_MODE).toBe('${{ inputs.version_mode }}') + expect(job.env.PACKAGE_SOURCE_REF).toBe('${{ inputs.source_ref }}') + expect(job.env.DSH_DESKTOP_UPDATE_CHANNEL).toBe('${{ inputs.update_channel }}') expect(job.env.DSH_PACKAGING_PRESET).toBe('${{ inputs.preset }}') expect(job.env.PACKAGE_SIGNING).toBe('${{ inputs.signing }}') - expect(job.steps.find(step => step.id === 'checkout')!.with).toMatchObject({ clean: true, 'persist-credentials': false }) + expect(job.steps.find(step => step.id === 'checkout')!.with).toMatchObject({ clean: true, 'persist-credentials': false, ref: '${{ inputs.source_ref }}' }) expect(job.steps.find(step => step.uses?.startsWith('pnpm/action-setup@'))?.with?.dest) .toBe('${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.run_attempt }}') }) From e776e2b3d2bfc36c276f614842c4f11153ccbf27 Mon Sep 17 00:00:00 2001 From: turtle1999 Date: Tue, 13 Oct 2026 17:09:18 +0800 Subject: [PATCH 11/35] refactor(plugin-manager): pass the profile to writePluginEnabled --- packages/boot/plugin-manager/src/index.ts | 4 +- packages/boot/plugin-manager/src/patch.ts | 14 +- .../boot/plugin-manager/tests/patch.spec.ts | 179 +++++++++--------- 3 files changed, 102 insertions(+), 95 deletions(-) diff --git a/packages/boot/plugin-manager/src/index.ts b/packages/boot/plugin-manager/src/index.ts index 274c9bb7f1..6434b0edfe 100644 --- a/packages/boot/plugin-manager/src/index.ts +++ b/packages/boot/plugin-manager/src/index.ts @@ -13,7 +13,7 @@ import { TypertRemoteService, Remote } from '@deepseek-ai/dsh-typert-protocol' import { pluginEntryId, readPluginInventory } from '@deepseek-ai/dsh-host-plugin-inventory' import { readPluginMeta, readProfileManifest, resolveBundleDir, loadOverlayPatches, composeEntries, - reconcileProfilePatches, readProfilePatches, readBundlePatches, OPTIONAL_BUNDLES, bundlePatchPaths, + reconcileProfilePatches, readProfilePatches, OPTIONAL_BUNDLES, bundlePatchPaths, evaluatePluginCompatibility, readProfileCompatibility, readProfileVersionExemptions, setProfileVersionExemption, PROFILE_COMPATIBILITY_FILENAME, OFFICIAL_ON_DEMAND_CATALOG, getDshRuntimeVersion, @@ -455,7 +455,7 @@ export class PluginManager extends TypertRemoteService { const row = (await this.listPlugins()).find(item => item.entryId === id) if (row === undefined) throw new ManagementFailure('unknown-plugin') if (row.readOnlyReason !== undefined) throw new ManagementFailure(row.readOnlyReason) - await writePluginEnabled(this.profile.patchPath, row.patchId, row.moduleName, enabled, readBundlePatches('dsh', this.profile)) + await writePluginEnabled(this.profile, row.patchId, row.moduleName, enabled) result.warnings = await this.reload(enabled ? [row.patchId] : []) const current = (await this.listPlugins()).find(item => item.entryId === id) return current?.enabled !== enabled && this.ownerContext.get('hmr') !== undefined ? 'overridden' : undefined diff --git a/packages/boot/plugin-manager/src/patch.ts b/packages/boot/plugin-manager/src/patch.ts index 148b17f783..0dfacb2de6 100644 --- a/packages/boot/plugin-manager/src/patch.ts +++ b/packages/boot/plugin-manager/src/patch.ts @@ -2,8 +2,7 @@ import { readFile } from 'node:fs/promises' import { isMap, isNode, isScalar, isSeq, parseDocument } from 'yaml' import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader' -import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include' -import { composeEntries, loadOptionalPatches } from '@deepseek-ai/dsh-app-boot' +import { composeEntries, loadOptionalPatches, readBundlePatches, type ProfileContext } from '@deepseek-ai/dsh-app-boot' import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write' /** Flatten only the groups addressable by the profile's patch composer. @@ -17,16 +16,14 @@ export function flatten(rows: EntryOptions[]): EntryOptions[] { /** Replace the last matching override or append one after existing insertions. An override that holds only `id` and * `disabled`, without anchors, is removed, or not appended, when the bundle layers and the remaining profile patches * already give the entry the requested literal `disabled` value; home patches and launch overlays are not consulted. - * @param filename Current profile patch file. + * @param profile Profile whose patch file is edited and whose selected bundles supply the compared composition. * @param id Unique composition entry id. * @param name Module name used to match name-qualified overrides. * @param enabled Desired entry enablement. - * @param layers Bundle patches applied before the profile patch file. * @returns Whether the file changed. */ -export async function writePluginEnabled( - filename: string, id: string, name: string, enabled: boolean, layers: readonly PatchOptions[], -): Promise { +export async function writePluginEnabled(profile: ProfileContext, id: string, name: string, enabled: boolean): Promise { + const filename = profile.patchPath let text: string try { text = await readFile(filename, 'utf8') @@ -41,6 +38,7 @@ export async function writePluginEnabled( if (error !== undefined) throw error if (!isSeq(document.contents)) throw new Error('Profile patch must be a YAML sequence') const patches = loadOptionalPatches('dsh', filename) ?? [] + const bundlePatches = readBundlePatches('dsh', profile) const items = document.contents.items const target = items.findLast((item, index) => { if (!isMap(item) || document.getIn([index, 'id']) !== id || item.has('insert')) return false @@ -49,7 +47,7 @@ export async function writePluginEnabled( }) // Compares the entry's own literal `disabled`; a `!!js` value never matches, so its override is kept. const implied = (index: number): boolean => { - const row = flatten(composeEntries([[...layers, ...patches.toSpliced(index, 1)]])).findLast(entry => entry.id === id) + const row = flatten(composeEntries([[...bundlePatches, ...patches.toSpliced(index, 1)]])).findLast(entry => entry.id === id) return row !== undefined && (row.disabled ?? false) === !enabled } if (isMap(target)) { diff --git a/packages/boot/plugin-manager/tests/patch.spec.ts b/packages/boot/plugin-manager/tests/patch.spec.ts index 4a80f4cfdc..9580da1e52 100644 --- a/packages/boot/plugin-manager/tests/patch.spec.ts +++ b/packages/boot/plugin-manager/tests/patch.spec.ts @@ -4,72 +4,83 @@ import { join } from 'node:path' import { tmpdir } from 'node:os' import { expect, it, onTestFinished } from 'vitest' import { applyEntryPatches } from '@deepseek-ai/cordis-plugin-include' -import { loadOptionalPatches } from '@deepseek-ai/dsh-app-boot' +import { initProfile, loadOptionalPatches, type ProfileContext } from '@deepseek-ai/dsh-app-boot' import { writePluginEnabled } from '../src/patch.ts' -async function fixture(text?: string): Promise { - const dir = await mkdtemp(join(tmpdir(), 'manager-patch-')) - onTestFinished(() => rm(dir, { recursive: true, force: true })) - const file = join(dir, 'cordis.patch.yml') - if (text !== undefined) await writeFile(file, text) - return file +const TOOL = { id: 'tool', name: 'package' } + +/** Create a profile whose patch file holds `text` (none when omitted) and whose one selected bundle inserts `rows`. */ +async function fixture(text?: string, rows: object[] = []): Promise { + const home = await mkdtemp(join(tmpdir(), 'manager-patch-')) + onTestFinished(() => rm(home, { recursive: true, force: true })) + const dir = join(home, 'profile') + const installAnchor = join(home, 'package.json') + await writeFile(installAnchor, '{"name":"installation","dependencies":{}}') + initProfile(dir, ['bundle']) + const bundle = join(dir, 'node_modules', 'bundle') + await mkdir(bundle, { recursive: true }) + await writeFile(join(bundle, 'package.json'), JSON.stringify({ name: 'bundle', version: '1.0.0', dsh: { bundle: { patch: './cordis.patch.yml' } } })) + await writeFile(join(bundle, 'cordis.patch.yml'), JSON.stringify([{ insert: rows }])) + const patchPath = join(dir, 'cordis.patch.yml') + await (text === undefined ? rm(patchPath) : writeFile(patchPath, text)) + return { name: 'test', dir, patchPath, installAnchor, cwd: home, home, startedBundles: ['bundle'], overlays: [], telemetryDisabledEnv: undefined } } it('preserves comments, expressions, unrelated configuration and the last override', async () => { - const file = await fixture('# personal configuration\n- id: tool\n config:\n value: !!js process.platform\n- id: tool\n disabled: false # availability\n') - expect(await writePluginEnabled(file, 'tool', 'package', false, [])).toBe(true) - const text = await readFile(file, 'utf8') + const profile = await fixture('# personal configuration\n- id: tool\n config:\n value: !!js process.platform\n- id: tool\n disabled: false # availability\n') + expect(await writePluginEnabled(profile, 'tool', 'package', false)).toBe(true) + const text = await readFile(profile.patchPath, 'utf8') expect(text).toContain('# personal configuration') expect(text).toContain('!!js process.platform') expect(text).toContain('# availability') - expect(loadOptionalPatches('test', file)).toEqual([ + expect(loadOptionalPatches('test', profile.patchPath)).toEqual([ { id: 'tool', config: { value: { __jsExpr: 'process.platform' } } }, { id: 'tool', disabled: true }, ]) - expect(await writePluginEnabled(file, 'tool', 'package', false, [])).toBe(false) - expect(await readFile(file, 'utf8')).toBe(text) - expect(await writePluginEnabled(file, 'tool', 'package', true, [])).toBe(true) + expect(await writePluginEnabled(profile, 'tool', 'package', false)).toBe(false) + expect(await readFile(profile.patchPath, 'utf8')).toBe(text) + expect(await writePluginEnabled(profile, 'tool', 'package', true)).toBe(true) }) it('creates a missing patch file and appends after insertions', async () => { - const file = await fixture() - await writePluginEnabled(file, 'tool', 'package', false, []) - expect(loadOptionalPatches('test', file)).toEqual([{ id: 'tool', disabled: true }]) - await writeFile(file, '- insert:\n - id: tool\n name: package\n') - await writePluginEnabled(file, 'tool', 'package', false, []) - expect(loadOptionalPatches('test', file)).toEqual([ + const profile = await fixture() + await writePluginEnabled(profile, 'tool', 'package', false) + expect(loadOptionalPatches('test', profile.patchPath)).toEqual([{ id: 'tool', disabled: true }]) + await writeFile(profile.patchPath, '- insert:\n - id: tool\n name: package\n') + await writePluginEnabled(profile, 'tool', 'package', false) + expect(loadOptionalPatches('test', profile.patchPath)).toEqual([ { insert: [{ id: 'tool', name: 'package' }] }, { id: 'tool', disabled: true }, ]) }) it.each(['- id: [broken', 'mapping: true\n'])('refuses malformed documents without overwriting %s', async (text) => { - const file = await fixture(text) - await expect(writePluginEnabled(file, 'tool', 'package', true, [])).rejects.toThrow() - expect(await readFile(file, 'utf8')).toBe(text) + const profile = await fixture(text) + await expect(writePluginEnabled(profile, 'tool', 'package', true)).rejects.toThrow() + expect(await readFile(profile.patchPath, 'utf8')).toBe(text) }) it('retains name-asserting overrides and appends an unambiguous switch', async () => { - const file = await fixture('- id: tool\n name: another-package\n disabled: false\n') - await writePluginEnabled(file, 'tool', 'package', false, []) - expect(loadOptionalPatches('test', file)).toEqual([ + const profile = await fixture('- id: tool\n name: another-package\n disabled: false\n') + await writePluginEnabled(profile, 'tool', 'package', false) + expect(loadOptionalPatches('test', profile.patchPath)).toEqual([ { id: 'tool', name: 'another-package', disabled: false }, { id: 'tool', disabled: true }, ]) - expect(await writePluginEnabled(file, 'tool', 'package', false, [])).toBe(false) + expect(await writePluginEnabled(profile, 'tool', 'package', false)).toBe(false) }) it('reports read failures without replacing a directory with configuration', async () => { - const file = await fixture() - await mkdir(file) - await expect(writePluginEnabled(file, 'tool', 'package', true, [])).rejects.toThrow() + const profile = await fixture() + await mkdir(profile.patchPath) + await expect(writePluginEnabled(profile, 'tool', 'package', true)).rejects.toThrow() }) it('updates the last matching named override and leaves mismatched names untouched', async () => { - const file = await fixture('- id: tool\n disabled: true\n- id: tool\n name: package\n config:\n value: !!js process.platform\n disabled: true # availability\n- id: tool\n name: another-package\n disabled: true\n') - expect(await writePluginEnabled(file, 'tool', 'package', true, [])).toBe(true) - const text = await readFile(file, 'utf8') + const profile = await fixture('- id: tool\n disabled: true\n- id: tool\n name: package\n config:\n value: !!js process.platform\n disabled: true # availability\n- id: tool\n name: another-package\n disabled: true\n') + expect(await writePluginEnabled(profile, 'tool', 'package', true)).toBe(true) + const text = await readFile(profile.patchPath, 'utf8') expect(text).toContain('!!js process.platform') expect(text).toContain('# availability') - const patches = loadOptionalPatches('test', file) + const patches = loadOptionalPatches('test', profile.patchPath) expect(patches).toEqual([ { id: 'tool', disabled: true }, { id: 'tool', name: 'package', config: { value: { __jsExpr: 'process.platform' } }, disabled: false }, @@ -77,63 +88,61 @@ it('updates the last matching named override and leaves mismatched names untouch ]) expect(applyEntryPatches([{ id: 'tool', name: 'package' }], patches, () => {})) .toMatchObject([{ id: 'tool', name: 'package', disabled: false }]) - expect(await writePluginEnabled(file, 'tool', 'package', true, [])).toBe(false) - expect(await readFile(file, 'utf8')).toBe(text) - expect(await writePluginEnabled(file, 'tool', 'package', false, [])).toBe(true) - expect(loadOptionalPatches('test', file)).toHaveLength(3) + expect(await writePluginEnabled(profile, 'tool', 'package', true)).toBe(false) + expect(await readFile(profile.patchPath, 'utf8')).toBe(text) + expect(await writePluginEnabled(profile, 'tool', 'package', false)).toBe(true) + expect(loadOptionalPatches('test', profile.patchPath)).toHaveLength(3) }) it('removes or omits a plain switch that restates the bundle layers', async () => { - const layers = [{ insert: [{ id: 'tool', name: 'package' }] }] - const file = await fixture('# personal configuration\n- id: other\n disabled: true\n- id: tool\n disabled: true\n') - expect(await writePluginEnabled(file, 'tool', 'package', true, layers)).toBe(true) - expect(await readFile(file, 'utf8')).toBe('# personal configuration\n- id: other\n disabled: true\n') - expect(await writePluginEnabled(file, 'tool', 'package', true, layers)).toBe(false) - expect(await readFile(file, 'utf8')).toBe('# personal configuration\n- id: other\n disabled: true\n') - expect(await writePluginEnabled(file, 'tool', 'package', false, layers)).toBe(true) - expect(loadOptionalPatches('test', file)).toEqual([{ id: 'other', disabled: true }, { id: 'tool', disabled: true }]) + const profile = await fixture('# personal configuration\n- id: other\n disabled: true\n- id: tool\n disabled: true\n', [TOOL]) + expect(await writePluginEnabled(profile, 'tool', 'package', true)).toBe(true) + expect(await readFile(profile.patchPath, 'utf8')).toBe('# personal configuration\n- id: other\n disabled: true\n') + expect(await writePluginEnabled(profile, 'tool', 'package', true)).toBe(false) + expect(await readFile(profile.patchPath, 'utf8')).toBe('# personal configuration\n- id: other\n disabled: true\n') + expect(await writePluginEnabled(profile, 'tool', 'package', false)).toBe(true) + expect(loadOptionalPatches('test', profile.patchPath)).toEqual([{ id: 'other', disabled: true }, { id: 'tool', disabled: true }]) }) it('keeps a switch that carries other settings or differs from the bundle layers', async () => { - const file = await fixture('- id: tool\n config: { value: 1 }\n disabled: true\n') - expect(await writePluginEnabled(file, 'tool', 'package', true, [{ insert: [{ id: 'tool', name: 'package' }] }])).toBe(true) - expect(loadOptionalPatches('test', file)).toEqual([{ id: 'tool', config: { value: 1 }, disabled: false }]) - const plain = await fixture('- id: tool\n disabled: true\n') - expect(await writePluginEnabled(plain, 'tool', 'package', true, [{ insert: [{ id: 'tool', name: 'package', disabled: true }] }])).toBe(true) - expect(loadOptionalPatches('test', plain)).toEqual([{ id: 'tool', disabled: false }]) + const profile = await fixture('- id: tool\n config: { value: 1 }\n disabled: true\n', [TOOL]) + expect(await writePluginEnabled(profile, 'tool', 'package', true)).toBe(true) + expect(loadOptionalPatches('test', profile.patchPath)).toEqual([{ id: 'tool', config: { value: 1 }, disabled: false }]) + const plain = await fixture('- id: tool\n disabled: true\n', [{ ...TOOL, disabled: true }]) + expect(await writePluginEnabled(plain, 'tool', 'package', true)).toBe(true) + expect(loadOptionalPatches('test', plain.patchPath)).toEqual([{ id: 'tool', disabled: false }]) }) it('keeps the comment above a removed switch and prunes a switch the composition already implies', async () => { - const layers = [{ insert: [{ id: 'tool', name: 'package' }] }] - const file = await fixture('# header\n- id: tool\n disabled: false\n# other\n- id: other\n disabled: true\n') - expect(await writePluginEnabled(file, 'tool', 'package', true, layers)).toBe(true) - expect(await readFile(file, 'utf8')).toBe('# header\n# other\n- id: other\n disabled: true\n') - const first = await fixture('# header\n- id: tool\n disabled: true\n- id: other\n disabled: true\n') - expect(await writePluginEnabled(first, 'tool', 'package', true, layers)).toBe(true) - expect(await readFile(first, 'utf8')).toBe('# header\n- id: other\n disabled: true\n') - const only = await fixture('# header\n- id: tool\n disabled: true\n') - expect(await writePluginEnabled(only, 'tool', 'package', true, layers)).toBe(true) - expect(loadOptionalPatches('test', only)).toEqual([]) - expect(await readFile(only, 'utf8')).toBe('# header\n[]\n') - const last = await fixture('- id: other\n disabled: true\n# about tool\n- id: tool\n disabled: true\n') - expect(await writePluginEnabled(last, 'tool', 'package', true, layers)).toBe(true) - expect(await readFile(last, 'utf8')).toBe('- id: other\n disabled: true\n\n# about tool\n') - const lastWithTail = await fixture('- id: other\n disabled: true\n# about tool\n- id: tool\n disabled: true\n# tail\n') - expect(await writePluginEnabled(lastWithTail, 'tool', 'package', true, layers)).toBe(true) - expect(await readFile(lastWithTail, 'utf8')).toBe('- id: other\n disabled: true\n\n# about tool\n# tail\n') - const trailing = await fixture('- id: tool\n disabled: true # removed\n# tail\n') - expect(await writePluginEnabled(trailing, 'tool', 'package', true, layers)).toBe(true) - expect(loadOptionalPatches('test', trailing)).toEqual([]) - expect(await readFile(trailing, 'utf8')).toContain('# tail') - const both = await fixture('# header\n- id: tool\n disabled: true\n# tail\n') - expect(await writePluginEnabled(both, 'tool', 'package', true, layers)).toBe(true) - expect(await readFile(both, 'utf8')).toBe('# header\n[]\n\n# tail\n') + const profile = await fixture('# header\n- id: tool\n disabled: false\n# other\n- id: other\n disabled: true\n', [TOOL]) + expect(await writePluginEnabled(profile, 'tool', 'package', true)).toBe(true) + expect(await readFile(profile.patchPath, 'utf8')).toBe('# header\n# other\n- id: other\n disabled: true\n') + const first = await fixture('# header\n- id: tool\n disabled: true\n- id: other\n disabled: true\n', [TOOL]) + expect(await writePluginEnabled(first, 'tool', 'package', true)).toBe(true) + expect(await readFile(first.patchPath, 'utf8')).toBe('# header\n- id: other\n disabled: true\n') + const only = await fixture('# header\n- id: tool\n disabled: true\n', [TOOL]) + expect(await writePluginEnabled(only, 'tool', 'package', true)).toBe(true) + expect(loadOptionalPatches('test', only.patchPath)).toEqual([]) + expect(await readFile(only.patchPath, 'utf8')).toBe('# header\n[]\n') + const last = await fixture('- id: other\n disabled: true\n# about tool\n- id: tool\n disabled: true\n', [TOOL]) + expect(await writePluginEnabled(last, 'tool', 'package', true)).toBe(true) + expect(await readFile(last.patchPath, 'utf8')).toBe('- id: other\n disabled: true\n\n# about tool\n') + const lastWithTail = await fixture('- id: other\n disabled: true\n# about tool\n- id: tool\n disabled: true\n# tail\n', [TOOL]) + expect(await writePluginEnabled(lastWithTail, 'tool', 'package', true)).toBe(true) + expect(await readFile(lastWithTail.patchPath, 'utf8')).toBe('- id: other\n disabled: true\n\n# about tool\n# tail\n') + const trailing = await fixture('- id: tool\n disabled: true # removed\n# tail\n', [TOOL]) + expect(await writePluginEnabled(trailing, 'tool', 'package', true)).toBe(true) + expect(loadOptionalPatches('test', trailing.patchPath)).toEqual([]) + expect(await readFile(trailing.patchPath, 'utf8')).toContain('# tail') + const both = await fixture('# header\n- id: tool\n disabled: true\n# tail\n', [TOOL]) + expect(await writePluginEnabled(both, 'tool', 'package', true)).toBe(true) + expect(await readFile(both.patchPath, 'utf8')).toBe('# header\n[]\n\n# tail\n') }) it('treats insertions in the profile patch as part of the inherited composition', async () => { - const file = await fixture('- insert:\n - id: tool\n name: package\n') - expect(await writePluginEnabled(file, 'tool', 'package', true, [])).toBe(false) - expect(loadOptionalPatches('test', file)).toEqual([{ insert: [{ id: 'tool', name: 'package' }] }]) + const profile = await fixture('- insert:\n - id: tool\n name: package\n') + expect(await writePluginEnabled(profile, 'tool', 'package', true)).toBe(false) + expect(loadOptionalPatches('test', profile.patchPath)).toEqual([{ insert: [{ id: 'tool', name: 'package' }] }]) }) it.each([ @@ -141,13 +150,13 @@ it.each([ ['the entry', '- &plugin { id: tool, disabled: true }\n- id: other\n config: { target: *plugin }\n', { target: { id: 'tool', disabled: false } }], ['a key', '- id: tool\n &key disabled: true\n- id: other\n config: { *key : 1 }\n', { disabled: 1 }], ])('keeps a switch whose anchor on %s other entries reference', async (_, text, config) => { - const file = await fixture(text) - expect(await writePluginEnabled(file, 'tool', 'package', true, [{ insert: [{ id: 'tool', name: 'package' }] }])).toBe(true) - expect(loadOptionalPatches('test', file)).toEqual([{ id: 'tool', disabled: false }, { id: 'other', config }]) + const profile = await fixture(text, [TOOL]) + expect(await writePluginEnabled(profile, 'tool', 'package', true)).toBe(true) + expect(loadOptionalPatches('test', profile.patchPath)).toEqual([{ id: 'tool', disabled: false }, { id: 'other', config }]) }) it('keeps a switch whose value is an alias', async () => { - const file = await fixture('- id: other\n config: { flag: &flag true }\n- id: tool\n disabled: *flag\n') - expect(await writePluginEnabled(file, 'tool', 'package', true, [{ insert: [{ id: 'tool', name: 'package' }] }])).toBe(true) - expect(loadOptionalPatches('test', file)).toEqual([{ id: 'other', config: { flag: true } }, { id: 'tool', disabled: false }]) + const profile = await fixture('- id: other\n config: { flag: &flag true }\n- id: tool\n disabled: *flag\n', [TOOL]) + expect(await writePluginEnabled(profile, 'tool', 'package', true)).toBe(true) + expect(loadOptionalPatches('test', profile.patchPath)).toEqual([{ id: 'other', config: { flag: true } }, { id: 'tool', disabled: false }]) }) From 9f5bc9669b5ef16f36ea4128a91e484b28da5355 Mon Sep 17 00:00:00 2001 From: Ziya Date: Tue, 13 Oct 2026 05:38:34 -0700 Subject: [PATCH 12/35] =?UTF-8?q?fix(schedule):=20=E7=AE=80=E5=8C=96?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E5=8C=96=E4=BB=BB=E5=8A=A1=E6=A0=87=E9=A2=98?= =?UTF-8?q?=EF=BC=8C=E5=8E=BB=E9=99=A4=E6=97=B6=E9=97=B4=E4=B8=8E=E6=89=A7?= =?UTF-8?q?=E8=A1=8C=E5=A4=87=E6=B3=A8=20(#6105)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(schedule): keep timing and execution notes out of task titles * fix(schedule): avoid repeating task title guidance * fix(schedule): keep title guidance examples in English --------- Co-authored-by: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> --- docs/tool-catalog.md | 4 ++-- docs/tool-catalog.zh.md | 4 ++-- packages/schedule/tool-schedule/README.i18n.yaml | 4 ++-- packages/schedule/tool-schedule/README.md | 2 +- packages/schedule/tool-schedule/README.zh.md | 2 +- packages/schedule/tool-schedule/src/index.ts | 12 ++++++++++-- .../tool-schedule/tests/tool-schedule.spec.ts | 2 +- .../web/fresh-round-trip/tool-schemas.expected.json | 4 ++-- .../system-prompt.expected.md | 4 ++-- .../optional-string-editor/system-prompt.expected.md | 4 ++-- snapshots/web/ptc-round/system-prompt.expected.md | 4 ++-- 11 files changed, 27 insertions(+), 19 deletions(-) diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 0d415975c6..82abbdd2c5 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -1397,7 +1397,7 @@ Create a reminder in the current session that delivers prompt when it becomes du }, "title": { "type": "string", - "description": "Task name of at most 120 characters, shown on the task card and in task lists." + "description": "Concise task name in the user's language, at most 120 characters. Name only the core action and subject; keep essential target identifiers such as PR numbers and branch names. Do not include execution frequency (e.g. daily, hourly), scheduled dates/times, time zones, task status, or configuration and execution details (e.g. read-only mode, profile-tag filters), even when embedded in the requested or existing title. Do not append parenthetical notes. Put scheduling information in the timing parameters and execution instructions in prompt." }, "after_seconds": { "type": "number", @@ -1558,7 +1558,7 @@ Change a reminder in place, keeping its id. Supply a new title, prompt, or at mo }, "title": { "type": "string", - "description": "New task name of at most 120 characters." + "description": "New task name; follow the schedule_create title rules." }, "prompt": { "type": "string", diff --git a/docs/tool-catalog.zh.md b/docs/tool-catalog.zh.md index 1622182f0f..1e3dad2606 100644 --- a/docs/tool-catalog.zh.md +++ b/docs/tool-catalog.zh.md @@ -1403,7 +1403,7 @@ create、edit、pause 和 resume 要求直接来自人类的根权限;complete }, "title": { "type": "string", - "description": "Task name of at most 120 characters, shown on the task card and in task lists." + "description": "Concise task name in the user's language, at most 120 characters. Name only the core action and subject; keep essential target identifiers such as PR numbers and branch names. Do not include execution frequency (e.g. daily, hourly), scheduled dates/times, time zones, task status, or configuration and execution details (e.g. read-only mode, profile-tag filters), even when embedded in the requested or existing title. Do not append parenthetical notes. Put scheduling information in the timing parameters and execution instructions in prompt." }, "after_seconds": { "type": "number", @@ -1564,7 +1564,7 @@ create、edit、pause 和 resume 要求直接来自人类的根权限;complete }, "title": { "type": "string", - "description": "New task name of at most 120 characters." + "description": "New task name; follow the schedule_create title rules." }, "prompt": { "type": "string", diff --git a/packages/schedule/tool-schedule/README.i18n.yaml b/packages/schedule/tool-schedule/README.i18n.yaml index dd18039a4a..8563815ed2 100644 --- a/packages/schedule/tool-schedule/README.i18n.yaml +++ b/packages/schedule/tool-schedule/README.i18n.yaml @@ -42,8 +42,8 @@ en: 3039bc5e7a796b21 zh: 6b7a9eaf69c9ea53 /deepseek-ai-dsh-tool-schedule/model-experience/tool-schemas/what-the-model-sees: - en: 82465f7520c67ddd - zh: eb9014c758b687c5 + en: 0056efd9de0ecd8c + zh: 6887c2dd9ed8587f /deepseek-ai-dsh-tool-schedule/model-experience/tool-schemas/token-effect: en: 70fb9094859d0804 zh: 30726b045484713c diff --git a/packages/schedule/tool-schedule/README.md b/packages/schedule/tool-schedule/README.md index 238bce1d47..b923332157 100644 --- a/packages/schedule/tool-schedule/README.md +++ b/packages/schedule/tool-schedule/README.md @@ -84,7 +84,7 @@ The plugin registers four `defineTool` definitions through the context that load #### What the model sees -The generated [`schedule_create`, `schedule_list`, `schedule_update`, and `schedule_delete` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-schedule) whenever this package is visible in the calling Agent's scope. +The generated [`schedule_create`, `schedule_list`, `schedule_update`, and `schedule_delete` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-schedule) whenever this package is visible in the calling Agent's scope. Title guidance does not rewrite stored titles or add validation beyond the existing non-empty and length checks. #### Token effect diff --git a/packages/schedule/tool-schedule/README.zh.md b/packages/schedule/tool-schedule/README.zh.md index 15e54ecc2f..8404186f32 100644 --- a/packages/schedule/tool-schedule/README.zh.md +++ b/packages/schedule/tool-schedule/README.zh.md @@ -84,7 +84,7 @@ kind: "package-reference" #### What the model sees -只要本包在调用方 Agent 的作用域内可见,模型就会收到生成的 [`schedule_create`、`schedule_list`、`schedule_update` 与 `schedule_delete` schema](../../../docs/tool-catalog.zh.md#deepseek-aidsh-tool-schedule)。 +只要本包在调用方 Agent 的作用域内可见,模型就会收到生成的 [`schedule_create`、`schedule_list`、`schedule_update` 与 `schedule_delete` schema](../../../docs/tool-catalog.zh.md#deepseek-aidsh-tool-schedule)。标题指引不会改写已存标题,也不会在现有的非空和长度检查之外增加校验。 #### Token effect diff --git a/packages/schedule/tool-schedule/src/index.ts b/packages/schedule/tool-schedule/src/index.ts index d67d474c6d..da802b4a2c 100644 --- a/packages/schedule/tool-schedule/src/index.ts +++ b/packages/schedule/tool-schedule/src/index.ts @@ -195,6 +195,14 @@ const UPDATE_DESCRIPTION = 'Change a reminder in place, keeping its id. Supply a new title, prompt, or at most one timing parameter; ' + 'omitted fields keep their stored values. To change a relative delay, create a new reminder.' +const CREATE_TITLE_DESCRIPTION = + `Concise task name in the user's language, at most ${MAX_TITLE_LENGTH} characters. ` + + 'Name only the core action and subject; keep essential target identifiers such as PR numbers and branch names. ' + + 'Do not include execution frequency (e.g. daily, hourly), scheduled dates/times, time zones, task status, ' + + 'or configuration and execution details (e.g. read-only mode, profile-tag filters), even when embedded in the requested or existing title. ' + + 'Do not append parenthetical notes. ' + + 'Put scheduling information in the timing parameters and execution instructions in prompt.' + /** Deterministic model content for every canonical Schedule value. */ function renderValue(_args: unknown, value: unknown): ContentBlock[] { // The ToolRuntime has already validated the value against the lossless-JSON output schema. @@ -454,7 +462,7 @@ function registerScheduleTools(ctx: Context): void { title: { type: 'string', required: true, - description: `Task name of at most ${MAX_TITLE_LENGTH} characters, shown on the task card and in task lists.`, + description: CREATE_TITLE_DESCRIPTION, }, after_seconds: { type: 'number', @@ -534,7 +542,7 @@ function registerScheduleTools(ctx: Context): void { id: { type: 'string', required: true, description: 'Schedule id returned by schedule_list.' }, title: { type: 'string', - description: `New task name of at most ${MAX_TITLE_LENGTH} characters.`, + description: 'New task name; follow the schedule_create title rules.', }, prompt: { type: 'string', diff --git a/packages/schedule/tool-schedule/tests/tool-schedule.spec.ts b/packages/schedule/tool-schedule/tests/tool-schedule.spec.ts index 95f115858f..7e2c294978 100644 --- a/packages/schedule/tool-schedule/tests/tool-schedule.spec.ts +++ b/packages/schedule/tool-schedule/tests/tool-schedule.spec.ts @@ -130,7 +130,7 @@ describe('Schedule model tools', () => { const schema = test.ctx.tools.schemas(test.agent).find(item => item.name === 'schedule_create') const parameters = schema?.parameters as { properties: Record; required: string[] } expect(parameters.properties.title?.type).toBe('string') - expect(parameters.properties.title?.description).toContain('task card') + expect(parameters.properties.title?.description).toContain('Concise task name') expect(parameters.required).toEqual(expect.arrayContaining(['prompt', 'title'])) const created = await execute(test, 'schedule_create', { prompt: 'Check the queue\nand the backlog', title: ' Queue check ', every_seconds: 300, diff --git a/snapshots/web/fresh-round-trip/tool-schemas.expected.json b/snapshots/web/fresh-round-trip/tool-schemas.expected.json index 557f8adc57..90228febab 100644 --- a/snapshots/web/fresh-round-trip/tool-schemas.expected.json +++ b/snapshots/web/fresh-round-trip/tool-schemas.expected.json @@ -408,7 +408,7 @@ }, "title": { "type": "string", - "description": "Task name of at most 120 characters, shown on the task card and in task lists." + "description": "Concise task name in the user's language, at most 120 characters. Name only the core action and subject; keep essential target identifiers such as PR numbers and branch names. Do not include execution frequency (e.g. daily, hourly), scheduled dates/times, time zones, task status, or configuration and execution details (e.g. read-only mode, profile-tag filters), even when embedded in the requested or existing title. Do not append parenthetical notes. Put scheduling information in the timing parameters and execution instructions in prompt." }, "after_seconds": { "type": "number", @@ -554,7 +554,7 @@ }, "title": { "type": "string", - "description": "New task name of at most 120 characters." + "description": "New task name; follow the schedule_create title rules." }, "prompt": { "type": "string", diff --git a/snapshots/web/official-subagent-bundle/system-prompt.expected.md b/snapshots/web/official-subagent-bundle/system-prompt.expected.md index 74cf3faf4c..eac847a631 100644 --- a/snapshots/web/official-subagent-bundle/system-prompt.expected.md +++ b/snapshots/web/official-subagent-bundle/system-prompt.expected.md @@ -186,7 +186,7 @@ interface ToolArgsMap { schedule_create: { /** Reminder content to present when the target becomes due. */ prompt: string; - /** Task name of at most 120 characters, shown on the task card and in task lists. */ + /** Concise task name in the user's language, at most 120 characters. Name only the core action and subject; keep essential target identifiers such as PR numbers and branch names. Do not include execution frequency (e.g. daily, hourly), scheduled dates/times, time zones, task status, or configuration and execution details (e.g. read-only mode, profile-tag filters), even when embedded in the requested or existing title. Do not append parenthetical notes. Put scheduling information in the timing parameters and execution instructions in prompt. */ title: string; /** Delay in whole seconds. */ after_seconds?: number; @@ -233,7 +233,7 @@ interface ToolArgsMap { schedule_update: { /** Schedule id returned by schedule_list. */ id: string; - /** New task name of at most 120 characters. */ + /** New task name; follow the schedule_create title rules. */ title?: string; /** New reminder content. */ prompt?: string; diff --git a/snapshots/web/optional-string-editor/system-prompt.expected.md b/snapshots/web/optional-string-editor/system-prompt.expected.md index e6bf7b9e62..f1e9811c90 100644 --- a/snapshots/web/optional-string-editor/system-prompt.expected.md +++ b/snapshots/web/optional-string-editor/system-prompt.expected.md @@ -186,7 +186,7 @@ interface ToolArgsMap { schedule_create: { /** Reminder content to present when the target becomes due. */ prompt: string; - /** Task name of at most 120 characters, shown on the task card and in task lists. */ + /** Concise task name in the user's language, at most 120 characters. Name only the core action and subject; keep essential target identifiers such as PR numbers and branch names. Do not include execution frequency (e.g. daily, hourly), scheduled dates/times, time zones, task status, or configuration and execution details (e.g. read-only mode, profile-tag filters), even when embedded in the requested or existing title. Do not append parenthetical notes. Put scheduling information in the timing parameters and execution instructions in prompt. */ title: string; /** Delay in whole seconds. */ after_seconds?: number; @@ -233,7 +233,7 @@ interface ToolArgsMap { schedule_update: { /** Schedule id returned by schedule_list. */ id: string; - /** New task name of at most 120 characters. */ + /** New task name; follow the schedule_create title rules. */ title?: string; /** New reminder content. */ prompt?: string; diff --git a/snapshots/web/ptc-round/system-prompt.expected.md b/snapshots/web/ptc-round/system-prompt.expected.md index d52e523e18..13222409c8 100644 --- a/snapshots/web/ptc-round/system-prompt.expected.md +++ b/snapshots/web/ptc-round/system-prompt.expected.md @@ -186,7 +186,7 @@ interface ToolArgsMap { schedule_create: { /** Reminder content to present when the target becomes due. */ prompt: string; - /** Task name of at most 120 characters, shown on the task card and in task lists. */ + /** Concise task name in the user's language, at most 120 characters. Name only the core action and subject; keep essential target identifiers such as PR numbers and branch names. Do not include execution frequency (e.g. daily, hourly), scheduled dates/times, time zones, task status, or configuration and execution details (e.g. read-only mode, profile-tag filters), even when embedded in the requested or existing title. Do not append parenthetical notes. Put scheduling information in the timing parameters and execution instructions in prompt. */ title: string; /** Delay in whole seconds. */ after_seconds?: number; @@ -233,7 +233,7 @@ interface ToolArgsMap { schedule_update: { /** Schedule id returned by schedule_list. */ id: string; - /** New task name of at most 120 characters. */ + /** New task name; follow the schedule_create title rules. */ title?: string; /** New reminder content. */ prompt?: string; From 879ad372ac96e688d788737d41b3f76d16276878 Mon Sep 17 00:00:00 2001 From: turtle1999 Date: Tue, 13 Oct 2026 22:15:57 +0800 Subject: [PATCH 13/35] feat(hmr): replace plugins whose package files changed during profile recomposition With HMR, installing, enabling, disabling and removing bundles take effect live. Each profile recomposition compares the loaded modules below the profile directory and its linked targets with disk, stops the plugins whose files changed, and recomposes the profile through app-boot's recomposeProfile(): the rows the next generation drops are removed first, the latest runtime resolution is published once, and the generation is applied and judged. Afterwards HMR evicts the modules below the mappings the recomposition dropped. - app-boot: recomposeProfile(); PluginPackages.resolution; ResolutionRestartRequired; the resolver accepts in-place version changes and relinks, and relocates Node's cached link real paths. - plugin-manager: with HMR, installBundle refuses an installed package (already-installed), so an upgrade is a removal and an installation; every restart-required result carries a restartReason; removal uses the registry the package was installed from. - ui-plugin-manager: the Official Update removes and then installs; restart prompts name their reason. --- ...9-profile-resolution-generations.i18n.yaml | 4 +- ...26-09-09-profile-resolution-generations.md | 2 +- ...09-09-profile-resolution-generations.zh.md | 2 +- ...efresh-and-manifest-invalidation.i18n.yaml | 20 +- ...ckage-refresh-and-manifest-invalidation.md | 24 +- ...ge-refresh-and-manifest-invalidation.zh.md | 24 +- ...package-modules-on-recomposition.i18n.yaml | 34 + ...hanged-package-modules-on-recomposition.md | 86 ++ ...ged-package-modules-on-recomposition.zh.md | 86 ++ apps/web/tests/plugin-hmr-lifecycle.e2e.ts | 250 ++++ apps/web/tests/plugin-manager.e2e.ts | 2 +- apps/web/tests/preview-boot.e2e.ts | 3 +- apps/web/tsconfig.json | 1 + docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- docs/event-producer-consumer.md | 12 +- docs/event-producer-consumer.zh.md | 12 +- docs/subsystems/boot.i18n.yaml | 3 + docs/subsystems/boot.md | 46 + docs/subsystems/boot.zh.md | 46 + docs/tool-catalog.i18n.yaml | 4 +- docs/tool-catalog.md | 2 +- docs/tool-catalog.zh.md | 2 +- .../guide.i18n.yaml | 16 + .../plugin-install-over-installed/guide.md | 20 + .../plugin-install-over-installed/guide.zh.md | 20 + packages/api/remotes/src/client/index.ts | 3 +- packages/boot/app-boot/README.i18n.yaml | 12 +- packages/boot/app-boot/README.md | 8 +- packages/boot/app-boot/README.zh.md | 8 +- packages/boot/app-boot/src/index.ts | 304 +++- .../src/profile-resolution/resolver.ts | 189 ++- .../src/profile-resolution/service.ts | 7 +- packages/boot/app-boot/tests/app-boot.spec.ts | 5 +- .../tests/profile-resolution-service.spec.ts | 13 +- .../app-boot/tests/profile-resolution.spec.ts | 201 ++- .../boot/app-boot/tests/user-patches.spec.ts | 5 +- packages/boot/hmr/README.i18n.yaml | 12 +- packages/boot/hmr/README.md | 23 +- packages/boot/hmr/README.zh.md | 23 +- packages/boot/hmr/src/index.ts | 511 ++++++- packages/boot/hmr/src/module-files.ts | 124 ++ packages/boot/hmr/tests/module-files.spec.ts | 24 + .../boot/hmr/tests/package-reload.spec.ts | 1280 +++++++++++++++++ packages/boot/plugin-manager/README.i18n.yaml | 20 +- packages/boot/plugin-manager/README.md | 11 +- packages/boot/plugin-manager/README.zh.md | 13 +- packages/boot/plugin-manager/src/index.ts | 145 +- packages/boot/plugin-manager/src/tools.ts | 83 +- packages/boot/plugin-manager/src/types.ts | 13 +- .../boot/plugin-manager/tests/manager.spec.ts | 98 +- .../tests/package-reload.spec.ts | 245 +++- .../boot/plugin-manager/tests/tools.spec.ts | 22 + .../client/ui-plugin-manager/README.i18n.yaml | 4 +- packages/client/ui-plugin-manager/README.md | 2 +- .../client/ui-plugin-manager/README.zh.md | 2 +- .../src/client/PluginManagerPage.tsx | 7 +- .../ui-plugin-manager/src/client/locales.ts | 20 +- .../src/client/manager-store.ts | 29 +- .../src/client/presentation.ts | 26 +- .../tests/components.client.spec.tsx | 11 +- .../tests/manager-store.client.spec.ts | 39 +- .../extensions/tool-cordis/src/api-catalog.ts | 23 +- scripts/gen-cordis-catalog.ts | 1 + scripts/type-equiv.manifest.json | 10 + .../tool-schemas.expected.json | 4 +- .../session/plugin-manager/session.v4.jsonl | 4 +- .../plugin-manager/tool-schemas.expected.json | 2 +- tsconfig.host.json | 1 + 69 files changed, 3872 insertions(+), 440 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-10-01-replace-changed-package-modules-on-recomposition.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-10-01-replace-changed-package-modules-on-recomposition.md create mode 100644 .agents/notes/implemented/architecture/2026-10-01-replace-changed-package-modules-on-recomposition.zh.md create mode 100644 apps/web/tests/plugin-hmr-lifecycle.e2e.ts create mode 100644 docs/upgrade-guide/v0.2.1-alpha.2/plugin-install-over-installed/guide.i18n.yaml create mode 100644 docs/upgrade-guide/v0.2.1-alpha.2/plugin-install-over-installed/guide.md create mode 100644 docs/upgrade-guide/v0.2.1-alpha.2/plugin-install-over-installed/guide.zh.md create mode 100644 packages/boot/hmr/src/module-files.ts create mode 100644 packages/boot/hmr/tests/module-files.spec.ts create mode 100644 packages/boot/hmr/tests/package-reload.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.i18n.yaml index 5cadfbef6f..8d31b237e6 100644 --- a/.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.i18n.yaml @@ -33,8 +33,8 @@ en: 99308bd758859e02 zh: 35f1e7719fe80d2e /agent-note-add-immutable-profile-resolution-generations/decision/additive-package-changes: - en: 0e5e2006caec3c91 - zh: fb3b61dd5692d2e3 + en: 39d27cff0d419259 + zh: 6cbdc88a450614d0 /agent-note-add-immutable-profile-resolution-generations/decision/filesystem-and-runtime-carriers: en: b0607713102a093d zh: 2de9c33bfaa0e3e2 diff --git a/.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.md b/.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.md index d933a4507e..08aeffa559 100644 --- a/.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.md +++ b/.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.md @@ -107,7 +107,7 @@ New Workers inherit the latest published generation. Existing Workers keep the g A caller adding a package completes its pnpm transaction before constructing a successor generation. Replacement rejects any generation that changes the directory or version of a retained package. Plugin Manager publishes the successor before mounting the new Loader row. A mount failure may leave the package installed but inactive. -A retained package keeps its normalized directory, version, and scope; installation entries also keep their declaring anchor and cannot be removed. Violating these restrictions requires process restart because Node's ESM Module Map, CommonJS cache, existing object references, and running Workers can retain the old module identity. A profile entry may change its declaring anchor: deselecting the first bundle that reaches a shared dependency can leave another bundle selecting the same physical package. Profile-scoped mappings and profile-local package names may be removed; the caller stops their plugins before deleting package files. These restrictions do not prohibit removing a linked root from the interception scope. Generation replacement does not claim to unload modules. +A retained package keeps its normalized directory, version, and scope; installation entries also keep their declaring anchor and cannot be removed. Violating these restrictions requires process restart because Node's ESM Module Map, CommonJS cache, existing object references, and running Workers can retain the old module identity. A profile entry may change its declaring anchor: deselecting the first bundle that reaches a shared dependency can leave another bundle selecting the same physical package. Profile-scoped mappings and profile-local package names may be removed; the caller stops their plugins before deleting package files. A profile mapping moves to another directory only through removal and a new installation; a linked root may point to another target ([replacement Note](2026-10-01-replace-changed-package-modules-on-recomposition.md)). These restrictions do not prohibit removing a linked root from the interception scope. Generation replacement does not claim to unload modules. ### Filesystem and runtime carriers diff --git a/.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.zh.md b/.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.zh.md index 24da72defb..cf63626c6a 100644 --- a/.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.zh.md @@ -107,7 +107,7 @@ runtime resolution 列出它提供的包;Loader entries 组成活动插件列 添加包的调用方先完成 pnpm 事务,再构造下一代。替换操作会拒绝改变任何保留包的目录或版本。Plugin Manager 先发布后继 generation,再挂载新的 Loader 配置项。挂载失败可以留下已安装但未启用的包。 -保留包的规范化目录、版本和作用域必须不变;安装条目还必须保留声明锚点,且不能移除。违反这些限制需要重启,因为 Node 的 ESM Module Map、CommonJS cache、现存对象引用和运行中的 Worker 都可能保留旧模块 identity。profile 条目可以改变声明锚点:取消选入最先找到共享依赖的组合包后,另一个组合包仍可能选中同一物理包。profile 范围的映射和 profile 本地包名可以移除;调用方在删除包文件之前先停止相关插件。这些限制不禁止从拦截范围移除 linked root。generation 换代不声称卸载模块。 +保留包的规范化目录、版本和作用域必须不变;安装条目还必须保留声明锚点,且不能移除。违反这些限制需要重启,因为 Node 的 ESM Module Map、CommonJS cache、现存对象引用和运行中的 Worker 都可能保留旧模块 identity。profile 条目可以改变声明锚点:取消选入最先找到共享依赖的组合包后,另一个组合包仍可能选中同一物理包。profile 范围的映射和 profile 本地包名可以移除;调用方在删除包文件之前先停止相关插件。profile 映射只能通过卸载和重新安装移到其他目录;linked root 可以指向其他目标([替换 Note](2026-10-01-replace-changed-package-modules-on-recomposition.zh.md))。这些限制不禁止从拦截范围移除 linked root。generation 换代不声称卸载模块。 ### 文件系统与运行时载体 diff --git a/.agents/notes/implemented/architecture/2026-09-30-profile-package-refresh-and-manifest-invalidation.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-30-profile-package-refresh-and-manifest-invalidation.i18n.yaml index 0187f43093..aa017c7b03 100644 --- a/.agents/notes/implemented/architecture/2026-09-30-profile-package-refresh-and-manifest-invalidation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-30-profile-package-refresh-and-manifest-invalidation.i18n.yaml @@ -12,23 +12,23 @@ en: a15c7c13f167e5ac zh: a15c7c13f167e5ac /agent-note-refresh-runtime-resolution-after-package-operations-and-expire-hmr-package-configuration/decision/responsibilities: - en: 3ffd0c78fa204b04 - zh: f468bab00a7ff357 + en: d53a850a2deabc08 + zh: 01c2f735f2a9562a /agent-note-refresh-runtime-resolution-after-package-operations-and-expire-hmr-package-configuration/decision/runtime-resolution: - en: ca45b973e1968533 - zh: 002f23bb70e4fed7 + en: 42a3ed903fbb5483 + zh: 7e1f6df1137794c3 /agent-note-refresh-runtime-resolution-after-package-operations-and-expire-hmr-package-configuration/decision/plugin-manager: - en: 9b04926c175f22e8 - zh: fbf05dd4065b0738 + en: 7ec1ad2a691bbd12 + zh: 01057bbe0240056a /agent-note-refresh-runtime-resolution-after-package-operations-and-expire-hmr-package-configuration/decision/hmr-package-configuration-expiry: en: c7de8dafe47a5587 zh: 6010a70d8451edd1 /agent-note-refresh-runtime-resolution-after-package-operations-and-expire-hmr-package-configuration/decision/future-work: - en: 31818f8f7f80470b - zh: ecb7ac85bb49cf6d + en: 8b5faf72418a43dd + zh: 3d676c9b54697a2a /agent-note-refresh-runtime-resolution-after-package-operations-and-expire-hmr-package-configuration/alternatives-considered: - en: 7c1c6d21570cda0d - zh: e6f8334c85f1b404 + en: f04a27b3525180b0 + zh: 9f3a01d0400c5a88 /agent-note-refresh-runtime-resolution-after-package-operations-and-expire-hmr-package-configuration/verification: en: 939cedceed200cb2 zh: c7bb02d0da400c8d diff --git a/.agents/notes/implemented/architecture/2026-09-30-profile-package-refresh-and-manifest-invalidation.md b/.agents/notes/implemented/architecture/2026-09-30-profile-package-refresh-and-manifest-invalidation.md index b1b86be199..83612801d2 100644 --- a/.agents/notes/implemented/architecture/2026-09-30-profile-package-refresh-and-manifest-invalidation.md +++ b/.agents/notes/implemented/architecture/2026-09-30-profile-package-refresh-and-manifest-invalidation.md @@ -17,9 +17,9 @@ Two pieces of Host state did not follow changes while the process ran: | Owner | Does | Does not | |---|---|---| -| resolver (`app-boot/src/profile-resolution/resolver.ts`) | `replace()` allows removing `scope: profile` entries and profile-local names | Touch any Node cache | -| plugin-manager | Calls `pluginPackages.refresh()` at fixed points of package operations | Construct resolutions or manage module and package-configuration caches | -| HMR (`packages/boot/hmr`) | Expires package configuration and preserves ordinary reloads for manifests loaded as JSON modules | Reload plugins for configuration-only manifests; handle `node_modules` package replacement | +| resolver (`app-boot/src/profile-resolution/resolver.ts`) | `replace()` allows removing `scope: profile` entries and profile-local names, and changing a retained profile entry's version in place | Touch any Node cache | +| plugin-manager | Without HMR, calls `pluginPackages.refresh()` at fixed points of package operations; with HMR, leaves publication to its recomposition | Construct resolutions or manage module and package-configuration caches | +| HMR (`packages/boot/hmr`) | Expires package configuration and preserves ordinary reloads for manifests loaded as JSON modules; replaces plugins whose loaded package files changed during recomposition ([replacement Note](2026-10-01-replace-changed-package-modules-on-recomposition.md)) | Reload plugins for configuration-only manifests | ### Runtime resolution @@ -29,20 +29,18 @@ Validation of a new generation in `replace()`: - `scope: profile` entries may be removed. - Profile-local package names may be removed. -- Retained profile entries keep their normalized directory, version, and scope, but may change declaring anchor when another selected bundle supplies the same package. Installation mappings and declaring anchors remain unchanged. The profile scope cannot change; a new local name cannot override an existing entry; a published link name cannot select another real directory. +- Retained profile entries keep their normalized directory and scope, but may change declaring anchor when another selected bundle supplies the same package, and may change version when the package is upgraded in place ([replacement Note](2026-10-01-replace-changed-package-modules-on-recomposition.md)). Installation mappings and declaring anchors remain unchanged. The profile scope cannot change; a new local name cannot override an existing entry. A published link name may select another real directory; Node's cached real paths of the previous one are relocated. ### Plugin Manager | Operation | Publication point | |---|---| -| Install a new package | After pnpm succeeds and the bundle selection is saved, before reconciliation, whether or not the bundle is enabled | -| Overwrite an installed package | No publication; the result is `restart-required` | -| Enable a bundle | After the selection is saved, before reconciliation | -| Disable a bundle | With HMR, after reconciliation stops its plugins; without HMR, no publication, and running plugins keep the current table | -| Remove | After pnpm remove succeeds, inside the HMR transaction | -| Failed or cancelled install, failed removal | No publication | +| Install, enable, disable or remove with HMR | Inside HMR's recomposition, after the rows the generation drops stopped and before the generation applies ([replacement Note](2026-10-01-replace-changed-package-modules-on-recomposition.md)) | +| Install a new package or enable a bundle without HMR | After the profile files are saved, before reconciliation | +| Remove without HMR | After pnpm remove succeeds | +| Disable without HMR, an install over an installed package, a failed or cancelled install, a failed removal | No publication; an install over a package the spec names is refused before pnpm runs | -`createRuntimeResolution()` returns a `ProfileRuntimeResolution`, which privately keeps the installation anchor, Harness home, and profile directory it was computed from. Its `computeLatestResolution()` rereads that profile with the same inputs and returns a new object for the latest generation, leaving the original unchanged; Workers receive only the table fields. `PluginPackages.refresh()` calls the current resolution's `computeLatestResolution()` and then `replace()`; a resolution constructed as plain data cannot be refreshed and makes the call throw. +`createRuntimeResolution()` returns a `ProfileRuntimeResolution`, which privately keeps the installation anchor, Harness home, and profile directory it was computed from. Its `computeLatestResolution()` rereads that profile with the same inputs and returns a new object for the latest generation, leaving the original unchanged; Workers receive only the table fields. `PluginPackages.refresh()` calls the current resolution's `computeLatestResolution()` and then `replace()`; a resolution constructed as plain data cannot be refreshed and makes the call throw. app-boot's `recomposeProfile()` first stops the rows the generation no longer lists, so their plugins dispose while their packages are still mapped, then publishes the latest generation once; a table that is already published is not published again. Without HMR, deselecting a startup bundle does not stop its plugins. While such a bundle remains deselected, later package operations keep the existing runtime table instead of publishing its removal. Other no-HMR installations and removals of non-running bundles still publish normally. @@ -79,7 +77,7 @@ The binding's `getNearestParentPackageJSON` is called only by `package_json_read ### Future Work -- Online replacement inside `node_modules`, same-path reinstall, changed link targets, and cross-package reload propagation remain unsupported. Package updates need process restart; this does not guarantee that every management result already reports that requirement correctly. +- A retained profile mapping that moves to another directory waits for a restart; Plugin Manager moves a package by removing and installing it. In-place replacement inside `node_modules` is handled by the [replacement Note](2026-10-01-replace-changed-package-modules-on-recomposition.md). - TSX versions using an asynchronous loader thread keep that thread's package configuration outside these hooks. Synchronizing it is deferred; this change does not provide general Worker cache synchronization. - CommonJS private-request-cache refresh with registered synchronous resolve hooks is deferred. Those requests retain their original loader behavior. - Disposing HMR restores the native readers, whose previous cached configuration can become visible again. Preserving invalidation state across HMR replacement is deferred. @@ -90,7 +88,7 @@ The binding's `getNearestParentPackageJSON` is called only by `package_json_read **Reread every observed manifest when runtime resolution publishes (#5496).** This places package-configuration expiry in table publication, decoupled from module caches: overwriting an installed package reports restart, yet the next unrelated publication makes new imports load the new version while the old plugin still runs. Package configuration is content and belongs with module caches, in HMR. -**Let HMR reload packages inside `node_modules`.** It requires finding affected plugins over a module graph that includes `node_modules`, preventing duplicate evaluation of shared libraries and Cordis, and handling missing dynamic-import edges, module side effects, and Workers. It is not done here; those scenarios keep requiring restart. +**Let HMR reload packages inside `node_modules`.** It requires finding affected plugins over a module graph that includes `node_modules`, preventing duplicate evaluation of shared libraries and Cordis, and handling missing dynamic-import edges, module side effects, and Workers. It is not done here; the [replacement Note](2026-10-01-replace-changed-package-modules-on-recomposition.md) adds it at profile recomposition. **Reload every plugin for a package.json change.** Configuration-only manifests do not require module evaluation. Manifests actually imported as JSON modules retain ordinary dependency-driven reloads. diff --git a/.agents/notes/implemented/architecture/2026-09-30-profile-package-refresh-and-manifest-invalidation.zh.md b/.agents/notes/implemented/architecture/2026-09-30-profile-package-refresh-and-manifest-invalidation.zh.md index 9ee201f27a..6f19b5fa4a 100644 --- a/.agents/notes/implemented/architecture/2026-09-30-profile-package-refresh-and-manifest-invalidation.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-30-profile-package-refresh-and-manifest-invalidation.zh.md @@ -17,9 +17,9 @@ Host 运行期间有两处状态没有随变化更新: | 负责方 | 做什么 | 不做什么 | |---|---|---| -| resolver(`app-boot/src/profile-resolution/resolver.ts`) | `replace()` 允许移除 `scope: profile` 条目和 profile 本地包名 | 不碰 Node 的任何缓存 | -| plugin-manager | 在包操作的确定时点调用 `pluginPackages.refresh()` | 不构造 resolution;不管模块缓存与包配置缓存 | -| HMR(`packages/boot/hmr`) | 使包配置失效,并保留作为 JSON 模块加载的 manifest 的普通重载 | 不因仅作为配置的 manifest 重载插件;不处理 `node_modules` 包替换 | +| resolver(`app-boot/src/profile-resolution/resolver.ts`) | `replace()` 允许移除 `scope: profile` 条目和 profile 本地包名,并允许原地更改保留的 profile 条目的版本 | 不碰 Node 的任何缓存 | +| plugin-manager | 没有 HMR 时,在包操作的确定时点调用 `pluginPackages.refresh()`;有 HMR 时,把发布交给其重新组合 | 不构造 resolution;不管模块缓存与包配置缓存 | +| HMR(`packages/boot/hmr`) | 使包配置失效,并保留作为 JSON 模块加载的 manifest 的普通重载;重新组合时替换已加载包文件发生变化的插件([替换说明](2026-10-01-replace-changed-package-modules-on-recomposition.zh.md)) | 不因仅作为配置的 manifest 重载插件 | ### runtime resolution @@ -29,20 +29,18 @@ Host 运行期间有两处状态没有随变化更新: - `scope: profile` 的条目可以移除。 - profile 本地包名可以移除。 -- 保留的 profile 条目保持规范化目录、版本与作用域不变;另一个已选 bundle 提供同一包时,可以更换声明锚点。安装范围的映射与声明锚点保持不变。profile 范围不能变;新本地包名不能覆盖已有条目;已发布的 link 名称不能换真实目录。 +- 保留的 profile 条目保持规范化目录与作用域不变;另一个已选 bundle 提供同一包时,可以更换声明锚点;包被原地升级时,可以更改版本([替换说明](2026-10-01-replace-changed-package-modules-on-recomposition.zh.md))。安装范围的映射与声明锚点保持不变。profile 范围不能变;新本地包名不能覆盖已有条目。已发布的 link 名称可以选中其他真实目录;Node 缓存的原目录真实路径会被重新定位。 ### plugin-manager | 操作 | 发布时点 | |---|---| -| 安装新包 | pnpm 成功、保存 bundle 选择之后,reconciliation 之前;无论是否立即启用都发布 | -| 覆盖已安装的包 | 不发布,结果为 `restart-required` | -| 启用 bundle | 保存选择后发布,再做 reconciliation | -| 停用 bundle | 有 HMR 时,reconciliation 让插件退出后再发布;没有 HMR 时不发布,运行中的插件继续使用原表 | -| 卸载 | pnpm remove 成功后,在 HMR 事务内发布 | -| 安装失败、取消、卸载失败 | 不发布 | +| 有 HMR 时的安装、启用、停用或卸载 | 在 HMR 的重新组合内,新一代丢弃的行停止之后、新一代应用之前([替换说明](2026-10-01-replace-changed-package-modules-on-recomposition.zh.md)) | +| 没有 HMR 时安装新包或启用 bundle | 保存 profile 文件之后,reconciliation 之前 | +| 没有 HMR 时卸载 | pnpm remove 成功后 | +| 没有 HMR 时停用、覆盖已安装的包、安装失败或取消、卸载失败 | 不发布;spec 指明已安装的包时,覆盖安装在 pnpm 运行之前被拒绝 | -`createRuntimeResolution()` 返回 `ProfileRuntimeResolution`,它私有保存构造时的 installAnchor、home 和 profile 目录;`computeLatestResolution()` 用同一组输入重新读取 profile,返回表示最新代的新对象,原实例不变;Worker 只收到表数据。`PluginPackages.refresh()` 调用当前 resolution 的 `computeLatestResolution()` 再 `replace()`;以纯数据构造的 resolution 无法刷新,调用时抛错。 +`createRuntimeResolution()` 返回 `ProfileRuntimeResolution`,它私有保存构造时的 installAnchor、home 和 profile 目录;`computeLatestResolution()` 用同一组输入重新读取 profile,返回表示最新代的新对象,原实例不变;Worker 只收到表数据。`PluginPackages.refresh()` 调用当前 resolution 的 `computeLatestResolution()`,再调用 `replace()`;以纯数据构造的 resolution 无法刷新,调用时抛错。app-boot 的 `recomposeProfile()` 先停止新一代不再列出的行,使其插件在包仍有映射时完成释放,再发布一次最新一代;已发布的表不会再次发布。 没有 HMR 时,取消启动 bundle 的选择不会停止它的插件。只要该 bundle 仍未选中,后续包操作就保留当前 runtime 表,不发布其移除。其他无 HMR 的安装和未运行 bundle 卸载仍正常发布。 @@ -79,7 +77,7 @@ binding 的 `getNearestParentPackageJSON` 只被 `package_json_reader` 自己调 ### 后续工作 -- 不支持 `node_modules` 内在线替换、同路径重装、link 换目标及跨包重载传播。包更新需要重启进程;这不保证每个管理操作的结果都已正确报告这一要求。 +- 移动到其他目录的保留 profile 映射需要等待重启;Plugin Manager 通过卸载再安装来移动包。`node_modules` 内的原地替换由[替换说明](2026-10-01-replace-changed-package-modules-on-recomposition.zh.md)处理。 - 使用异步 loader 线程的 TSX 版本,其线程内包配置不受这些 hook 管理。线程同步留待后续;本次不提供通用 Worker 缓存同步。 - 注册了同步 resolve hooks 时,CommonJS 私有请求缓存刷新留待后续;这些请求保留原有 loader 行为。 - 销毁 HMR 会恢复原生 reader,其此前缓存的配置可能重新可见。跨 HMR 替换保留失效状态留待后续。 @@ -90,7 +88,7 @@ binding 的 `getNearestParentPackageJSON` 只被 `package_json_reader` 自己调 **在 runtime resolution 发布时全量重读已见过的 manifest(#5496)。** 它把包配置失效放进了绑定表发布,和模块缓存脱了钩:覆盖已安装的包时报需要重启,但下一次无关的发布会让新导入拿到新版本,旧插件仍在运行。包配置缓存属于内容,应该和模块缓存由同一方负责,也就是 HMR。 -**让 HMR 支持 `node_modules` 内的包重载。** 这需要沿包含 `node_modules` 的模块图确定受影响插件,防止共享库和 Cordis 被重复求值,还要处理动态 import 漏边、模块副作用和 Worker。本次不做,这些场景继续要求重启。 +**让 HMR 支持 `node_modules` 内的包重载。** 这需要沿包含 `node_modules` 的模块图确定受影响插件,防止共享库和 Cordis 被重复求值,还要处理动态 import 漏边、模块副作用和 Worker。本次不做;[替换说明](2026-10-01-replace-changed-package-modules-on-recomposition.zh.md)在 profile 重新组合时加入了这一能力。 **package.json 变化时重载所有插件。** 仅作为配置的 manifest 不要求模块求值。实际作为 JSON 模块导入的 manifest 保留普通的依赖驱动重载。 diff --git a/.agents/notes/implemented/architecture/2026-10-01-replace-changed-package-modules-on-recomposition.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-01-replace-changed-package-modules-on-recomposition.i18n.yaml new file mode 100644 index 0000000000..576319b255 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-01-replace-changed-package-modules-on-recomposition.i18n.yaml @@ -0,0 +1,34 @@ +# Bilingual-pair consistency record for 2026-10-01-replace-changed-package-modules-on-recomposition.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-01-replace-changed-package-modules-on-recomposition.md +/agent-note-replace-plugins-whose-loaded-package-files-changed-during-profile-recomposition: + en: 72fe06d4e304418d + zh: bc3c96e957889bc1 +/agent-note-replace-plugins-whose-loaded-package-files-changed-during-profile-recomposition/problem: + en: c43fb0d2df54b28f + zh: 6636548449663fc5 +/agent-note-replace-plugins-whose-loaded-package-files-changed-during-profile-recomposition/decision: + en: a15c7c13f167e5ac + zh: a15c7c13f167e5ac +/agent-note-replace-plugins-whose-loaded-package-files-changed-during-profile-recomposition/decision/one-recomposition-path: + en: f2874c3fc946cf7a + zh: 4c32b9f94e1f9709 +/agent-note-replace-plugins-whose-loaded-package-files-changed-during-profile-recomposition/decision/detecting-changed-modules: + en: 1fedf2dde87f41bf + zh: c2249267d1c2bc78 +/agent-note-replace-plugins-whose-loaded-package-files-changed-during-profile-recomposition/decision/replacing-them: + en: 6fe2cbe5aa3a9786 + zh: fe30fbf672ea8c21 +/agent-note-replace-plugins-whose-loaded-package-files-changed-during-profile-recomposition/decision/plugin-manager-and-runtime-resolution: + en: 794ed33204d91014 + zh: b08daa2a43cbe3b3 +/agent-note-replace-plugins-whose-loaded-package-files-changed-during-profile-recomposition/alternatives-considered: + en: a67d0c6bc4957060 + zh: 32e02ab45b8e71cc +/agent-note-replace-plugins-whose-loaded-package-files-changed-during-profile-recomposition/consequences: + en: 2cb3f39c6a910685 + zh: e14938c868390f39 +/agent-note-replace-plugins-whose-loaded-package-files-changed-during-profile-recomposition/verification: + en: d4750abb810faac2 + zh: 78c9c4d86d719b9b diff --git a/.agents/notes/implemented/architecture/2026-10-01-replace-changed-package-modules-on-recomposition.md b/.agents/notes/implemented/architecture/2026-10-01-replace-changed-package-modules-on-recomposition.md new file mode 100644 index 0000000000..f599d86407 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-01-replace-changed-package-modules-on-recomposition.md @@ -0,0 +1,86 @@ +# Agent Note: Replace plugins whose loaded package files changed during profile recomposition + +Status: implemented + +English | [中文](2026-10-01-replace-changed-package-modules-on-recomposition.zh.md) + +## Problem + +Node caches an evaluated module by file URL. A profile installs packages with pnpm's hoisted layout, so a package lives at `node_modules/` whatever its version. Overwriting, upgrading or reinstalling it keeps every file URL, and a later import or Loader entry restart returns the cached module of the previous version. The [package-refresh Note](2026-09-30-profile-package-refresh-and-manifest-invalidation.md) refreshes runtime resolution and package configuration but leaves this module cache; Plugin Manager therefore reported overwrites as `restart-required`, and a reinstall at the same path after removal reported `applied` while the previous version kept running. A `link:` development package hits the same cache whenever its build output changes, unless its directory is listed in HMR's source watch roots. + +## Decision + +### One recomposition path + +`Hmr.reconcileProfile()` is the profile recomposition used by HMR's profile watches and by Plugin Manager; the Config Editor still recomposes through app-boot directly. It runs inside `runExclusive()`. Plugin Manager only edits profile files and runs pnpm, and refuses to install over an installed package it can name before pnpm runs; another version is a removal and an installation, each with its own recomposition. The steps of a recomposition are: + +1. Read the profile patches and compare loaded modules below the profile directory and its linked package targets with disk. +2. Plan the replacement as below; a patch read or comparison failure throws or is reported before any module changes. +3. Evict the replaceable changed modules and their importers. A changed module whose importers reach the process entry point's static graph or a plugin that no Loader entry owns stays loaded with its importers; the other changes proceed. +4. Stop the Loader entries running the replaced plugins by deleting their runtimes, so the Loader does not record them as disabled. +5. Call app-boot's `recomposeProfile()` once with the entries that ran before the stops. It records the activation state, stops the rows the generation no longer lists by removing them from their groups, publishes the runtime resolution, runs the compatibility preflight against the published packages, and applies the generation. The Loader imports the current files when it starts an entry: rows the generation keeps start once with the new code and their new configuration; rows it disables, refuses or removes stay stopped and never run the new code; an entry it inserts links against the current files. Stopped entries the update did not try to start, below an unchanged Include or outside the profile composition, start afterwards; an entry whose import failed during the update is imported once more. Activation is judged: a stopped entry that failed afterwards fails the operation, while an entry waiting for a service is a warning, since it starts once a provider appears. If no generation applied, the published resolution and the stopped rows are restored, and the stopped entries start again. +6. Evict the loaded modules below the directories of the mappings the recomposition dropped, so a removed or deselected package, including its dynamically imported dependencies, is imported afresh when it returns. A bundle switched off while its package stays mapped keeps its unchanged modules. + +### Detecting changed modules + +[`ModuleFiles`](../../../../packages/boot/hmr/src/module-files.ts) records inode, size and modification time for the loaded ES and CommonJS modules below the profile directory and the real directories of its linked packages. Modules of the dsh installation are outside this scope. Change time is excluded: pnpm imports packages by hard link from its store, and adding a link elsewhere changes a loaded file's change time without changing its content. Records are taken at application readiness, after each recomposition, and for modules a source reload evaluated again. A recomposition records the identities it read before replacing anything, so a write during the recomposition stays detectable; modules first loaded during it are recorded after settlement to avoid an unnecessary restart during the next installation. An edit between first evaluation and that record can be missed; the timestamp fallback still covers modules first loaded outside recomposition. If the readiness observation fails, the fallback starts at HMR construction time, not the Unix epoch. A module is stale when its file disappeared or its identity differs. A module first loaded after the last recording has no record; it is stale when its file was modified at or after that recording, less a 2-second filesystem timestamp resolution, so a reinstall between loading and the next comparison is not missed. Version numbers are not consulted: lockfiles record no content identity for `file:` and `link:` dependencies, and file identity covers registry, tarball, directory and link installs alike. + +### Replacing them + +A stale module whose importer closure reaches the process entry point's static graph, including installed packages, stays loaded and is reported. The remaining stale modules and their static importers, across ES module links and CommonJS `require` children, form the affected set, and every manifest from a stale file up to its profile root or linked target expires through `PackageManifests.invalidate()`. The whole affected set is evicted without a backup before recomposition, so every entry that imports during recomposition reaches the current files. A plugin registered outside a Loader entry has no owner that can start its replacement, so the changes its modules import stay pending; plugins are read from the ES module namespace and from cached CommonJS exports, so a CommonJS plugin registered through `require` keeps its cache too. HMR imports no replacement code itself, so admission is the Loader's ordinary activation: a disabled, removed or refused row never evaluates the new module, and dynamic `disabled` expressions and nested Includes are evaluated as at startup. Every recomposition imports again each enabled entry whose last import failed, after expiring the package configuration below the profile roots and evicting the modules whose links or evaluation failed, which Node keeps cached, so a corrected file or a later-installed dependency takes effect without a restart. A plugin counts as owned only when an affected plugin's own Loader entry fiber starts it; a changed plugin another plugin registers through a dynamic import stays pending. Unchanged dependencies of a replaced plugin are not re-evaluated, so shared libraries keep one instance. Source reloads through `partialReload()` keep their own in-place replacement with rollback. + +`reconcileProfile()` returns `pending`, each stale module that loads only after a restart with its reason (`host-module`, `unmanaged-plugin`, or `module-comparison` when planning failed). Pending modules stay stale, including modules first loaded after the last recording, so the next recomposition retries them. A module first loaded during a recomposition whose file was modified after the comparison also stays stale: activation may have evaluated it before that write. A failure while listing or comparing modules keeps the previous records and sets the profile-wide `restartReason: module-comparison`, even without known module URLs. Neither prevents recomposition. An activation failure, including a replacement that fails to import, is thrown with the Loader's import error; an unchanged failure is returned as a warning by later recompositions. Plugin Manager reports a thrown failure as `failed` with that diagnostic, and turns `pending` into `restart-required` with its `restartReason`, unless the operation reports `overridden`. The Web page localizes the reason, and the agent tool derives a `nextStep` sentence from it, so no surface parses a diagnostic string. + +The [packed worker loader](../../../../packages/experimental/webworker-runtime/src/module-system/module-loader.ts) stores evaluated modules in a private map and its `require` exposes no cache. HMR cannot replace them through Node APIs and reports a restart requirement. Native Node still exposes its caches when using asynchronous module hooks. + +### Plugin Manager and runtime resolution + +Plugin Manager refuses to install over an installed package it can name before pnpm runs, so a new version or location is a removal and an installation: the removal stops the plugins, evicts their modules and drops the mappings, and the installation maps the package afresh. Runtime resolution accepts a retained profile mapping whose version changes while its normalized directory and scope stay, as a shared dependency upgraded by another installation does, and rejects one that moved to another directory. A linked root may point to another directory; a file Node's cached link real path selects below the previous target, for an importer outside it, is relocated below the current one. Rejected publications, including changes to installation mappings or local overrides, leave the resolution and the composition unchanged and report `restart-required` with reason `package-moved`. + +## Alternatives considered + +**A separate package-replacement call from Plugin Manager.** Plugin Manager knows which package it installed, but not which Loader entries import it, and row additions, removals and configuration changes in the new bundle patch must apply in the same step. A second operation would split one profile change into two transactions with an undefined order. Recomposition already reads the new profile; comparing loaded files there covers Plugin Manager, CLI installs followed by a recomposition, and link builds. + +**Compare package versions from the profile manifest or lockfile.** The profile manifest stores specs, not resolved versions, and `pnpm update` can change a version without changing it. Lockfiles carry no content identity for `file:` and `link:` dependencies, and a same-version reinstall of a tarball changes content without changing the version. + +**Let app-boot call HMR from inside its recomposition.** Hooks for the steps before applying and before judging would split the order of one operation between two owners. HMR instead stops everything it replaces first and passes the stopped entries to one app-boot call. + +**Expose the recomposition's stages to HMR.** Separate calls to record activation, prepare after publication, apply, restore and drop mappings let HMR insert its stops between them, but they leave callers ordering obligations that the types cannot express. Stopping before the call costs a restart of the stopped plugins when preparation fails. + +**Publish twice, keeping the dropped mappings while the generation applies.** A temporary table with both the new and the dropped mappings lets one Loader update stop and start entries, but it is a third generation that must stay recomputable when the update fails. Stopping the dropped rows first lets one publication follow them. + +**Overwrite an installed bundle in one operation.** Stopping the bundle before pnpm and starting it afterwards, or detecting moved packages from the loaded module files, keeps one call for an upgrade, but needs a restart path for every pnpm failure, a selection restored in its order, and a release rule for moved directories. Refusing the overwrite leaves a removal and an installation, which the existing operations already run in this order. + +**Preload the replacement and keep the previous plugin when it fails to load.** HMR would import new code before stopping the previous runtime and restore the previous caches on failure. Preloading must not evaluate a row the generation disables, removes or refuses, so HMR would decide admission before the Loader does; dynamic `disabled` expressions and nested Includes cannot be decided without activation, so those changes would need a restart. Rollback must also remove new importers of restored modules, and the previous code would still receive the new configuration. A restart would load the same broken files, so keeping the previous plugin only delays a failure that needs a fix anyway. + +**Restart replaced plugins in place before recomposition.** New code would start with the previous configuration, which an upgraded version may reject, and a row the new composition disables or refuses, for example an incompatible version left on disk by a failed install, would briefly run. + +**Replace running plugins after recomposition.** Recomposition would restart the previous code with the new configuration, which the previous version may reject, so a successful upgrade would be reported as a failure; an entry inserted during recomposition would also link against the previous copy of a changed dependency still cached for a running plugin. + +**Stop affected plugins before pnpm runs.** This prevents previous code from lazily loading files pnpm has already replaced, but adds downtime for the whole install and holds the HMR queue during pnpm. + +**Re-evaluate every dependency of a replaced plugin.** Source reloads reload a plugin's non-`node_modules` dependency tree. Doing the same below `node_modules` would create second instances of unchanged shared libraries and Cordis packages used by other plugins. + +**Record identities in a module load hook.** `module.registerHooks()` could record each file at load time and close the window for modules loaded between recordings, but it adds a hook to every module load and interacts with TSX and the vendored Loader's internals. The timestamp rule only causes an extra reload in that window. + +## Consequences + +- Upgrades through a removal and an installation, and packages whose files changed in place, take effect without a restart when the replacement loads and starts; a replacement that fails to load or start stops the previous plugin and reports `failed` with the error, and it starts once its files or dependencies are corrected. +- Each recomposition stats every loaded file of the profile; the module graph is walked when a file changed, and the host graph is collected on first use. +- Dynamic `import()` edges are not followed: an importer keeps running while its next dynamic import evaluates the new file. Existing Workers and the TSX loader thread keep their caches. +- Replaced Loader entries stop before recomposition and start once afterwards; the previous version does not return. +- Loader entries outside the profile composition restart with replaced code without a new compatibility check ([limitation](../../../../packages/boot/hmr/README.md#known-limitations-and-deferred-work)). +- `ModuleFiles` belongs to one HMR instance. A recomposition that restarts the `hmr` entry itself starts a new instance whose first observation records the current files, so changes pending at that point are no longer reported or retried. +- `pending` describes the whole profile, so a host module brought into scope by a linked workspace package makes later unrelated operations report `restart-required` until the process restarts. +- A file replaced by a hard link to an older store file keeps an older modification time; when its module was first loaded after the last recording, the change is not detected. +- A change confined to `package.json` is not detected. HMR's profile watches do not recompose for dependency-only manifest changes, so installs performed outside Plugin Manager wait for the next recomposition or restart. + +## Verification + +| Coverage | Location | +|---|---| +| In-place upgrades with bundle patch changes, configurations the previous version rejects, inserted entries and disabled rows, plugins without a Loader entry, shared-dependency importers, linked packages, hard links, CommonJS `require` children and their failures, failed replacement and retry, nested groups and Includes, dynamic disabling, same-path reinstall, late-loaded modules, host-graph modules, comparison and recomposition failures, eviction of packages a recomposition stopped using or whose mappings it dropped, and the restart of stopped entries after a rejected publication or failed preparation | `packages/boot/hmr/tests/package-reload.spec.ts` | +| Refused overwrites, removal then installation with HMR, failed installation, relinked and link-to-registry reinstalls, unmanaged plugins, comparison failures | `packages/boot/plugin-manager/tests/package-reload.spec.ts`, `packages/boot/plugin-manager/tests/manager.spec.ts` | +| In-place profile mapping version change, retained directory and scope rejection including a relinked path inside the profiles tree, relinked roots and native lookups relocated below the current target | `packages/boot/app-boot/tests/profile-resolution.spec.ts` | + +Tests need no API key and make no model calls. diff --git a/.agents/notes/implemented/architecture/2026-10-01-replace-changed-package-modules-on-recomposition.zh.md b/.agents/notes/implemented/architecture/2026-10-01-replace-changed-package-modules-on-recomposition.zh.md new file mode 100644 index 0000000000..35ba5c98e9 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-01-replace-changed-package-modules-on-recomposition.zh.md @@ -0,0 +1,86 @@ +# Agent Note: profile 重新组合时替换已加载包文件发生变化的插件 + +Status: implemented + +[English](2026-10-01-replace-changed-package-modules-on-recomposition.md) | 中文 + +## Problem + +Node 按文件 URL 缓存已求值的模块。profile 使用 pnpm 的 hoisted 布局安装包,因此无论版本如何,包都位于 `node_modules/`。覆盖、升级或重装包时所有文件 URL 保持不变,之后的导入或 Loader entry 重启返回的是旧版本的缓存模块。[包刷新说明](2026-09-30-profile-package-refresh-and-manifest-invalidation.zh.md)刷新 runtime resolution 和包配置,但不处理这一模块缓存;因此 Plugin Manager 把覆盖报告为 `restart-required`,而删除后在同一路径重装时报告 `applied`,旧版本却仍在运行。`link:` 开发包的构建输出变化时也会命中同一缓存,除非其目录列在 HMR 的源码监听根目录中。 + +## Decision + +### 唯一的重新组合路径 + +`Hmr.reconcileProfile()` 是 HMR 的 profile 监听和 Plugin Manager 共用的 profile 重新组合入口;Config Editor 仍直接通过 app-boot 重新组合。它在 `runExclusive()` 内运行。Plugin Manager 只编辑 profile 文件并运行 pnpm;对于在运行 pnpm 之前能确定包名的已安装包,它拒绝覆盖安装;安装另一个版本需要先卸载再安装,每一步各有一次重新组合。一次重新组合的步骤如下: + +1. 读取 profile patch,将 profile 目录及其已链接包目标之下的已加载模块与磁盘比较。 +2. 按下文规划替换;patch 读取或比较失败时,在任何模块变化之前抛出错误或报告失败。 +3. 逐出可替换的已变化模块及其导入方。若已变化模块的导入方可到达进程入口的静态依赖图,或可到达没有 Loader entry 拥有的插件,该模块连同其导入方保持加载;其余变化照常处理。 +4. 通过删除运行时停止运行被替换插件的 Loader entry,使 Loader 不把它们记录为已停用。 +5. 以停止之前正在运行的条目调用一次 app-boot 的 `recomposeProfile()`。它记录激活状态,把新一代组合不再列出的行从其所在组中移除,以停止这些行,发布 runtime resolution,基于已发布的包运行兼容性预检,然后应用新一代组合。Loader 在启动条目时导入当前文件:新一代组合保留的行以新代码和新配置启动一次;停用、拒绝或删除的行保持停止,不会运行新代码;新插入的条目链接当前文件。更新未尝试启动的已停止条目(位于未变化 Include 之下或 profile 组合之外)随后启动;更新期间导入失败的条目会再导入一次。随后判定激活结果:之后失败的已停止条目使操作失败;等待服务的条目只是警告,因为出现提供方后它即启动。若没有新一代组合被应用,已发布的 resolution 和已停止的行被恢复,已停止的条目重新启动。 +6. 逐出重新组合删除的映射所在目录之下的已加载模块,使已删除或取消选入的包连同其动态导入的依赖在重新加入时重新导入。关闭组合包而其包仍有映射时,它未变化的模块保持加载。 + +### 检测已变化的模块 + +[`ModuleFiles`](../../../../packages/boot/hmr/src/module-files.ts) 为 profile 目录及其已链接包的真实目录之下已加载的 ES 和 CommonJS 模块记录 inode、大小和修改时间。dsh 安装目录中的模块不在此范围内。不记录状态变更时间:pnpm 通过硬链接从其 store 导入包,在其他位置添加链接会改变已加载文件的状态变更时间,而内容不变。记录发生在应用就绪时和每次重新组合之后;源码重载重新求值模块时,也为这些模块记录。重新组合记录的是它在替换任何模块之前读取的文件身份,因此重新组合期间发生的写入仍可被检测到;在重新组合期间首次加载的模块在结算后记录,避免下一次安装时发生不必要的重启。首次求值与记录之间的修改可能漏检;时间戳兜底仍覆盖重新组合之外首次加载的模块。就绪时的观察失败时,兜底从 HMR 构造时间开始,而非 Unix 纪元。文件已消失或身份不同的模块视为陈旧。在上次记录之后才首次加载的模块没有记录;若其文件的修改时间不早于该次记录时间减去 2 秒文件系统时间戳精度,则视为陈旧,因此不会漏掉在加载与下一次比较之间发生的重装。比较不参考版本号:lockfile 不为 `file:` 和 `link:` 依赖记录内容标识,而文件身份同样覆盖 registry、tarball、目录和 link 安装。 + +### 替换 + +导入方闭包可到达进程入口静态依赖图(包括已安装包)的陈旧模块保持加载,并被报告。其余陈旧模块及其静态导入方(沿 ES 模块链接和 CommonJS `require` 子模块)构成受影响集合,从每个陈旧文件到其 profile 根目录或链接目标的各级 manifest 都通过 `PackageManifests.invalidate()` 失效。整个受影响集合在重新组合之前被逐出且不保留备份,因此重新组合期间执行导入的每个条目都读取当前文件。在 Loader entry 之外注册的插件没有能启动其替换版本的所有者,因此其模块导入的变化保持待处理;插件从 ES 模块命名空间和已缓存的 CommonJS exports 中读取,因此通过 `require` 注册的 CommonJS 插件同样保留其缓存。HMR 本身不导入替换代码,因此是否允许加载由 Loader 的普通激活决定:停用、删除或被拒绝的行永远不会对新模块求值,动态 `disabled` 表达式和嵌套 Include 按启动时的方式求值。每次重新组合都会先让 profile 根目录下的包配置失效,并逐出链接或求值失败的模块(Node 会在缓存中保留这些模块),再次导入上次导入失败的每个已启用条目,因此修正后的文件或之后安装的依赖无需重启即生效。只有当受影响插件自身的 Loader entry fiber 启动某个插件时,该插件才算有所有者;由另一个插件通过动态导入注册的已变化插件保持待处理。被替换插件中未变化的依赖不会重新求值,因此共享库保持单一实例。通过 `partialReload()` 进行的源码重载保留其自身带回滚的原地替换。 + +`reconcileProfile()` 返回 `pending`,即每个只有重启后才能加载的陈旧模块及其原因(`host-module`、`unmanaged-plugin`,规划失败时为 `module-comparison`)。待处理模块保持陈旧,包括在上次记录之后才首次加载的模块,因此下一次重新组合会重试它们。在重新组合期间首次加载、且其文件在比较之后被修改的模块同样保持陈旧:激活可能在该写入之前已对其求值。列出或比较模块时发生的失败会保留原有记录,并设置 profile 级 `restartReason: module-comparison`,即使无法确定模块 URL。两种情况都不会阻止重新组合。激活失败(包括替换版本导入失败)会连同 Loader 的导入错误一起抛出;未变化的失败在后续重新组合中作为警告返回。Plugin Manager 把抛出的失败报告为带该诊断的 `failed`,把 `pending` 转为带其 `restartReason` 的 `restart-required`,除非操作报告 `overridden`。Web 页面本地化该原因,agent 工具据此生成一句 `nextStep`,因此没有任何界面解析诊断字符串。 + +[打包 worker loader](../../../../packages/experimental/webworker-runtime/src/module-system/module-loader.ts) 在私有映射中保存已求值模块,其 `require` 不暴露缓存。HMR 无法通过 Node API 替换它们,因此报告需要重启。原生 Node 使用异步模块 hook 时仍暴露其缓存。 + +### Plugin Manager 与 runtime resolution + +对于在运行 pnpm 之前能确定包名的已安装包,Plugin Manager 拒绝覆盖安装,因此新版本或新位置需要先卸载再安装:卸载停止插件、逐出其模块并删除映射,安装再重新映射该包。runtime resolution 接受保留的 profile 映射在规范化目录和作用域不变时更改版本,即共享依赖被另一次安装升级的情形;移动到其他目录的映射被拒绝。linked root 可以指向其他目录;对于原目标之外的导入方,Node 缓存的链接真实路径若选中原目标之下的文件,该文件会被重新定位到当前目标之下。被拒绝的发布,包括对安装范围映射或本地覆盖的更改,不改变 resolution 和组合,并报告原因是 `package-moved` 的 `restart-required`。 + +## Alternatives considered + +**由 Plugin Manager 单独调用包替换。** Plugin Manager 知道自己安装了哪个包,但不知道哪些 Loader entry 导入了它;新组合包 patch 中的行增加、删除和配置变更也必须在同一步应用。第二个操作会把一次 profile 变更拆成两个顺序未定义的事务。重新组合本来就会读取新的 profile;在此处比较已加载文件,同时覆盖 Plugin Manager、之后执行重新组合的 CLI 安装以及 link 构建。 + +**比较 profile manifest 或 lockfile 中的包版本。** profile manifest 存储的是 spec,不是解析后的版本,`pnpm update` 可以在不修改 spec 的情况下改变版本。lockfile 不为 `file:` 和 `link:` 依赖记录内容标识,而同版本重装 tarball 会改变内容却不改变版本。 + +**让 app-boot 在其重新组合内部调用 HMR。** 为应用之前和判定之前的步骤提供 hook,会把一次操作的顺序拆分给两个所有者。HMR 改为先停止它替换的所有内容,再把已停止的条目传给一次 app-boot 调用。 + +**向 HMR 暴露重新组合的各个阶段。** 分别调用记录激活状态、发布后准备、应用、恢复和丢弃映射,可让 HMR 在其间插入停止操作,但会给调用方留下类型无法表达的顺序义务。在调用之前停止插件,代价是准备失败时需要重新启动已停止的插件。 + +**发布两次,在应用新一代组合期间保留被丢弃的映射。** 同时包含新映射和被丢弃映射的临时表,可以让一次 Loader 更新既停止又启动条目,但它是第三个 generation,更新失败时必须仍能重新计算。先停止被丢弃的行,之后只需发布一次。 + +**在一次操作中覆盖已安装的组合包。** 在 pnpm 运行之前停止该组合包、之后再启动,或根据已加载模块文件检测已移动的包,可以让升级只需一次调用;但这需要为每种 pnpm 失败提供重启路径、按原顺序恢复选择列表,并为已移动的目录制定释放规则。拒绝覆盖安装后只剩卸载和安装,现有操作已按此顺序执行。 + +**预加载替换版本,并在其加载失败时保留原插件。** HMR 将在停止旧运行时之前导入新代码,并在失败时恢复旧缓存。预加载不得对新一代组合停用、删除或拒绝的行求值,因此 HMR 要先于 Loader 决定是否允许加载;动态 `disabled` 表达式和嵌套 Include 不经激活就无法判定,因此这些变化需要重启。回滚还必须删除已恢复模块的新导入方,而旧代码仍会收到新配置。重启会加载相同的损坏文件,因此保留原插件只是推迟一个无论如何都需要修复的失败。 + +**在重新组合之前原地重启被替换的插件。** 新代码会先以旧配置启动,而升级后的版本可能拒绝旧配置;新组合停用或拒绝的行(例如安装失败后留在磁盘上的不兼容版本)也会短暂运行。 + +**在重新组合之后替换运行中的插件。** 重新组合会以新配置重启旧代码,而旧版本可能拒绝新配置,导致一次成功的升级被报告为失败;重新组合期间插入的条目也会链接到仍为运行中插件缓存的、已变化依赖的旧副本。 + +**在 pnpm 运行前停止受影响插件。** 这能防止旧代码惰性加载 pnpm 已经替换的文件,但会让整个安装期间插件不可用,并在 pnpm 运行期间占用 HMR 队列。 + +**重新求值被替换插件的全部依赖。** 源码重载会重载插件在 `node_modules` 之外的依赖树。若对 `node_modules` 之下做同样处理,会为其他插件使用的、未变化的共享库和 Cordis 包创建第二个实例。 + +**在模块加载 hook 中记录文件身份。** `module.registerHooks()` 可以在加载时记录每个文件,消除两次记录之间加载的模块的检测空窗,但这会给每次模块加载增加一个 hook,并与 TSX 和 vendored Loader 的内部机制相互影响。时间戳规则在该空窗内只会导致一次多余的重载。 + +## Consequences + +- 通过卸载再安装进行的升级,以及文件被原地更改的包,在替换版本成功加载并启动时无需重启即生效;替换版本加载或启动失败时,原插件停止并报告 `failed` 及该错误,其文件或依赖被修正后它即启动。 +- 每次重新组合对 profile 的所有已加载文件执行一次 stat;有文件变化时才遍历模块图,宿主依赖图在首次使用时收集。 +- 不跟踪动态 `import()` 边:导入方继续运行,而其下一次动态导入会求值新文件。已存在的 Worker 和 TSX loader 线程保留各自的缓存。 +- 被替换的 Loader entry 在重新组合之前停止,之后启动一次;旧版本不会恢复。 +- profile 组合之外的 Loader entry 以替换后的代码重启,不重新进行兼容性检查([限制](../../../../packages/boot/hmr/README.zh.md#known-limitations-and-deferred-work))。 +- `ModuleFiles` 属于单个 HMR 实例。重启 `hmr` 条目本身的重新组合会启动一个新实例,其首次观察记录当前文件,因此此时待处理的变化不再被报告或重试。 +- `pending` 描述整个 profile,因此由已链接工作区包纳入范围的宿主模块,会使后续无关操作也报告 `restart-required`,直到进程重启。 +- 若文件被替换为指向较旧 store 文件的硬链接,它保留较旧的修改时间;当其模块在上次记录之后才首次加载时,该变化不会被检测到。 +- 仅限于 `package.json` 的变化不会被检测到。HMR 的 profile 监听不会因仅涉及依赖的 manifest 变化而重新组合,因此在 Plugin Manager 之外执行的安装要等到下一次重新组合或重启。 + +## Verification + +| 覆盖面 | 位置 | +|---|---| +| 伴随组合包 patch 变更的原地升级、旧版本拒绝的配置、新插入的条目和停用行、没有 Loader entry 的插件、共享依赖的导入方、已链接包、硬链接、CommonJS `require` 子模块及其失败、替换失败与重试、嵌套 group 和 Include、动态停用、同路径重装、延后加载的模块、宿主依赖图中的模块、比较失败与重新组合失败、逐出重新组合不再使用或已删除其映射的包,以及发布被拒绝或准备失败后已停止条目的重启 | `packages/boot/hmr/tests/package-reload.spec.ts` | +| 被拒绝的覆盖安装、有 HMR 时先卸载再安装、安装失败、重新链接和从链接改为 registry 的重装、非托管插件、比较失败 | `packages/boot/plugin-manager/tests/package-reload.spec.ts`、`packages/boot/plugin-manager/tests/manager.spec.ts` | +| profile 映射原地更改版本、保留目录和作用域的拒绝(包括 profiles 树内重新链接的路径)、重新链接的 root 以及被重新定位到当前目标之下的原生查询 | `packages/boot/app-boot/tests/profile-resolution.spec.ts` | + +测试不需要 API key,不调用模型。 diff --git a/apps/web/tests/plugin-hmr-lifecycle.e2e.ts b/apps/web/tests/plugin-hmr-lifecycle.e2e.ts new file mode 100644 index 0000000000..966e60548b --- /dev/null +++ b/apps/web/tests/plugin-hmr-lifecycle.e2e.ts @@ -0,0 +1,250 @@ +// Web e2e scenario: the plugin lifecycle a user drives through the plugin manager, with real pnpm on local fixture +// packages and the live profile's HMR. The page installs, enables, disables, uninstalls, and reinstalls; upgrades go +// through the manager methods behind the agent's plugin_manager tool, removing and then installing. Every step checks which +// plugin version runs through the trace its apply and disposal append, and that a change HMR cannot apply asks for a +// restart or names its error. +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { chromium, type Browser, type Locator, type Page } from 'playwright' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import type { ChangeResult } from '@deepseek-ai/dsh-plugin-manager' +import { launchWebScaffold, watchConsole, type WebScaffold } from './scaffold.ts' +import { ZH_BROWSER_LOCALE } from './support.ts' + +const NAME = 'e2e-hmr-addon' + +/** Real pnpm with its store, cache, and metadata under a test directory and no registry access. */ +function isolatedPnpm(scratch: string) { + return { + command: 'pnpm', args: [], + // pnpm 11 reads settings from pnpm_config_* variables. + env: { pnpm_config_store_dir: join(scratch, 'pnpm-store'), pnpm_config_cache_dir: join(scratch, 'pnpm-cache'), pnpm_config_offline: 'true' }, + } +} + +/** The page's toast or inline alert whose text contains the given words. */ +function alertWith(page: Page, text: string): Locator { + return page.getByRole('alert').filter({ hasText: text }) +} + +describe('web e2e: plugin lifecycle with HMR', () => { + let scratch: string + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + let tracePath: string + + /** Write one version of the fixture bundle, whose only row is its own plugin, into a directory of its own. */ + async function version(label: string, body = ''): Promise { + const dir = join(scratch, 'sources', label) + await mkdir(dir, { recursive: true }) + await writeFile(join(dir, 'package.json'), JSON.stringify({ + name: NAME, version: `${label.replace(/\D/gu, '') || '0'}.0.0`, type: 'module', main: './index.mjs', + dsh: { bundle: { patch: './cordis.patch.yml' } }, + })) + await writeFile(join(dir, 'cordis.patch.yml'), `- insert:\n - id: ${NAME}\n name: ${NAME}\n`) + await writeFile(join(dir, 'index.mjs'), `${body} +import { appendFileSync } from 'node:fs' +export const name = '${NAME}' +export function apply(ctx) { + appendFileSync(${JSON.stringify(tracePath)}, 'start:${label}\\n') + ctx.effect(() => () => { appendFileSync(${JSON.stringify(tracePath)}, 'stop:${label}\\n') }) +} +`) + return dir + } + + /** The trace lines since the last call, after the given ones have arrived. */ + async function traced(expected: string[]): Promise { + await expect.poll(async () => (await readFile(tracePath, 'utf8')).split('\n').filter(Boolean), { timeout: 10_000 }).toEqual(expected) + await writeFile(tracePath, '') + } + + /** The running profile's manager, whose methods the agent's plugin_manager tool calls. */ + function manager() { + return scaffold.ctx.get('pluginManager')! + } + + /** Upgrade as the agent's tool does: an installed package is removed before another version is installed. */ + async function reinstall(spec: string): Promise { + const removed = await manager().removeBundle(NAME) + expect(removed.application, JSON.stringify(removed)).not.toBe('failed') + return manager().installBundle(spec) + } + + async function openPanel(): Promise { + if (await page.getByRole('dialog', { name: '设置' }).count() > 0) await page.keyboard.press('Escape') + const panel = page.locator('[data-plugin-panel]') + const list = panel.getByRole('heading', { name: '插件', exact: true }) + // The sidebar entry toggles the page, so it is pressed only while the page is closed. + if (await panel.count() === 0) { + await page.getByRole('navigation', { name: '全局面板' }).getByRole('button', { name: '插件', exact: true }).click() + } else if (!await list.isVisible()) { + await panel.getByRole('button', { name: '返回插件', exact: true }).click() + } + await list.waitFor({ timeout: 10_000 }) + return panel + } + + /** Install a spec through the page's dialog and switch the bundle on from its finished screen. */ + async function installThroughPage(spec: string): Promise { + const panel = await openPanel() + await panel.getByRole('button', { name: '添加插件', exact: true }).click() + const dialog = page.getByRole('dialog') + await dialog.getByRole('textbox', { name: '包名或地址' }).fill(spec) + await dialog.getByRole('button', { name: '安装', exact: true }).click() + await dialog.getByRole('button', { name: '立即启用', exact: true }).click({ timeout: 30_000 }) + await dialog.waitFor({ state: 'detached', timeout: 10_000 }) + return panel + } + + async function uninstallThroughPage(panel: Locator): Promise { + await panel.getByRole('button', { name: `查看 ${NAME}`, exact: true }).click() + await panel.getByRole('button', { name: `卸载 ${NAME}`, exact: true }).click() + await page.getByRole('dialog', { name: `卸载「${NAME}」?` }).getByRole('button', { name: '卸载', exact: true }).click() + // The page leaves the removed package's details for the list. + await panel.getByRole('heading', { name: '插件', exact: true }).waitFor({ timeout: 30_000 }) + await expect.poll(() => panel.locator(`[data-plugin-package="${NAME}"]`).count(), { timeout: 30_000 }).toBe(0) + } + + async function dependency(): Promise { + const manifest = JSON.parse(await readFile(join(scaffold.harnessHome, 'profiles', 'scaffold', 'package.json'), 'utf8')) as { + dependencies?: Record + } + return manifest.dependencies?.[NAME] + } + + beforeAll(async () => { + scratch = await mkdtemp(join(tmpdir(), 'dsh-plugin-hmr-')) + tracePath = join(scratch, 'trace.log') + await writeFile(tracePath, '') + scaffold = await launchWebScaffold({ profile: { packages: [], packageManager: isolatedPnpm(scratch) } }) + browser = await chromium.launch() + page = await browser.newPage({ viewport: { width: 1440, height: 1000 }, locale: ZH_BROWSER_LOCALE }) + tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl) + await page.waitForSelector('[class*="frame"]') + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + await rm(scratch, { recursive: true, force: true }) + }) + + it('installs, switches off and on, and upgrades without a restart', async () => { + const v1 = await version('v1') + const panel = await installThroughPage(`file:${v1}`) + await traced(['start:v1']) + const toggle = panel.getByRole('switch', { name: `启用 ${NAME}`, exact: true }) + await toggle.click() + await traced(['stop:v1']) + await toggle.click() + await traced(['start:v1']) + // Installing over the installed package is refused; the upgrade removes it first. + const v2 = await version('v2') + expect(await manager().installBundle(`file:${v2}`)).toMatchObject({ application: 'failed', error: { code: 'already-installed' } }) + const upgraded = await reinstall(`file:${v2}`) + expect(upgraded, JSON.stringify(upgraded)).toMatchObject({ application: 'applied' }) + await traced(['stop:v1', 'start:v2']) + // The page reads the upgrade from the Host's change announcement. + await panel.getByRole('button', { name: `查看 ${NAME}`, exact: true }).click() + await panel.getByText('v2.0.0', { exact: true }).waitFor({ timeout: 10_000 }) + await panel.getByRole('button', { name: '返回插件' }).click() + }, 90_000) + + it('stops the running version and names the load error when the upgrade cannot load, then applies the fix', async () => { + const broken = await version('v3', "import 'e2e-hmr-missing-dependency'\n") + const failed = await reinstall(`file:${broken}`) + expect(failed).toMatchObject({ application: 'failed', error: { code: 'operation-error' } }) + expect(failed.error?.diagnostic).toContain('e2e-hmr-missing-dependency') + await traced(['stop:v2']) + // Switching the broken plugin off and on again loads the same files, and the page names the missing dependency. + const panel = await openPanel() + const toggle = panel.getByRole('switch', { name: `启用 ${NAME}`, exact: true }) + await toggle.click() + await expect.poll(() => toggle.getAttribute('aria-checked')).toBe('false') + await toggle.click() + await alertWith(page, 'e2e-hmr-missing-dependency').waitFor({ timeout: 10_000 }) + expect(await alertWith(page, 'e2e-hmr-missing-dependency').textContent()).not.toContain('重启') + await traced([]) + const fixed = await version('v4') + expect(await reinstall(`file:${fixed}`)).toMatchObject({ application: 'applied' }) + await traced(['start:v4']) + }, 90_000) + + it('reports an upgrade that loads but cannot start, then recovers with a working version', async () => { + const throwing = await version('v5') + await writeFile(join(throwing, 'index.mjs'), `export const name = '${NAME}'\nexport function apply() { throw new Error('v5 cannot start') }\n`) + const failed = await reinstall(`file:${throwing}`) + expect(failed).toMatchObject({ application: 'failed', error: { code: 'operation-error' } }) + expect(failed.error?.diagnostic).toContain('v5 cannot start') + await traced(['stop:v4']) + const working = await version('v6') + expect(await reinstall(`file:${working}`)).toMatchObject({ application: 'applied' }) + await traced(['start:v6']) + }, 90_000) + + it('uninstalls and reinstalls the same package with its new code', async () => { + const panel = await openPanel() + await uninstallThroughPage(panel) + await traced(['stop:v6']) + // pnpm removes the package after its plugins stop. + await expect.poll(dependency, { timeout: 30_000 }).toBeUndefined() + const v7 = await version('v7') + await installThroughPage(`file:${v7}`) + await traced(['start:v7']) + await uninstallThroughPage(await openPanel()) + await traced(['stop:v7']) + await expect.poll(dependency, { timeout: 30_000 }).toBeUndefined() + }, 90_000) + + it('starts a plugin linked to another directory without a restart', async () => { + // A bare local path links the package to its directory, as for a plugin under development. + const first = await version('v8') + await installThroughPage(first) + await traced(['start:v8']) + expect(await dependency()).toMatch(/^link:/u) + const moved = await version('v9') + expect(await reinstall(moved)).toMatchObject({ application: 'applied' }) + await traced(['stop:v8', 'start:v9']) + expect(await alertWith(page, '重启').count()).toBe(0) + expect(tripwire.pageErrors).toEqual([]) + }, 90_000) +}) + +describe('web e2e: plugin installation without HMR', () => { + it('asks for a restart after installing into a profile that applies changes at startup', async () => { + const scratch = await mkdtemp(join(tmpdir(), 'dsh-plugin-startup-')) + let scaffold: WebScaffold | undefined + let browser: Browser | undefined + try { + const dir = join(scratch, 'addon') + await mkdir(dir) + await writeFile(join(dir, 'package.json'), JSON.stringify({ + name: NAME, version: '1.0.0', type: 'module', main: './index.mjs', dsh: { bundle: { patch: './cordis.patch.yml' } }, + })) + await writeFile(join(dir, 'cordis.patch.yml'), `- insert:\n - id: ${NAME}\n name: ${NAME}\n`) + await writeFile(join(dir, 'index.mjs'), `export const name = '${NAME}'\nexport function apply() {}\n`) + scaffold = await launchWebScaffold({ profile: { packages: [], hmr: false, packageManager: isolatedPnpm(scratch) } }) + browser = await chromium.launch() + const page = await browser.newPage({ viewport: { width: 1440, height: 1000 }, locale: ZH_BROWSER_LOCALE }) + await page.goto(scaffold.authenticatedUrl) + await page.waitForSelector('[class*="frame"]') + if (await page.getByRole('dialog', { name: '设置' }).count() > 0) await page.keyboard.press('Escape') + await page.getByRole('navigation', { name: '全局面板' }).getByRole('button', { name: '插件', exact: true }).click() + const panel = page.locator('[data-plugin-panel]') + await panel.getByRole('button', { name: '添加插件', exact: true }).click() + const dialog = page.getByRole('dialog') + await dialog.getByRole('textbox', { name: '包名或地址' }).fill(`file:${dir}`) + await dialog.getByRole('button', { name: '安装', exact: true }).click() + await dialog.getByText('已安装,请重启 DSH 加载它。这个 profile 只在启动时应用更改。', { exact: true }).waitFor({ timeout: 30_000 }) + } finally { + await browser?.close() + await scaffold?.close() + await rm(scratch, { recursive: true, force: true }) + } + }, 120_000) +}) diff --git a/apps/web/tests/plugin-manager.e2e.ts b/apps/web/tests/plugin-manager.e2e.ts index 560837cb67..5502602721 100644 --- a/apps/web/tests/plugin-manager.e2e.ts +++ b/apps/web/tests/plugin-manager.e2e.ts @@ -1100,7 +1100,7 @@ describe('web e2e: startup-applied plugin management', () => { // The selection is saved and the switch turns on, but nothing mounts before the next start; a toast says so. await expect.poll(bundles, { timeout: PLUGIN_TOGGLE_SETTLE_MS }).toEqual([...SCAFFOLD_BUNDLES, '@fixture/bundle']) await expect.poll(() => toggle.getAttribute('aria-checked'), { timeout: PLUGIN_TOGGLE_SETTLE_MS }).toBe('true') - await page.getByText('更改将在下次启动生效', { exact: true }).waitFor({ timeout: 10_000 }) + await page.getByText('请重启 DSH 使更改生效。这个 profile 只在启动时应用更改。', { exact: true }).waitFor({ timeout: 10_000 }) expect(mounted()?.fiber?.state).toBeUndefined() // The pack's page lists its rows from their declarations, with no live entry to switch. await panel.getByRole('button', { name: '查看 @fixture/bundle' }).click() diff --git a/apps/web/tests/preview-boot.e2e.ts b/apps/web/tests/preview-boot.e2e.ts index 3a931d8e89..cea516a9de 100644 --- a/apps/web/tests/preview-boot.e2e.ts +++ b/apps/web/tests/preview-boot.e2e.ts @@ -584,8 +584,9 @@ async function bootEmptyPreview(origin: string, browser: Browser): Promise // has no host routes — the controller publishes an empty list and the // header renders no button, which is that surface's designed degradation. expect([...failedResponses].sort()).toEqual(['/open-in-app/apps', '/plugins/events']) + // The worker recomposes configuration but exposes no Node module caches for code replacement. expect(consoleErrors.filter(line => !line.includes('Failed to load resource: the server responded with a status of 404'))) - .toEqual([]) + .toEqual(['hmr: restart required: module-comparison']) } catch (error) { await saveFailureShot(page, 'preview-boot-empty') throw pageErrors.length === 0 diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 3c8d0dfd37..b529ba1697 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -89,6 +89,7 @@ "tests/inspector-source-build.e2e.ts", "tests/plugin-install-cancel.e2e.ts", "tests/plugin-install-approve.e2e.ts", + "tests/plugin-hmr-lifecycle.e2e.ts", "tests/plugin-install-registry.e2e.ts", "tests/plugin-install-github.e2e.ts", "tests/client-plugin-live.e2e.ts", diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 48f0b2c812..6a05ba6bb9 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1437,7 +1437,7 @@ export interface Config { ## `@deepseek-ai/dsh-hmr` - `refs`: `ChokidarOptions` (`chokidar`) -- `source`: [`packages/boot/hmr/src/index.ts:53`](../packages/boot/hmr/src/index.ts) +- `source`: [`packages/boot/hmr/src/index.ts:56`](../packages/boot/hmr/src/index.ts) ```ts config-catalog /** Module roots and watcher timing, with Chokidar deployment options. */ diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index c6b19b388e..80bf1886b4 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -1439,7 +1439,7 @@ export interface Config { ## `@deepseek-ai/dsh-hmr` - `refs`: `ChokidarOptions` (`chokidar`) -- `source`: [`packages/boot/hmr/src/index.ts:53`](../packages/boot/hmr/src/index.ts) +- `source`: [`packages/boot/hmr/src/index.ts:56`](../packages/boot/hmr/src/index.ts) ```ts config-catalog /** Module roots and watcher timing, with Chokidar deployment options. */ diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 4017a84fa3..a54a65bdb7 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -27,7 +27,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:631`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:610`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:617`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:52`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) | +| `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:54`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:87`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | - | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:89`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | @@ -51,14 +51,14 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:59`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`workspace-changes`](../packages/deliverables/workspace-changes) | | `goal/activation-changed` | `emit` | [`packages/goal/goal/src/types.ts:150`](../packages/goal/goal/src/types.ts) | [`goal`](../packages/goal/goal) (`emit`) | `remotes` | | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) | -| `hmr/change` | `emit` | [`packages/boot/hmr/src/index.ts:32`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`emit`) | - | -| `hmr/reload` | `emit` | [`packages/boot/hmr/src/index.ts:37`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`emit`) | - | +| `hmr/change` | `emit` | [`packages/boot/hmr/src/index.ts:35`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`emit`) | - | +| `hmr/reload` | `emit` | [`packages/boot/hmr/src/index.ts:40`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`emit`) | - | | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), `remotes` | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:75`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | | `permission-presets/catalog-changed` | `emit` | [`packages/interaction/permission-presets/src/types.ts:48`](../packages/interaction/permission-presets/src/types.ts) | [`permission-presets`](../packages/interaction/permission-presets) (`events.dispatch`) | `remotes` | -| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:258`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | -| `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:264`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | -| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:271`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | +| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:269`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | +| `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:275`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | +| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:282`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | | `schedule/changed` | `emit` | [`packages/schedule/schedule/src/types.ts:478`](../packages/schedule/schedule/src/types.ts) | [`schedule`](../packages/schedule/schedule) (`emit`) | `remotes` | | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:44`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | | `session/created` | `emit` | [`packages/core/session/src/index.ts:54`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`permission-presets`](../packages/interaction/permission-presets), [`schedule`](../packages/schedule/schedule), `server`, `session-controller`, [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 3d621ea377..faebbe62ff 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -29,7 +29,7 @@ | `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:631`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:610`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:617`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:52`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) | +| `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:54`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:87`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | - | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:89`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | @@ -53,14 +53,14 @@ | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:59`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`workspace-changes`](../packages/deliverables/workspace-changes) | | `goal/activation-changed` | `emit` | [`packages/goal/goal/src/types.ts:150`](../packages/goal/goal/src/types.ts) | [`goal`](../packages/goal/goal) (`emit`) | `remotes` | | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) | -| `hmr/change` | `emit` | [`packages/boot/hmr/src/index.ts:32`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`emit`) | - | -| `hmr/reload` | `emit` | [`packages/boot/hmr/src/index.ts:37`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`emit`) | - | +| `hmr/change` | `emit` | [`packages/boot/hmr/src/index.ts:35`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`emit`) | - | +| `hmr/reload` | `emit` | [`packages/boot/hmr/src/index.ts:40`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`emit`) | - | | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), `remotes` | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:75`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | | `permission-presets/catalog-changed` | `emit` | [`packages/interaction/permission-presets/src/types.ts:48`](../packages/interaction/permission-presets/src/types.ts) | [`permission-presets`](../packages/interaction/permission-presets) (`events.dispatch`) | `remotes` | -| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:258`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | -| `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:264`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | -| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:271`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | +| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:269`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | +| `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:275`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | +| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:282`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | | `schedule/changed` | `emit` | [`packages/schedule/schedule/src/types.ts:478`](../packages/schedule/schedule/src/types.ts) | [`schedule`](../packages/schedule/schedule) (`emit`) | `remotes` | | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:44`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | | `session/created` | `emit` | [`packages/core/session/src/index.ts:54`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`permission-presets`](../packages/interaction/permission-presets), [`schedule`](../packages/schedule/schedule), `server`, `session-controller`, [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry) | diff --git a/docs/subsystems/boot.i18n.yaml b/docs/subsystems/boot.i18n.yaml index 5d10d9774d..f0edbcf9b4 100644 --- a/docs/subsystems/boot.i18n.yaml +++ b/docs/subsystems/boot.i18n.yaml @@ -8,3 +8,6 @@ /profile-management/management-records: en: a1412da0219c2a09 zh: b6869fd548f4c693 +/profile-management/profile-reconciliation: + en: 42a093314af774d4 + zh: 7c1784a3af02b31c diff --git a/docs/subsystems/boot.md b/docs/subsystems/boot.md index 049049957a..8fb78deb28 100644 --- a/docs/subsystems/boot.md +++ b/docs/subsystems/boot.md @@ -18,6 +18,37 @@ The [boot package group](../../packages/boot/README.md) owns launcher-provided p `ChangeResult.changed` reports a disk edit independently of `application`: `applied`, `restart-required`, `overridden` or `failed`. Optional `error` carries a localizable code and external diagnostic. `packageResult` records the pnpm exit code, bounded output, truncation flag and complete diagnostic log path, plus `timedOut` when the manager terminated a run that stopped printing. A terminated run is classified `timeout` whatever exit status the signal left behind, so installation and removal report failure instead of success and no further registry is asked. `pendingBuilds` lists undecided packages across the profile; `approvedBuilds` records the names granted permission by this operation; `registries` lists the registries an installation asked, in order; `bundle` and `version` name the package a finished installation added and its manifest version; `failedAt` says whether the last failed run could not reach the registry it asked or the host a git or tarball spec is fetched from. +## Profile reconciliation + + + +```ts type-equiv +/** Outcome of {@link Hmr.reconcileProfile}. */ +interface ProfileReconciliation { + /** Diagnostics for Loader entries left inactive. */ + warnings: string[] + /** Loaded modules whose files changed on disk and whose previous code keeps running until a restart. */ + pending: PendingModule[] + /** + * Why every change waits for a restart: `module-comparison` when module caches cannot be inspected or their files + * compared, even when no URLs are known; `package-moved` when the runtime resolution cannot publish a moved package. + */ + restartReason?: 'module-comparison' | 'package-moved' +} +``` + +```ts type-equiv +/** A changed module that loads only after a restart. */ +interface PendingModule { + url: string + /** + * `host-module`: the process entry point's static graph imports it. `unmanaged-plugin`: it is imported by a plugin + * that runs outside a traversable Loader entry. `module-comparison`: loaded modules could not be compared with disk. + */ + reason: 'host-module' | 'unmanaged-plugin' | 'module-comparison' +} +``` + @@ -78,6 +109,21 @@ async watchConfig(filename: string, refresh: () => Promise): Promise<() => * @returns Linked module URLs, or an empty list for an uncached module. */ async getLinked(url: string): Promise + +/** + * Recompose the profile from its manifest and patch files, replacing running plugins whose loaded files below the + * profile directory or its linked package targets changed on disk, and publishing its runtime resolution. Changed + * modules are evicted and the Loader entries running them stop; app-boot's {@link recomposeProfile} then publishes the + * resolution, applies the generation, starts the stopped entries with the current files and configuration, + * imports entries whose import failed again, and judges activation. Afterwards the modules below the mappings the + * recomposition dropped are evicted, so a removed or deselected package is imported afresh when it returns. Callers run + * inside {@link runExclusive}. + * @param requiredIds Entry ids whose inactive state is reported as a failure even when it predates this call. + * @returns Inactive-entry diagnostics, the changed modules that load after a restart, and a profile-wide restart reason. + * @throws The patch read, publication, preparation, apply, or activation failure of the recomposition, including a + * replacement plugin that fails to load or start, whose previous code no longer runs. + */ +async reconcileProfile(requiredIds: readonly string[] = []): Promise ``` Source: [`packages/boot/hmr/src/index.ts`](../../packages/boot/hmr/src/index.ts) diff --git a/docs/subsystems/boot.zh.md b/docs/subsystems/boot.zh.md index b2b800f3c9..20e7701f67 100644 --- a/docs/subsystems/boot.zh.md +++ b/docs/subsystems/boot.zh.md @@ -18,6 +18,37 @@ `ChangeResult.changed` 报告磁盘修改,独立于 `application`:`applied`、`restart-required`、`overridden` 或 `failed`。可选的 `error` 包含可本地化的错误码和外部诊断。`packageResult` 记录 pnpm 退出码、有界输出、截断标志及完整诊断日志路径;当管理器终止了一个停止打印的运行,还记录 `timedOut`。被终止的运行不论信号留下什么退出状态都归类为 `timeout`,因此安装与删除都报告失败而非成功,也不会再询问下一个注册表。`pendingBuilds` 列出整个 profile 尚未决定的包;`approvedBuilds` 记录本次操作授予权限的包名;`registries` 按顺序列出一次安装问过的注册表;`bundle` 与 `version` 给出完成的安装新增的包及其清单版本;`failedAt` 说明最后一次失败的运行连不上的是所问的注册表,还是 git 或 tarball spec 自身拉取的主机。 +## Profile 重新组合 + + + +```ts type-equiv +/** Outcome of {@link Hmr.reconcileProfile}. */ +interface ProfileReconciliation { + /** Diagnostics for Loader entries left inactive. */ + warnings: string[] + /** Loaded modules whose files changed on disk and whose previous code keeps running until a restart. */ + pending: PendingModule[] + /** + * Why every change waits for a restart: `module-comparison` when module caches cannot be inspected or their files + * compared, even when no URLs are known; `package-moved` when the runtime resolution cannot publish a moved package. + */ + restartReason?: 'module-comparison' | 'package-moved' +} +``` + +```ts type-equiv +/** A changed module that loads only after a restart. */ +interface PendingModule { + url: string + /** + * `host-module`: the process entry point's static graph imports it. `unmanaged-plugin`: it is imported by a plugin + * that runs outside a traversable Loader entry. `module-comparison`: loaded modules could not be compared with disk. + */ + reason: 'host-module' | 'unmanaged-plugin' | 'module-comparison' +} +``` + @@ -78,6 +109,21 @@ async watchConfig(filename: string, refresh: () => Promise): Promise<() => * @returns Linked module URLs, or an empty list for an uncached module. */ async getLinked(url: string): Promise + +/** + * Recompose the profile from its manifest and patch files, replacing running plugins whose loaded files below the + * profile directory or its linked package targets changed on disk, and publishing its runtime resolution. Changed + * modules are evicted and the Loader entries running them stop; app-boot's {@link recomposeProfile} then publishes the + * resolution, applies the generation, starts the stopped entries with the current files and configuration, + * imports entries whose import failed again, and judges activation. Afterwards the modules below the mappings the + * recomposition dropped are evicted, so a removed or deselected package is imported afresh when it returns. Callers run + * inside {@link runExclusive}. + * @param requiredIds Entry ids whose inactive state is reported as a failure even when it predates this call. + * @returns Inactive-entry diagnostics, the changed modules that load after a restart, and a profile-wide restart reason. + * @throws The patch read, publication, preparation, apply, or activation failure of the recomposition, including a + * replacement plugin that fails to load or start, whose previous code no longer runs. + */ +async reconcileProfile(requiredIds: readonly string[] = []): Promise ``` Source: [`packages/boot/hmr/src/index.ts`](../../packages/boot/hmr/src/index.ts) diff --git a/docs/tool-catalog.i18n.yaml b/docs/tool-catalog.i18n.yaml index 639b01a6e4..af0cd38b46 100644 --- a/docs/tool-catalog.i18n.yaml +++ b/docs/tool-catalog.i18n.yaml @@ -15,8 +15,8 @@ en: e0143916bb634762 zh: e0143916bb634762 /tool-schema-catalog/deepseek-ai-dsh-plugin-manager/plugin-manager: - en: ab4d1e7f7aa435b5 - zh: 70af9245b9b98039 + en: 55b6f02a3ef555cf + zh: 5263303aa6efd86e /tool-schema-catalog/deepseek-ai-dsh-mcp-resources: en: dd78bd55ec83a539 zh: dd78bd55ec83a539 diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 82abbdd2c5..f96fe0802d 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -54,7 +54,7 @@ This table connects model-visible tool names to the plugin package and service s ### `plugin_manager` -List plugins or bundles in the current profile, enable or disable them, install a bundle, or remove an installed bundle. Every action requires danger-full-access permission or approval for this call. Approval does not change the session permission mode. Changes affect every session in this profile. List first to obtain exact identifiers. Package installation can execute allowed build scripts. Live profiles apply changes immediately; startup profiles require restart. Incompatible DSH peer dependencies block installation and activation. Version exemptions risk crashes and data loss: warn the user and obtain explicit permission for the exact plugin and runtime versions before granting one. +List plugins or bundles in the current profile, enable or disable them, install a bundle, or remove an installed bundle. Every action requires danger-full-access permission or approval for this call. Approval does not change the session permission mode. Changes affect every session in this profile. List first to obtain exact identifiers. Package installation can execute allowed build scripts. Live profiles apply changes immediately, including upgrades of installed packages; a change result that is not applied carries nextStep, which says what to do or tell the user. Incompatible DSH peer dependencies block installation and activation. Version exemptions risk crashes and data loss: warn the user and obtain explicit permission for the exact plugin and runtime versions before granting one. ```json { diff --git a/docs/tool-catalog.zh.md b/docs/tool-catalog.zh.md index 1e3dad2606..f7ed4a8c08 100644 --- a/docs/tool-catalog.zh.md +++ b/docs/tool-catalog.zh.md @@ -58,7 +58,7 @@ ### `plugin_manager` -列出当前 profile 中的插件或组合包,启用或禁用它们,安装组合包或移除已安装的组合包。每项操作都要求 danger-full-access 权限或本次调用的批准。批准不改变会话权限模式。变更影响该 profile 的所有会话。先列出条目以获取准确标识。包安装可能运行已获批准的构建脚本。支持热更新的 profile 立即应用变更;仅启动时加载的 profile 需要重启。不兼容的 DSH peer 依赖会阻止安装和激活。版本豁免可能导致崩溃和数据丢失:授权前必须警告用户,并获得用户对精确插件版本与运行时版本组合的明确许可。 +列出当前 profile 中的插件或组合包,启用或禁用它们,安装组合包或移除已安装的组合包。每项操作都要求 danger-full-access 权限或本次调用的批准。批准不改变会话权限模式。变更影响该 profile 的所有会话。先列出条目以获取准确标识。包安装可能运行已获批准的构建脚本。支持热更新的 profile 立即应用变更,包括已安装包的升级;未直接应用的变更结果带有 nextStep,说明下一步要做什么或告诉用户什么。不兼容的 DSH peer 依赖会阻止安装和激活。版本豁免可能导致崩溃和数据丢失:授权前必须警告用户,并获得用户对精确插件版本与运行时版本组合的明确许可。 ```json { diff --git a/docs/upgrade-guide/v0.2.1-alpha.2/plugin-install-over-installed/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-alpha.2/plugin-install-over-installed/guide.i18n.yaml new file mode 100644 index 0000000000..d3693c2a4f --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.2/plugin-install-over-installed/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-alpha.2/plugin-install-over-installed/guide.md +/: + en: 036db2d1004f0aa0 + zh: fb026ec0af7949f6 +/installing-over-an-installed-plugin-package-is-refused-with-hmr: + en: a941bcec1d8f3b70 + zh: ac5ca3517057a52c +/installing-over-an-installed-plugin-package-is-refused-with-hmr/change: + en: e521f46141e8be0a + zh: 1d34929d5d90e466 +/installing-over-an-installed-plugin-package-is-refused-with-hmr/migration: + en: f72eeb19e689bbee + zh: 9ae1f049688edf38 diff --git a/docs/upgrade-guide/v0.2.1-alpha.2/plugin-install-over-installed/guide.md b/docs/upgrade-guide/v0.2.1-alpha.2/plugin-install-over-installed/guide.md new file mode 100644 index 0000000000..53b51a1113 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.2/plugin-install-over-installed/guide.md @@ -0,0 +1,20 @@ +--- +kind: upgrade-guide +description: "With HMR, installing a package that is already installed fails with already-installed; an upgrade removes the package first." +--- + +# Installing over an installed plugin package is refused with HMR + +English | [中文](guide.zh.md) + +## Change + +In a profile that runs HMR, which the shipped profiles do, `pluginManager.installBundle(spec)` used to run pnpm over a package the profile already depends on and report `restart-required` while the previous version kept running. It now fails before pnpm runs, with `application: 'failed'` and `error.code: 'already-installed'`, and changes no profile file, when the spec names an installed package: a registry name with or without a version, a local directory, or the git or tarball spec the dependency was saved with. Repeating an installation of the same spec fails the same way instead of reporting `changed: false`. + +This affects callers of `installBundle()` through the Host API and the remote client in `@deepseek-ai/dsh-api-remotes/client`, and the agent's `plugin_manager` tool (`install_bundle`). An installed package that cannot be removed, such as a bundle carrying plugin management, can no longer be installed over while HMR runs. The Web page's Update for an Official plugin removes the package and installs the new version itself. Without HMR, installing over an installed package still runs pnpm and reports `restart-required`. + +## Migration + +1. To install another version or location of an installed package, call `removeBundle(name)` first, then `installBundle(spec)`. For the agent tool, call `remove_bundle`, then `install_bundle`. The removal stops the bundle's plugins and drops its packages' mappings, so the new version is mapped afresh and starts without a restart. +2. If code switches exhaustively over `ManagementError['code']`, handle the `already-installed` code. +3. Confirm the migration: installing the package again returns `already-installed`, and after `removeBundle` the same `installBundle` call returns `applied` with the new `version`. diff --git a/docs/upgrade-guide/v0.2.1-alpha.2/plugin-install-over-installed/guide.zh.md b/docs/upgrade-guide/v0.2.1-alpha.2/plugin-install-over-installed/guide.zh.md new file mode 100644 index 0000000000..4a3d7adf53 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.2/plugin-install-over-installed/guide.zh.md @@ -0,0 +1,20 @@ +--- +kind: upgrade-guide +description: "启用 HMR 时,安装已安装的包会以 already-installed 失败;升级需要先移除该包。" +--- + +# 启用 HMR 时拒绝在已安装的插件包上再次安装 + +[English](guide.md) | 中文 + +## 变更 + +在运行 HMR(热模块替换)的 profile 中(随附的 profile 都运行 HMR),`pluginManager.installBundle(spec)` 此前会对 profile 已依赖的包运行 pnpm,并在旧版本继续运行时报告 `restart-required`。现在当 spec 指向已安装的包时,它会在运行 pnpm 之前失败,返回 `application: 'failed'` 和 `error.code: 'already-installed'`,且不修改任何 profile 文件。这里的 spec 包括带或不带版本的注册表包名、本地目录,以及保存依赖时使用的 git 或 tarball spec。重复安装同一个 spec 也会以同样方式失败,不再报告 `changed: false`。 + +受影响的调用方包括:通过 Host API 调用 `installBundle()` 的代码、`@deepseek-ai/dsh-api-remotes/client` 中的远端客户端,以及 agent 的 `plugin_manager` 工具(`install_bundle`)。启用 HMR 时,不能再在无法移除的已安装包(例如带有插件管理的组合包)上安装。Web 页面上官方插件的“更新”操作会自行移除该包并安装新版本。未启用 HMR 时,在已安装的包上安装仍会运行 pnpm 并报告 `restart-required`。 + +## 迁移 + +1. 要安装已安装包的其他版本或其他位置,先调用 `removeBundle(name)`,再调用 `installBundle(spec)`。使用 agent 工具时,先调用 `remove_bundle`,再调用 `install_bundle`。移除操作会停止该 bundle 的插件,并删除其各个包的映射,因此新版本会重新建立映射,无需重启即可启动。 +2. 如果代码对 `ManagementError['code']` 做穷尽 switch,需要处理 `already-installed`。 +3. 确认迁移结果:再次安装该包返回 `already-installed`;执行 `removeBundle` 后,同样的 `installBundle` 调用返回 `applied` 和新的 `version`。 diff --git a/packages/api/remotes/src/client/index.ts b/packages/api/remotes/src/client/index.ts index cdb3fbc897..68997853d6 100644 --- a/packages/api/remotes/src/client/index.ts +++ b/packages/api/remotes/src/client/index.ts @@ -34,7 +34,8 @@ export type { BundleInfo, BundleRowInfo, ChangeResult, IncompatiblePlugin, InspectOptions, InstallBundleOptions, InstallSpecKind, ManagementError, PackageResult, PluginChange, PluginEntryId, PluginInfo, PluginInspectProblem, PluginInstallCancellation, PluginInstallFailureKind, - PluginInstallLogChunk, PluginInstallProgress, PluginInstallRequestId, PluginRegistries, PluginSpecInspection, ReadOnlyReason, Registry, + PluginInstallLogChunk, PluginInstallProgress, PluginInstallRequestId, PluginRegistries, PluginSpecInspection, + ReadOnlyReason, Registry, RestartReason, } from '@deepseek-ai/dsh-plugin-manager/types' export type {} from '@deepseek-ai/dsh-plugin-manager/remote' export type {} from '@deepseek-ai/dsh-client-ui-plugin-manager/remote' diff --git a/packages/boot/app-boot/README.i18n.yaml b/packages/boot/app-boot/README.i18n.yaml index 4468b0fd9a..12b9f227e6 100644 --- a/packages/boot/app-boot/README.i18n.yaml +++ b/packages/boot/app-boot/README.i18n.yaml @@ -36,14 +36,14 @@ en: a9afb263b03d112c zh: 31f5e797cd953c15 /deepseek-ai-dsh-app-boot/use-this-package/startup-and-reload-failures: - en: 206c9093965bc5e1 - zh: d8525e950595c399 + en: dc13dfa1f79292e4 + zh: d51682ced7a18ac2 /deepseek-ai-dsh-app-boot/understand-the-implementation: en: f4a2695cb4c8a570 zh: 89837235d2f376cf /deepseek-ai-dsh-app-boot/understand-the-implementation/design-notes: - en: 25a0d1b46fd66d68 - zh: 5cd8b4ac382dccdc + en: 9ecb744556705fd1 + zh: d83aaf2ebb91d0fc /deepseek-ai-dsh-app-boot/understand-the-implementation/helper-behavior: en: 6fa850d1bb000d0e zh: aa83a40a9d3db295 @@ -60,8 +60,8 @@ en: 5a1d9344c8fdc790 zh: 0b32579f658b2ca1 /deepseek-ai-dsh-app-boot/known-limitations-and-deferred-work: - en: bf1a49013cad8e9c - zh: 41b2b010f8650ec3 + en: 1400c4f22a79b59a + zh: 269f6c91584aec3e /deepseek-ai-dsh-app-boot/known-limitations-and-deferred-work/dev-note: en: d2f0d68fe29fb750 zh: 323d21ee73a9e3f6 diff --git a/packages/boot/app-boot/README.md b/packages/boot/app-boot/README.md index cba4c934e4..5e4d3b460a 100644 --- a/packages/boot/app-boot/README.md +++ b/packages/boot/app-boot/README.md @@ -91,7 +91,7 @@ Use `readPluginMeta(specifier, parentURL)` or `ctx.pluginPackages.metaOf(specifi ### Startup and reload failures -Profile reconciliation returns diagnostics for unchanged inactive entries without failing an unrelated mutation. A new inactive entry, a changed configuration or fiber, or a changed diagnostic fails reconciliation; removed fibers must still finish disposal. Explicit enablement targets must activate even when their failure predates the operation. Successful reconciliation emits `app-boot/config-reload` after lifecycle settlement and diagnostic checks, including programmatic updates without HMR. The event carries no diff or parsed config. Successful reconciliation returns after lifecycle settlement and diagnostic checks; volatile-only entry changes are committed by Loader during the update. +Profile reconciliation returns diagnostics for unchanged inactive entries without failing an unrelated mutation. A new inactive entry, a changed configuration or fiber, or a changed diagnostic fails reconciliation; removed fibers must still finish disposal. Explicit enablement targets must activate even when their failure predates the operation. `recomposeProfile(ctx, patches, binName, { requiredIds, running })` runs one recomposition together with the runtime resolution: it removes the rows the generation no longer lists and waits for their disposal while their packages are still mapped, publishes the latest generation of a recomputable resolution, runs the compatibility preflight against the published packages, applies the generation, starts the `running` entries and entries whose import failed that the update left without a running fiber, and judges activation. `running` lists the entries that ran before the caller stopped some of them; each that failed afterwards counts as a new failure. A failure before the generation applies puts the removed rows back at their places, which starts them again, and starts the `running` entries again. An entry waiting for a service is reported as a warning unless it is required, so it starts once a provider appears; `reconcileProfilePatches()` judges it the same way. A publication the resolution rejects is such a failure and reports `package-moved`. `reconcileProfilePatches()`, which the Config Editor uses, applies and judges the patches without publishing the resolution or retrying failed imports. An entry whose import failed is reported with the error Loader's import returns again, so judging it evaluates the module when the import now succeeds; the state recorded before the change imports nothing, so an entry whose import failed before counts as the same failure whatever its error now is. Successful reconciliation emits `app-boot/config-reload` after lifecycle settlement and diagnostic checks, including programmatic updates without HMR. The event carries no diff or parsed config. Successful reconciliation returns after lifecycle settlement and diagnostic checks; volatile-only entry changes are committed by Loader during the update. After the Loader settles, app-boot warns when only optional entries are inactive. If an enabled required entry cannot activate, `boot()` rejects with `StartupError` after disposal. An independently owned logger exporter retains warning and error records through asynchronous disposal and is released before `boot()` settles. Its message groups all failed plugins and pending services, marks required entries, and retains original stacks, nested causes, and aggregate members. The CLI prints that message once and saves [full startup diagnostics](../../../apps/cli/reference/README.md#startup-diagnostics) before exiting with code 1; unrelated exceptions retain their normal stack output. In the table, stopping startup means disposing any mounted plugins and exiting nonzero without reporting readiness; continuing keeps successful plugins running. Later configuration HMR does not repeat the required-startup audit and does not roll back the whole update. @@ -135,8 +135,8 @@ This section explains how the outcomes above are realized and points at the code - **Package metadata.** `ctx.pluginPackages.packageOf` locates the owning package without loading code or requiring an exported `package.json`; a subpath selects its package without validating that file. The installed runtime resolution owns selection even for a miss. Low-level embedders that install the service without one retain native lookup. Display metadata uses the separate entry-aware reader described above. - **Two Loader builtins.** `mountRootInclude` registers `cordis:include` and `cordis:group` as Loader builtins: a group row gives one `isolate` realm to a provider and its consumers together, and an agent preset outside this workspace cannot resolve `@deepseek-ai/cordis-plugin-group` by name. Both load through the ambient module pipeline rather than the included tree's own specifier resolution. - **Consumer-owned strictness.** Ordinary Loader groups keep successful siblings. App-boot applies the global required-entry policy after initial settlement; agent presets and dynamic multi-entry compositions own and dispose their separate Loader subtree when they require all-or-nothing setup. App-boot reads failed fibers to report their recorded errors and coalesces duplicate Loader rejection notifications through one process checkpoint. -- **One runtime resolution.** The installation-first and ordered-bundle breadth-first traversal produces the runtime table. Runtime resolution creates no links; runtime resolution entries occupy their package names at `$DSH_HOME/profiles/node_modules`, and every other name sees that directory as an ordinary ancestor. Profile load removes the `.dsh-module-fallback` projections that link-backend releases wrote into a profile; pnpm-installed packages stay. External bare targets selected by package `imports` use the same package order, while Node retains mapping, conditions, and exact target resolution. A complete successor may add package names, remove profile-scoped mappings and profile-local names, and update linked-root membership atomically; retained mappings keep their normalized directory, version, and scope. Installation anchors remain fixed; a profile mapping may change its declarer. [Plugin Manager](../plugin-manager/README.md) publishes successors after package operations ([decision](../../../.agents/notes/implemented/architecture/2026-09-30-profile-package-refresh-and-manifest-invalidation.md)). -- **Removing linked interception.** A successor may remove a linked root without restart. Directories outside all remaining roots use native lookup for subsequent requests, which may find a development copy or report a missing package. Existing module references and Node caches stay unchanged. Re-adding the same link name and target is allowed; pointing a previously published name at a different target is rejected even after removal ([generation rules](../../../.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.md#immutable-generations)). +- **One runtime resolution.** The installation-first and ordered-bundle breadth-first traversal produces the runtime table. Runtime resolution creates no links; runtime resolution entries occupy their package names at `$DSH_HOME/profiles/node_modules`, and every other name sees that directory as an ordinary ancestor. Profile load removes the `.dsh-module-fallback` projections that link-backend releases wrote into a profile; pnpm-installed packages stay. External bare targets selected by package `imports` use the same package order, while Node retains mapping, conditions, and exact target resolution. A complete successor may add package names, remove profile-scoped mappings and profile-local names, and update linked-root membership atomically; retained mappings keep their normalized directory and scope. Installation mappings keep their version and anchor; a profile mapping may change its declarer and, for a package upgraded in place, its version. [Plugin Manager](../plugin-manager/README.md) publishes successors after package operations ([decision](../../../.agents/notes/implemented/architecture/2026-09-30-profile-package-refresh-and-manifest-invalidation.md)). +- **Removing linked interception.** A successor may remove a linked root without restart. Directories outside all remaining roots use native lookup for subsequent requests, which may find a development copy or report a missing package. Existing module references and Node caches stay unchanged. A link name that is no profile mapping may point to another target, also after removal, and so may one whose mapping was removed first; Node's cached real paths of the previous target are relocated ([generation rules](../../../.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.md#immutable-generations)). - **Application-owned profiles.** Application-owned profiles use the same runtime resolution. Links within the active profile directory, including pnpm store links, are not external roots even when the profile is outside the shared profiles tree. Resolution does not modify their `node_modules`; pnpm owns installed packages. - **Owned Workers.** Worker build banners import `@deepseek-ai/dsh-app-boot/worker/profile-resolution-bootstrap` before bundled business code. Each Worker installs the structured-cloned runtime resolution in its own isolate. The bootstrap bundle has no static package imports. Source Worker entries retain their self-contained dependency closure, and third-party Workers receive no injection. - **Update completion.** App boot observes restart failures through the `internal/update` waterfall. Live patch reloads wait for the tree's fibers before auditing activation; `Fiber.update()` and `Entry.update()` alone do not establish restart success. @@ -196,7 +196,7 @@ Boot itself changes no request prefix. These limits describe when this boot library is a poor fit or needs special care. They are current package constraints, not a task backlog. - **Runtime resolution depends on Node internals** — supported Node versions require the native builtin-access addon and executable compatibility coverage. Only built Harness-owned Workers receive the runtime resolution bootstrap; third-party Workers and custom `vm` linkers keep native resolution. -- **Relinking a profile package needs a restart** — Node caches real paths, so changing the target of a profile link or a dependency link requires a process restart. +- **Moving a profile package needs its removal first** — `replace()` rejects a retained profile mapping that moved to another directory or link target with `ResolutionRestartRequired`; removing the package and installing it again maps the new directory. A linked root may point to another directory once its profile mapping was removed. Node keeps the real path of every link it resolved through; once a link points elsewhere, or its name is installed again as a directory, a native lookup that selects a file below its previous target, for an importer outside it, selects the same file below the current one when that exists, including an explicit absolute path into the previous target. Installation mappings and local overrides still require a restart. - **Linked scope follows recorded real directories** — a hoisted dependency outside every linked root uses native Node. A peer read never invalidates Node caches or changes which files are watched. - **Source launches use an ESM-only hook** — CommonJS requests still need the JavaScript files selected by package exports; resolution does not supply missing build outputs. - **Snapshot replay swapping is basename-specific** — only a config ending in `cordis.yml` or `cordis.yaml` maps to the sibling `cordis.snapshot.yml`; custom config names require caller-managed selection. diff --git a/packages/boot/app-boot/README.zh.md b/packages/boot/app-boot/README.zh.md index cb383af462..31fdebcb15 100644 --- a/packages/boot/app-boot/README.zh.md +++ b/packages/boot/app-boot/README.zh.md @@ -91,7 +91,7 @@ profile 导入插件前,DSH 会检查其 `peerDependencies` 中对 `@deepseek- ### 启动与重载失败 -profile 重载返回未变化的已有故障诊断,不让无关修改因此失败。新增未激活条目、配置或 fiber 变化、诊断变化都会使重载失败;被移除的 fiber 仍须完成释放。显式启用的目标必须成功激活,即使它的故障早于本次操作。成功重载在生命周期结束及诊断检查通过后发出 `app-boot/config-reload`,包括未启用 HMR 时的程序化更新。事件不携带 diff 或解析后的配置。 成功重载在生命周期结束及诊断检查通过后返回;仅 volatile 的条目变化由 Loader 在更新过程中提交。 +profile 重载返回未变化的已有故障诊断,不让无关修改因此失败。新增未激活条目、配置或 fiber 变化、诊断变化都会使重载失败;被移除的 fiber 仍须完成释放。显式启用的目标必须成功激活,即使它的故障早于本次操作。`recomposeProfile(ctx, patches, binName, { requiredIds, running })` 与 runtime resolution 一起执行一次重新组合:它移除新一代组合不再列出的行,并在其包仍有映射时等待这些行释放完成,发布可重新计算的 resolution 的最新一代,使兼容性预检读取已发布的包,应用新一代组合,启动更新后没有运行中 fiber 的 `running` 条目和导入失败的条目,然后判定激活结果。`running` 列出调用方停止其中部分条目之前正在运行的条目;之后每个失败的条目都计为新的故障。新一代组合应用之前的失败会把移除的行放回原位置,从而重新启动它们,并重新启动 `running` 条目。等待服务的条目报告为警告(required 条目除外),因此出现提供方后它即启动;`reconcileProfilePatches()` 以同样方式判定它。resolution 拒绝的发布属于这种失败,并报告 `package-moved`。Config Editor 使用的 `reconcileProfilePatches()` 应用并判定 patch,不发布 resolution,也不重试失败的导入。导入失败的条目以 Loader 再次导入时返回的错误报告,因此若导入现在成功,判定该条目时会对模块求值;变更前记录的激活状态不执行任何导入,因此之前导入失败的条目无论现在的错误是什么,都计为同一个故障。成功重载在生命周期结束及诊断检查通过后发出 `app-boot/config-reload`,包括未启用 HMR 时的程序化更新。事件不携带 diff 或解析后的配置。成功重载在生命周期结束及诊断检查通过后返回;仅 volatile 的条目变化由 Loader 在更新过程中提交。 Loader 结算后,app-boot 在仅 optional 条目未激活时输出警告。如果已启用的 required 条目无法激活,`boot()` 会在释放资源后以 `StartupError` 拒绝。独立管理生命周期的 logger exporter 会保留异步资源释放期间的警告和错误记录,并在 `boot()` 结算前释放。其消息分组列出所有失败插件和等待的服务,标记 required 条目,并保留原始堆栈、嵌套原因和聚合错误成员。CLI 仅输出该消息一次,并在保存[完整启动诊断](../../../apps/cli/reference/README.zh.md#startup-diagnostics)后以退出码 1 结束;其他异常保留正常堆栈输出。表中的“终止启动”指释放已挂载插件并以非零码退出,不报告就绪;“继续”指保留成功运行的插件。后续配置 HMR 不会再次执行 required 启动审计,也不会回滚整个更新。 @@ -135,8 +135,8 @@ Loader 结算后,app-boot 在仅 optional 条目未激活时输出警告。如 - **包元数据。** `ctx.pluginPackages.packageOf` 定位所属包,不加载代码,也不要求导出 `package.json`;子路径选择其所属包,不校验该文件。安装 runtime resolution 后,即使查询未命中也以其选包规则为准。仅安装服务而不提供 runtime resolution 的底层嵌入方保留原生查询。展示元数据使用上文另述的入口感知读取器。 - **两个 Loader builtin。** `mountRootInclude` 把 `cordis:include` 与 `cordis:group` 注册为 Loader builtin:group 行能把一个提供方与它的消费方放进同一个 `isolate` realm,而位于本工作区之外的 agent preset 无法按名称解析 `@deepseek-ai/cordis-plugin-group`。两者都通过宿主的模块管线加载,而非被包含树自身的说明符解析。 - **由 consumer 持有严格语义。** 普通 Loader group 保留成功 sibling。App-boot 在首次结算后应用全局 required-entry policy;agent preset 与动态多 entry 组合在需要 all-or-nothing setup 时,持有并拆卸各自的独立 Loader 子树。App-boot 读取 failed fiber 来报告已记录的错误,并在一个进程检查点内合并 Loader 重复的 rejection 通知。 -- **唯一 runtime resolution。** 安装优先、有序 bundle 逐根 breadth-first 遍历生成运行时表。runtime 解析不创建链接;runtime resolution 条目占据 `$DSH_HOME/profiles/node_modules` 上各自的包名位置,其余包名把该目录当作普通祖先。profile 加载时删除 Link 后端发布版写进 profile 的 `.dsh-module-fallback` 投影;pnpm 安装的包保留。package `imports` 选中的外部 bare target 使用相同的选包顺序,映射、conditions 和精确 target 解析仍由 Node 负责。完整后继 runtime resolution 可以原子地增加 package name、移除 profile 范围的映射和 profile 本地包名、更新 linked root 集合;保留映射的规范化目录、版本和作用域不变。安装范围的锚点不变;profile 映射可以更换声明来源。[插件管理器](../plugin-manager/README.zh.md)在包操作之后发布后继代([决策](../../../.agents/notes/implemented/architecture/2026-09-30-profile-package-refresh-and-manifest-invalidation.zh.md))。 -- **移除链接拦截。** 后继 generation 可以移除 linked root,无需重启。目录不再被任何剩余 root 覆盖时,后续请求使用原生查询,可能找到开发副本,也可能报告缺包。已有模块引用和 Node 缓存保持不变。同名、同目标可以重新加入;曾发布的名称改指向不同目标时,即使中间移除过也会被拒绝([generation 规则](../../../.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.zh.md#immutable-generations))。 +- **唯一 runtime resolution。** 安装优先、有序 bundle 逐根 breadth-first 遍历生成运行时表。runtime 解析不创建链接;runtime resolution 条目占据 `$DSH_HOME/profiles/node_modules` 上各自的包名位置,其余包名把该目录当作普通祖先。profile 加载时删除 Link 后端发布版写进 profile 的 `.dsh-module-fallback` 投影;pnpm 安装的包保留。package `imports` 选中的外部 bare target 使用相同的选包顺序,映射、conditions 和精确 target 解析仍由 Node 负责。完整后继 runtime resolution 可以原子地增加 package name、移除 profile 范围的映射和 profile 本地包名、更新 linked root 集合;保留映射的规范化目录和作用域不变。安装范围的映射保持版本和锚点不变;profile 映射可以更换声明来源,对原地升级的包还可以更改版本。[插件管理器](../plugin-manager/README.zh.md)在包操作之后发布后继代([决策](../../../.agents/notes/implemented/architecture/2026-09-30-profile-package-refresh-and-manifest-invalidation.zh.md))。 +- **移除链接拦截。** 后继 generation 可以移除 linked root,无需重启。目录不再被任何剩余 root 覆盖时,后续请求使用原生查询,可能找到开发副本,也可能报告缺包。已有模块引用和 Node 缓存保持不变。不属于 profile 映射的链接名称可以指向其他目标,移除之后也可以;其 profile 映射先被移除的链接名称同样可以;Node 缓存的原目标真实路径会被重新定位([generation 规则](../../../.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.zh.md#immutable-generations))。 - **应用自有 profile。** 应用自有 profile 使用相同的 runtime resolution。目标位于当前 profile 目录内的链接(包括 pnpm store 链接)不算外部 root,即使 profile 位于共享 profiles 树外。解析过程不修改其 `node_modules`;已安装包由 pnpm 管理。 - **自有 Worker。** Worker 构建 banner 会在业务 bundle 前导入 `@deepseek-ai/dsh-app-boot/worker/profile-resolution-bootstrap`。每个 Worker 在自己的 isolate 中安装结构化克隆的 runtime resolution。bootstrap bundle 不静态导入任何包。源码 Worker 入口保留自包含依赖,第三方 Worker 不接受注入。 - **更新完成。** App boot 通过 `internal/update` waterfall 观察重启失败。实时 patch 重载在检查激活状态前等待配置树中的 fiber;单独调用 `Fiber.update()` 或 `Entry.update()` 不能确定重启成功。 @@ -196,7 +196,7 @@ Loader 结算后,app-boot 在仅 optional 条目未激活时输出警告。如 这些限制说明此启动库在何时不合适,或何时需要特别注意。它们是当前包约束,不是任务积压。 - **运行时解析依赖 Node 内部机制**——受支持的 Node 版本需要 native builtin access addon 和可执行兼容验证。只有构建后的 Harness 自有 Worker 接收 runtime resolution bootstrap;第三方 Worker 与自定义 `vm` linker 保持原生解析。 -- **重新链接 profile 包需要重启**——Node 缓存真实路径,因此改变 profile 链接或依赖链接的目标需要重启进程。 +- **移动 profile 包需要先卸载**——`replace()` 以 `ResolutionRestartRequired` 拒绝移动到其他目录或链接目标的保留 profile 映射;卸载该包后重新安装,即映射到新目录。linked root 的 profile 映射被移除后,它可以指向其他目录。Node 保留其解析过的每个链接的真实路径;链接指向别处后,或其名称作为目录重新安装后,原生查询为原目标之外的导入方选中原目标之下的文件时,若当前目标之下存在同一文件,会改为选中该文件,包括指向原目标的显式绝对路径。安装映射和本地覆盖仍需要重启。 - **链接作用域以记录的真实目录为准**——提升后的依赖若在所有 linked root 之外,就使用原生 Node。每次读取 peer 都不会使 Node 缓存失效,也不会改变被监视的文件。 - **源码启动只安装 ESM 钩子**——CommonJS 请求仍需要 package exports 选中的 JavaScript 文件,解析器不会补出缺失的构建产物。 - **快照回放替换仅识别特定 basename**——只有以 `cordis.yml` 或 `cordis.yaml` 结尾的配置会映射到同级 `cordis.snapshot.yml`;自定义配置名称需要调用方自行选择。 diff --git a/packages/boot/app-boot/src/index.ts b/packages/boot/app-boot/src/index.ts index 282519fbbd..fded02c9c8 100644 --- a/packages/boot/app-boot/src/index.ts +++ b/packages/boot/app-boot/src/index.ts @@ -11,8 +11,8 @@ import { readFileSync } from 'node:fs' import { inspect, parseEnv } from 'node:util' import { basename, dirname, isAbsolute, resolve } from 'node:path' import * as yaml from 'js-yaml' -import { Context, type FiberState } from '@deepseek-ai/cordis' -import Loader, { type Entry, type EntryOptions } from '@deepseek-ai/cordis-plugin-loader' +import { Context, type Fiber, type FiberState } from '@deepseek-ai/cordis' +import Loader, { type Entry, type EntryGroup, type EntryOptions } from '@deepseek-ai/cordis-plugin-loader' import Include, { applyEntryPatches, entryListSchema, type PatchOptions } from '@deepseek-ai/cordis-plugin-include' import Group from '@deepseek-ai/cordis-plugin-group' import { dshHomePath, resolveDshHome } from '@deepseek-ai/dsh-home-paths' @@ -25,6 +25,8 @@ export { setProfileVersionExemption, type ProfileCompatibility, } from './profile-compatibility.ts' import { prepareProfilePatches } from './compatibility-preflight.ts' +import { ProfileRuntimeResolution, type RuntimeResolution } from './profile.ts' +import { ResolutionRestartRequired } from './profile-resolution/resolver.ts' export { prepareProfileEntries, prepareProfilePatches } from './compatibility-preflight.ts' export { readPluginMeta, resolvePluginResource } from './package-meta.ts' export { ON_DEMAND_BUNDLES, OFFICIAL_ON_DEMAND_CATALOG, type OfficialBundleCatalogEntry } from './official-bundles.ts' @@ -88,6 +90,7 @@ export { type PluginPackage, type PluginPackagesConfig, } from './profile-resolution/service.ts' +export { ResolutionRestartRequired } from './profile-resolution/resolver.ts' /** * Resolve the config to boot. Replay swaps a `cordis.yml` basename for @@ -264,7 +267,52 @@ const bootstrapIncludes = new WeakMap() // reference `process.env`. const userPatchesSchema = entryListSchema -/** Apply one complete patch generation and wait for Loader activation diagnostics. +/** What a caller of {@link recomposeProfile} already did and requires. */ +export interface ProfileRecompositionOptions { + /** Explicit enablement targets whose existing failures also reject the recomposition. */ + readonly requiredIds?: readonly string[] + /** + * Loader entries that ran before the caller stopped some of them. Each that no longer runs starts again, and each that + * failed afterwards counts as a new failure. + */ + readonly running?: readonly Entry[] +} + +/** The outcome of a {@link recomposeProfile} that applied or kept the composition. */ +export interface ProfileRecomposition { + /** Diagnostics for unchanged pre-existing inactive entries. */ + warnings: string[] + /** Present when the runtime resolution could not publish a moved package or a changed installation mapping. */ + restartReason?: 'package-moved' +} + +/** + * Recompose the profile from its complete patch list, together with its runtime resolution. The rows the generation no + * longer lists are removed first and their disposal finishes while their packages are still mapped; the latest + * generation of an installed, recomputable resolution is published; the compatibility preflight reads the published + * packages; the generation is applied to the root Include; entries that ran, and enabled entries whose import failed, + * start afterwards when the update left them without a running fiber; and activation is judged. A failure before the generation applies, + * including a publication the resolution rejects, which reports `package-moved`, puts the removed rows back at their + * places, starting them again, and starts the entries that ran again. An entry waiting for a service is reported as a + * warning unless it is required. Patches are copied with `structuredClone`, so they hold no functions. + * @param ctx Booted root context. + * @param patches Complete ordered patch list. + * @param binName Diagnostic prefix. + * @param options What the caller already did and requires. + * @returns Diagnostics for unchanged pre-existing inactive entries, and the restart reason of a rejected publication. + * @throws When the root Include is missing; the latest resolution cannot be read or published; preparation or the + * Loader update rejects; or for a new or changed failure, an inactive required entry, a failed entry that ran, or a + * healthy fiber whose disposal rejected. + */ +export function recomposeProfile( + ctx: Context, patches: PatchOptions[], binName: string, options: ProfileRecompositionOptions = {}, +): Promise { + return recompose(ctx, patches, binName, options, true) +} + +/** + * Apply a complete patch list to the root Include and judge activation, leaving the runtime resolution and entries + * whose import failed as they are. * @param ctx Booted root context. * @param patches Complete ordered patch list. * @param binName Diagnostic prefix. @@ -274,32 +322,188 @@ const userPatchesSchema = entryListSchema export async function reconcileProfilePatches( ctx: Context, patches: PatchOptions[], binName: string, requiredIds: readonly string[] = [], ): Promise { - const entry = bootstrapIncludes.get(ctx) - if (entry === undefined) throw new Error(`${binName}: profile reload requires the root Include entry`) - const previousFailures = (await inactiveEntries(ctx)).map(failure => ({ - ...failure, diagnostic: inactiveDiagnostic(failure), fiber: failure.entry.fiber, options: JSON.stringify(failure.entry.options), - })) - // Removed entries leave the Loader store before their async disposers finish. - const previousFibers = [...ctx.loader.entries()].flatMap(row => row.fiber === undefined ? [] : [{ - fiber: row.fiber, failed: row.fiber.state === FIBER_FAILED || row.fiber.state === FIBER_DISPOSED, - }]) - const { patches: _previous, ...includeConfig } = entry.options.config as Include.Config - // The recomposition judges the rows the launch judged, resolved from the file this Include read. - const parentURL = new URL('.', new URL(includeConfig.path, entry.parent.tree.ctx.baseUrl)).href - const prepared = prepareProfilePatches(ctx, patches, parentURL, binName) - await entry.update({ config: { ...includeConfig, patches: prepared } }) - const results = await Promise.allSettled(previousFibers.map(({ fiber }) => fiber.await())) - await ctx.loader.await() - const failures = await inactiveEntries(ctx) - const introduced = failures.filter(failure => requiredIds.includes(failure.entry.options.id) || !previousFailures.some(previous => - previous.entry === failure.entry && previous.fiber === failure.entry.fiber - && previous.options === JSON.stringify(failure.entry.options) && previous.diagnostic === inactiveDiagnostic(failure))) - if (introduced.length > 0) throw new Error(activationDiagnostic(binName, introduced).trimEnd()) - for (const [index, result] of results.entries()) { - if (result.status === 'rejected' && !previousFibers[index]?.failed) throw result.reason + return (await recompose(ctx, patches, binName, { requiredIds }, false)).warnings +} + +async function recompose( + ctx: Context, patches: PatchOptions[], binName: string, options: ProfileRecompositionOptions, withResolution: boolean, +): Promise { + const { requiredIds = [], running = [] } = options + let applying = false + // The rows stopped first, which a failure before the generation applies puts back. + let stopped: StoppedRow[] = [] + try { + const entry = bootstrapIncludes.get(ctx) + if (entry === undefined) throw new Error(`${binName}: profile reload requires the root Include entry`) + // Reading the prior state imports nothing, so plugin code does not run before the next resolution publishes. + const previousFailures = (await inactiveEntries(ctx, false)).map((failure): RecordedFailure => ({ + entry: failure.entry, fiber: failure.entry.fiber, options: JSON.stringify(failure.entry.options), + // An entry without a running fiber has no import error yet; the judgment after the update reads it. + diagnostic: failure.entry.fiber?.uid ? reloadDiagnostic(failure) : undefined, + })) + // Removed entries leave the Loader store before their async disposers finish. + const previousFibers = [...ctx.loader.entries()].flatMap(row => row.fiber === undefined ? [] : [{ + fiber: row.fiber, failed: row.fiber.state === FIBER_FAILED || row.fiber.state === FIBER_DISPOSED, + }]) + const { patches: previous = [], ...includeConfig } = entry.options.config as Include.Config + const current = withResolution ? ctx.get('pluginPackages')?.resolution : undefined + // A plain-data resolution has no inputs to recompute from. + const successor = current instanceof ProfileRuntimeResolution ? await current.computeLatestResolution() : undefined + if (successor !== undefined) { + // Rows the generation no longer lists stop, and their disposal finishes, while their packages are still mapped. + const listed = rowIds(structuredClone(patches)) + const composed = rowIds(structuredClone(previous)) + // Only rows of the root Include's tree; a nested Include file has its own ids. + const tree = entry.subgroup?.tree + const dropped = [...ctx.loader.entries()] + .filter(row => row.parent.tree === tree && composed.has(row.options.id) && !listed.has(row.options.id)) + // A dropped group takes its rows with it. + const ids = new Set(dropped.map(row => row.options.id)) + const topmost = dropped.filter(row => !ids.has(row.parent.ctx.fiber.entry?.options.id as string)) + // The Loader marks the options of a row it stops, so the copy taken before is the one restored. + stopped = topmost.map(row => ({ + group: row.parent, options: structuredClone(row.options), + index: row.parent.data.indexOf(row.options), + })) + if (topmost.length > 0) { + const fibers = dropped.flatMap(row => row.fiber === undefined ? [] : [row.fiber]) + for (const row of topmost) { + // A disabled row's disposal is the Loader's own, so it does not rewrite the profile's configuration file. + row.options.disabled = true + row.parent.remove(row.options.id) + } + await Promise.allSettled(fibers.map(fiber => fiber.await())) + await ctx.loader.await() + } + try { + publish(ctx, successor) + } catch (error) { + if (!(error instanceof ResolutionRestartRequired)) throw error + // The generation waits for the mappings the rejected publication would change. + return { warnings: [], restartReason: 'package-moved' } + } + } + // The recomposition judges the rows the launch judged, resolved from the file this Include read. + const parentURL = new URL('.', new URL(includeConfig.path, entry.parent.tree.ctx.baseUrl)).href + const prepared = prepareProfilePatches(ctx, patches, parentURL, binName) + applying = true + const retried = [...new Set([...running, ...withResolution ? [...ctx.loader.entries()].filter(importFailed) : []])] + try { + await entry.update({ config: { ...includeConfig, patches: prepared } }) + } finally { + await resume(ctx, retried) + } + const results = await Promise.allSettled(previousFibers.map(({ fiber }) => fiber.await())) + await ctx.loader.await() + const failures = await inactiveEntries(ctx) + const introduced = failures.filter((failure) => { + if (requiredIds.includes(failure.entry.options.id)) return true + // An entry waiting for a service starts once a provider appears. + if (failure.outcome.kind === 'pending') return false + // An entry that ran before the caller stopped it is a new failure even when it failed the same way before. + if (running.includes(failure.entry)) return true + return !previousFailures.some(previous => sameFailure(previous, failure)) + }) + if (introduced.length > 0) throw new Error(activationDiagnostic(binName, introduced, false).trimEnd()) + for (const [index, result] of results.entries()) { + if (result.status === 'rejected' && !previousFibers[index]?.failed) throw result.reason + } + ctx.emit('app-boot/config-reload') + return { warnings: failures.map(reloadDiagnostic) } + } finally { + // Before the generation applies, the rows removed first and the entries the caller stopped run again. + if (!applying && (stopped.length > 0 || running.length > 0)) { + try { + // Ascending original positions put each row back where it was. + for (const { group, options, index } of stopped.toSorted((left, right) => left.index - right.index)) { + // `create` starts the entry but leaves the group's option list as it is; that list is what the next update + // compares against to remove the row and what the Include writes back. + group.data.splice(index, 0, options) + await group.create(options) + } + await resume(ctx, running) + // A restored row is a new Loader entry with the id of the one that ran. + const ran = new Set(running.map(row => row.options.id)) + for (const failure of await inactiveEntries(ctx)) { + if (ran.has(failure.entry.options.id)) ctx.logger.warn(`${binName}: ${reloadDiagnostic(failure)}`) + } + } catch (error) { + // The recomposition's own failure is the outcome. + ctx.logger.warn(error) + } + } } - ctx.emit('app-boot/config-reload') - return failures.map(inactiveDiagnostic) +} + +/** An inactive entry as a recomposition found it before changing anything. */ +interface RecordedFailure { + entry: Entry + fiber: Fiber | undefined + /** The entry's options as JSON. */ + options: string + /** The reload diagnostic; `undefined` for an import failure, which matches any diagnostic. */ + diagnostic: string | undefined +} + +/** Whether an inactive entry is the failure recorded before: the same entry, fiber and options, and a matching diagnostic. */ +function sameFailure(previous: RecordedFailure, failure: InactiveEntry): boolean { + return previous.entry === failure.entry && previous.fiber === failure.entry.fiber + && previous.options === JSON.stringify(failure.entry.options) + && (previous.diagnostic === undefined || previous.diagnostic === reloadDiagnostic(failure)) +} + +/** A row a recomposition removed before publishing, with its place in its group. */ +interface StoppedRow { + group: EntryGroup + options: EntryOptions + index: number +} + +/** The ids of the rows a patch list inserts, including the rows of groups. */ +function rowIds(patches: readonly PatchOptions[]): Set { + const ids = new Set() + const visit = (rows: readonly EntryOptions[]): void => { + for (const row of rows) { + ids.add(row.id) + // A group keeps its rows in its configuration. + if ((row.group === true || row.name === 'cordis:group') && Array.isArray(row.config)) visit(row.config as EntryOptions[]) + } + } + // Patch warnings are logged when the generation itself is prepared. + visit(applyEntryPatches([], patches as PatchOptions[], () => {})) + return ids +} + +/** Publish a generation unless it maps exactly what is published, which would only drop the package caches. */ +function publish(ctx: Context, next: RuntimeResolution): void { + const table = ({ localPackageNames, entries, linkedRoots }: RuntimeResolution): string => + JSON.stringify([localPackageNames, entries, linkedRoots]) + const packages = ctx.pluginPackages + if (table(packages.resolution as RuntimeResolution) !== table(next)) packages.replace(next) +} + +/** Whether an enabled entry below a running parent has no fiber or keeps the disposed fiber of a failed import. */ +function importFailed(entry: Entry): boolean { + return enabledEntry(entry) && entry.parent.ctx.fiber.uid !== null + && (entry.fiber === undefined || entry.fiber.state === FIBER_DISPOSED) +} + +/** Whether an entry is enabled now; a throwing `disabled` expression counts as disabled. */ +function enabledEntry(entry: Entry): boolean { + try { + return !entry.disabled + } catch (_error) { + // Judgment reports the expression failure. + return false + } +} + +/** Start invalidated entries the Loader still holds without a running fiber, then wait until it settles. */ +async function resume(ctx: Context, invalidated: readonly Entry[]): Promise { + const entries = new Set(ctx.loader.entries()) + // A stopped entry starts again on update even with unchanged options; an unchanged Include does not update its rows. + for (const row of invalidated) if (entries.has(row) && !row.fiber?.uid) await row.update({}, false, true) + await ctx.loader.await() } /** @@ -756,7 +960,7 @@ const requiredStartupEntryIds = new Set([ ]) /** Render plugin stacks, nested causes, and aggregate member failures once per error. */ -function formatActivationError(error: unknown): string { +function formatActivationError(error: unknown, stacks = true): string { const details: string[] = [] const seen = new Set() function visit(value: unknown): void { @@ -766,7 +970,7 @@ function formatActivationError(error: unknown): string { } if (seen.has(value)) return seen.add(value) - details.push(value.stack ?? value.message) + details.push(stacks ? value.stack ?? value.message : String(value)) if (value.cause !== undefined) visit(value.cause) if (value instanceof AggregateError) value.errors.forEach(visit) } @@ -818,12 +1022,24 @@ export class StartupError extends Error { } } +/** The error importing an entry's module raises now, which Node returns from its cache for a module that failed before. */ +async function importFailure(entry: Entry): Promise { + try { + await entry.parent.tree.import(entry.options.name) + return undefined + } catch (error) { + return error + } +} + /** * Collect Loader activation failures and disabled-expression errors. Failed * fibers are awaited to recover their recorded rejection reason and coalesce * duplicate Loader notifications through the next process rejection checkpoint. + * An entry whose import failed reports the error importing it raises now; without + * `importNow` nothing is imported and the entry reports a failed import without the import error. */ -async function inactiveEntries(ctx: Context): Promise { +async function inactiveEntries(ctx: Context, importNow = true): Promise { const failures: InactiveEntry[] = [] const rejectionReasons: unknown[] = [] for (const entry of ctx.loader.entries()) { @@ -834,8 +1050,12 @@ async function inactiveEntries(ctx: Context): Promise { continue } const fiber = entry.fiber - if (fiber === undefined) { - failures.push({ entry, outcome: { kind: 'failed', error: 'failed to import' } }) + // An entry whose import failed has no fiber, or keeps the disposed fiber of its previous activation. + if (fiber === undefined || fiber.state === FIBER_DISPOSED) { + const error = importNow ? await importFailure(entry) : undefined + failures.push({ entry, outcome: error === undefined + ? { kind: 'failed', error: fiber === undefined ? 'failed to import' : `fiber state ${String(fiber.state)}` } + : { kind: 'failed', error, phase: 'failed to import' } }) continue } const state = fiber.state @@ -864,22 +1084,28 @@ async function inactiveEntries(ctx: Context): Promise { } /** Render one failed plugin's original error and activation phase. */ -function failureDetail(outcome: Extract): string { - return `${outcome.phase === undefined ? '' : `${outcome.phase}: `}${formatActivationError(outcome.error)}` +function failureDetail(outcome: Extract, stacks = true): string { + return `${outcome.phase === undefined ? '' : `${outcome.phase}: `}${formatActivationError(outcome.error, stacks)}` } /** Render optional-only warnings without changing startup policy. */ function activationDiagnostic( binName: string, failures: readonly InactiveEntry[], + stacks = true, ): string { const noun = failures.length === 1 ? 'entry' : 'entries' - return `${binName}: warning: ${String(failures.length)} ${noun} did not activate\n${failures.map(inactiveDiagnostic).join('\n')}\n` + return `${binName}: warning: ${String(failures.length)} ${noun} did not activate\n${failures.map(failure => inactiveDiagnostic(failure, stacks)).join('\n')}\n` +} + +/** Per-entry reload text, which people read through the Web UI and agents, so it carries error messages without stack frames. */ +function reloadDiagnostic(failure: InactiveEntry): string { + return inactiveDiagnostic(failure, false) } /** Stable per-entry text for reload comparisons and optional warnings. */ -function inactiveDiagnostic({ entry, outcome }: InactiveEntry): string { - const detail = outcome.kind === 'failed' ? failureDetail(outcome) +function inactiveDiagnostic({ entry, outcome }: InactiveEntry, stacks = true): string { + const detail = outcome.kind === 'failed' ? failureDetail(outcome, stacks) : `pending (waiting for ${outcome.missing.length === 1 ? 'service' : 'services'}: ${outcome.missing.join(', ') || 'unknown'})` return `${entry.options.id} (${entry.options.name}): ${detail}` } @@ -1031,7 +1257,7 @@ export async function boot( deepest = deepest.cause } const stack = deepest instanceof AggregateError - ? `\n${deepest.stack ?? deepest.message}\n${deepest.errors.map(formatActivationError).join('\n')}` + ? `\n${deepest.stack ?? deepest.message}\n${deepest.errors.map(error => formatActivationError(error)).join('\n')}` : deepest instanceof Error && deepest !== cause ? `\n${deepest.stack ?? deepest.message}` : '' throw new Error(`${binName}: ${stage}: ${detail}${stack}`, { cause }) } finally { diff --git a/packages/boot/app-boot/src/profile-resolution/resolver.ts b/packages/boot/app-boot/src/profile-resolution/resolver.ts index 85c530665d..60fce4f032 100644 --- a/packages/boot/app-boot/src/profile-resolution/resolver.ts +++ b/packages/boot/app-boot/src/profile-resolution/resolver.ts @@ -2,7 +2,7 @@ import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs' import { createRequire, isBuiltin } from 'node:module' -import { basename, dirname, join, resolve, sep } from 'node:path' +import { basename, dirname, isAbsolute, join, resolve, sep } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' import { getEnvironmentData, setEnvironmentData } from 'node:worker_threads' import type { ModuleLoaderV1, ModuleLoaderV2, ResolveResult } from '@deepseek-ai/cordis-plugin-loader' @@ -100,8 +100,13 @@ interface ParentRoutes { type ResolutionRoutes = Map +/** A published entry with the real directory its modules load from. */ +interface CompiledEntry extends RuntimeResolutionEntry { + readonly realDir: string +} + interface CompiledResolution { - readonly entries: ReadonlyMap + readonly entries: ReadonlyMap readonly profilesDir: string readonly profileDir: string | undefined readonly profilePaths: readonly string[] @@ -113,6 +118,15 @@ interface CompiledResolution { readonly cjsRoutes: ResolutionRoutes } +/** A successor resolution the running process cannot adopt, such as a retained mapping in another directory; it waits for a restart. */ +export class ResolutionRestartRequired extends Error { + /** @param message Diagnostic naming the rejected changes. */ + constructor(message: string) { + super(message) + this.name = 'ResolutionRestartRequired' + } +} + /** Active interception in one Node isolate. */ export interface RuntimeInterception { /** @@ -125,13 +139,16 @@ export interface RuntimeInterception { packageDir(specifier: string, parentURL: string): string | undefined /** * Atomically publish a complete successor and fresh caches. Profile-scoped mappings, local names, and linked - * roots may be removed; removed roots stop intercepting uncovered directories. Existing modules and Node caches - * remain intact, so callers remove a package only after its plugins stop. - * A retained profile mapping may change its declarer if its normalized directory, version, and scope stay unchanged. + * roots may be removed; removed roots stop intercepting uncovered directories. A retained profile mapping may + * change its version and declarer when its normalized directory and scope stay unchanged, as when a package is + * upgraded in place. Existing modules and Node caches remain intact: callers remove a package only after its + * plugins stop, and replace the loaded modules of a package upgraded in place separately. + * A previously published link name may point to another real directory; callers stop the plugins of its previous + * target first, as removing and installing the package again does. * @param successor - fully constructed generation retaining the installation mappings. - * @throws when the profile scope, a retained directory, version, scope, or installation declarer changes; - * an installation mapping is removed; a new local name overrides an existing mapping; or a previously published - * link name selects a different real directory. + * @throws when the profile scope changes; and `ResolutionRestartRequired` when a retained profile mapping's real + * directory or scope, or an installation version or declarer changes; an installation mapping is removed; or a new + * local name overrides an existing mapping. */ replace(successor: RuntimeResolution): void /** Restore the native resolver methods. Interceptions dispose in reverse order. */ @@ -153,6 +170,18 @@ export function barePackageName(request: string): string | undefined { return second < 0 ? request : request.slice(0, second) } +/** The JavaScript and native real paths of an existing directory; none for a directory that no longer exists. */ +function realSpellings(path: string): string[] { + try { + // A packed worker's file system has no native realpath. + const native: unknown = Reflect.get(realpathSync, 'native') + return [realpathSync(path), ...typeof native === 'function' ? [String(Reflect.apply(native, realpathSync, [path]))] : []] + } catch (_error) { + // A removed previous directory keeps only its recorded spelling. + return [] + } +} + function canonicalPath(path: string): string { try { return realpathSync(path) @@ -170,7 +199,7 @@ function prefixes(path: string): readonly string[] { function compileResolution(resolution: RuntimeResolution): CompiledResolution { return { - entries: new Map(resolution.entries.map(entry => [entry.name, entry])), + entries: new Map(resolution.entries.map(entry => [entry.name, { ...entry, realDir: canonicalPath(entry.packageDir) }])), profilesDir: resolution.profilesDir, profileDir: resolution.profileDir, profilePaths: prefixes(resolution.profilesDir), @@ -363,10 +392,75 @@ function sameResolution(left: string, right: string): boolean { return canonicalPath(left) === canonicalPath(right) } +/** A directory as given and in its JavaScript and native real paths, each with a trailing separator. */ +function spellings(dir: string): string[] { + const result = new Set([dir + sep]) + for (const real of realSpellings(dir)) result.add(real + sep) + return [...result] +} + +/** + * The previous real directories of packages that moved, each mapped to the current one. Node keeps the real path of + * every link it resolved through and never refreshes it, so a lookup through a link that now points elsewhere still + * selects a file below the target Node saw first; {@link MovedDirectories.relocate} selects the same file below the + * current target instead. + */ +class MovedDirectories { + /** Previous directory to current directory, both with a trailing separator. */ + private readonly targets = new Map() + + /** + * Record that a package moved from one directory to another. Every spelling of the previous directory leads to the + * current one. A directory that an earlier move led to the previous one now leads to the current one as well, since + * Node still selects files below the earliest target. + * @param from Previous real directory. + * @param to Current real directory. + */ + record(from: string, to: string): void { + const previous = spellings(from) + const current = to + sep + for (const [key, value] of this.targets) { + if (previous.includes(value)) this.targets.set(key, current) + } + for (const key of previous) this.targets.set(key, current) + } + + /** + * Forget the previous directories that are current again, in any spelling, and records that lead to themselves. + * @param current The real directories the published resolution maps. + */ + forget(current: readonly string[]): void { + const active = new Set() + for (const dir of current) for (const spelling of spellings(dir)) active.add(spelling) + for (const [key, value] of [...this.targets]) { + if (active.has(key) || key === value) this.targets.delete(key) + } + } + + /** + * The same file below the current directory when it exists, for a file below a previous directory that an importer + * outside that directory selected. An importer inside the previous directory keeps its own package's files. The + * innermost previous directory decides. + * @param filename Selected file. + * @param parent Importer path. + * @returns The file to load. + */ + relocate(filename: string, parent: string | undefined): string { + let from = '' + for (const dir of this.targets.keys()) { + if (filename.startsWith(dir) && dir.length > from.length) from = dir + } + if (from === '' || parent?.startsWith(from) === true) return filename + const relocated = (this.targets.get(from) as string) + filename.slice(from.length) + return existsSync(relocated) ? relocated : filename + } +} + /** One mutable pointer to the immutable runtime resolution. */ class ResolutionRouter { private current: CompiledResolution - private readonly linkedTargets: Map + private linkedTargets: Map + private readonly moved = new MovedDirectories() constructor(resolution: RuntimeResolution, private readonly nodeModulePaths: (directory: string) => string[]) { this.current = compileResolution(resolution) @@ -379,37 +473,64 @@ class ResolutionRouter { || successor.profileDir !== this.current.profileDir) { throw new Error('profile resolution: a successor cannot change its profile scope') } + const rejected: string[] = [] + const moves: [string, string][] = [] for (const [name, current] of this.current.entries) { const next = entries.get(name) if (next === undefined && current.scope === 'profile') continue + if (next !== undefined && current.scope === 'profile' && next.scope === 'profile') { + // Real directories are compared: a link keeps its path when its target changes. + if (current.realDir === canonicalPath(next.packageDir)) continue + rejected.push(`profile resolution: replacing ${JSON.stringify(name)} requires a process restart`) + continue + } if (next === undefined || !sameResolution(current.packageDir, next.packageDir) || (current.scope === 'installation' && !sameResolution(current.declarer, next.declarer)) - || current.version !== next.version + || (current.scope === 'installation' && current.version !== next.version) || current.scope !== next.scope) { - throw new Error(`profile resolution: replacing ${JSON.stringify(name)} requires a process restart`) + rejected.push(`profile resolution: replacing ${JSON.stringify(name)} requires a process restart`) } } const localPackageNames = new Set(successor.localPackageNames) for (const name of localPackageNames) { if (!this.current.localPackageNames.has(name) && this.current.entries.has(name)) { - throw new Error(`profile resolution: overriding ${JSON.stringify(name)} locally requires a process restart`) + rejected.push(`profile resolution: overriding ${JSON.stringify(name)} locally requires a process restart`) } } - // Node retains realpath caches after a root leaves the interception scope. - for (const root of successor.linkedRoots) { - const previous = this.linkedTargets.get(root.name) - if (previous !== undefined && !sameResolution(previous, root.realPath)) { - throw new Error(`profile resolution: relinking ${JSON.stringify(root.name)} requires a process restart`) - } + // A link name pointing elsewhere, or installed again as a directory, moves its package. Names whose link and mapping + // are gone stay recorded, so the package that returns under the name is compared with its last target. + const targets = new Map(this.linkedTargets) + for (const [name, previous] of this.linkedTargets) { + const root = successor.linkedRoots.find(candidate => candidate.name === name) + const entry = entries.get(name) + const target = root?.realPath ?? (entry?.scope === 'profile' ? canonicalPath(entry.packageDir) : undefined) + if (target === undefined || sameResolution(previous, target)) continue + moves.push([previous, target]) + targets.set(name, target) } + if (rejected.length > 0) throw new ResolutionRestartRequired(rejected.join('; ')) const next = compileResolution(successor) - for (const { name, realPath } of successor.linkedRoots) { - if (!this.linkedTargets.has(name)) this.linkedTargets.set(name, realPath) - } + for (const { name, realPath } of successor.linkedRoots) targets.set(name, realPath) + this.linkedTargets = targets + for (const [from, to] of moves) this.moved.record(from, to) + const current: string[] = [] + for (const entry of next.entries.values()) current.push(entry.realDir) + for (const root of successor.linkedRoots) current.push(root.realPath) + this.moved.forget(current) this.current = next } + /** + * The file to load instead of one Node selected through a link that has moved since; see {@link MovedDirectories}. + * @param filename Selected file. + * @param parent Importer path. + * @returns The file to load. + */ + relocate(filename: string, parent: string | undefined): string { + return this.moved.relocate(filename, parent) + } + /** Whether a module path has an interception layer: it lies in the profiles tree, the active profile, or a linked root. */ hasInterceptionLayerForPath(path: string): boolean { return findInterceptionLayer(path, this.current) !== undefined @@ -764,7 +885,21 @@ export function installRuntimeInterception( delegatedEsm = previous } } - return adapted + // Node's last real-path step, for routed and native requests alike, may follow a link it resolved earlier. + const relocate = (result: ResolveResult, parent: string | undefined): ResolveResult => { + if (!result.url.startsWith('file:')) return result + const url = new URL(result.url) + const filename = fileURLToPath(url) + const relocated = router.relocate(filename, parent?.startsWith('file:') === true ? fileURLToPath(parent) : undefined) + if (relocated === filename) return result + url.pathname = pathToFileURL(relocated).pathname + return { ...result, url: url.href } + } + return (request, parent, attributes) => { + const result = adapted(request, parent, attributes) + /* v8 ignore next -- Node 24+ resolves synchronously; the Node 22 matrix covers its Promise result */ + return result instanceof Promise ? result.then(resolved => relocate(resolved, parent)) : relocate(result, parent) + } } let restoreEsm: () => void @@ -904,7 +1039,11 @@ export function installRuntimeInterception( delegatedCjs-- } } - cjs._resolveFilename = wrappedFilename + const relocatedFilename: CommonJsModule['_resolveFilename'] = (request, parent, main, options) => { + const filename = wrappedFilename(request, parent, main, options) + return isAbsolute(filename) ? router.relocate(filename, parent?.filename ?? undefined) : filename + } + cjs._resolveFilename = relocatedFilename return { packageDir(specifier, parentURL) { @@ -913,7 +1052,7 @@ export function installRuntimeInterception( replace(next) { router.replace(next) }, dispose() { /* v8 ignore else -- interceptions are disposed in reverse installation order */ - if (cjs._resolveFilename === wrappedFilename) cjs._resolveFilename = originalFilename + if (cjs._resolveFilename === relocatedFilename) cjs._resolveFilename = originalFilename restoreEsm() }, } diff --git a/packages/boot/app-boot/src/profile-resolution/service.ts b/packages/boot/app-boot/src/profile-resolution/service.ts index 7d493698a7..6e73c16c1a 100644 --- a/packages/boot/app-boot/src/profile-resolution/service.ts +++ b/packages/boot/app-boot/src/profile-resolution/service.ts @@ -76,10 +76,15 @@ export class PluginPackages extends Service { }, 'profile package resolution') } + /** The published runtime resolution, or `undefined` when Node resolves packages alone. */ + get resolution(): RuntimeResolution | undefined { + return this.current + } + /** * Publish a complete successor generation for this process and subsequently created Workers. * Profile mappings and local package names may be removed after their plugins stop; linked roots may also be removed. - * Retained profile mappings may change their declarer, but not their normalized directory, version, or scope; + * Retained profile mappings may change their declarer and version, but not their normalized directory or scope; * installation mappings must remain unchanged. Publication does not unload modules or clear Node caches. * @param successor - fully constructed generation accepted by {@link RuntimeInterception.replace}. */ diff --git a/packages/boot/app-boot/tests/app-boot.spec.ts b/packages/boot/app-boot/tests/app-boot.spec.ts index 826e4fa368..5e83da0b17 100644 --- a/packages/boot/app-boot/tests/app-boot.spec.ts +++ b/packages/boot/app-boot/tests/app-boot.spec.ts @@ -575,6 +575,7 @@ describe('auditStartupEntries', () => { } disabled?: boolean options: { id: string; name: string } + parent?: { tree: { import(name: string): Promise } } } const ctxWith = (entries: FakeEntry[]): Context => ({ @@ -610,7 +611,7 @@ describe('auditStartupEntries', () => { const warn = vi.fn() const original = new Error('todo apply failure') await auditStartupEntries(ctxWith([ - { options: { id: 'missing-tool', name: './missing.mjs' } }, + { options: { id: 'missing-tool', name: './missing.mjs' }, parent: { tree: { import: () => Promise.resolve() } } }, { fiber: fiber(3, original), options: { id: 'tool-todo', name: '@deepseek-ai/dsh-tool-todo' } }, { fiber: fiber(0, undefined, { ready: {}, missing: {} }, ['ready']), @@ -854,7 +855,7 @@ describe('boot', () => { const error = failure as StartupError expect(error.entries).toEqual([{ id: 'webserver', module: './missing.mjs', required: true, fiberState: undefined, - outcome: { kind: 'failed', error: 'failed to import' }, + outcome: { kind: 'failed', error: expect.objectContaining({ message: expect.stringContaining('missing.mjs') as string }) as Error, phase: 'failed to import' }, }]) expect(error.startup?.configurationPath).toBe(config) expect(error.startup?.messages.some(message => message.args.some(arg => arg instanceof Error && arg.message.includes('missing.mjs')))).toBe(true) diff --git a/packages/boot/app-boot/tests/profile-resolution-service.spec.ts b/packages/boot/app-boot/tests/profile-resolution-service.spec.ts index be57535fd4..9ca8a7e0d6 100644 --- a/packages/boot/app-boot/tests/profile-resolution-service.spec.ts +++ b/packages/boot/app-boot/tests/profile-resolution-service.spec.ts @@ -102,7 +102,7 @@ describe('profile package metadata service', () => { expect(createRequire(parentURL).resolve('metadata-lib')).toBe(join(packageDir, 'index.cjs')) }) - it('rejects refreshing a plain-data or absent resolution', async () => { + it('rejects refreshing a plain-data or absent resolution, and exposes the installed one', async () => { const root = mkdtempSync(join(tmpdir(), 'dsh-package-service-plain-')) roots.push(root) const packageDir = join(root, 'lib') @@ -112,10 +112,13 @@ describe('profile package metadata service', () => { resolution: resolution(join(root, 'profiles'), join(root, 'profiles', 'test'), packageDir, pkg(packageDir, '1.0.0'), '1.0.0'), }) await expect(ctx.pluginPackages.refresh()).rejects.toThrow('plugin-packages: the installed runtime resolution cannot be recomputed') + expect(ctx.pluginPackages.resolution?.profileDir).toBe(join(root, 'profiles', 'test')) + // Without an installed resolution Node resolves alone. const bare = new Context() contexts.push(bare) await bare.plugin(PluginPackages) await expect(bare.pluginPackages.refresh()).rejects.toThrow('plugin-packages: the installed runtime resolution cannot be recomputed') + expect(bare.pluginPackages.resolution).toBeUndefined() }) it('reads translated metadata from the selected local package without importing its entry', async () => { @@ -280,11 +283,11 @@ describe('profile package metadata service', () => { expectSelectedPackage(local, '2.0.0') const removedWorkerData = getEnvironmentData(key) expect(removedWorkerData).toEqual({ resolution: removed }) - expect(() => { ctx.pluginPackages.replace(relinked) }).toThrow( - 'profile resolution: relinking "linked-plugin" requires a process restart', - ) + expect(removedWorkerData).toEqual({ resolution: removed }) + // A link name may point to another directory once the plugins of its previous target stopped. + ctx.pluginPackages.replace(relinked) expectSelectedPackage(local, '2.0.0') - expect(getEnvironmentData(key)).toBe(removedWorkerData) + expect(getEnvironmentData(key)).toEqual({ resolution: relinked }) await ctx.fiber.dispose() contexts.pop() diff --git a/packages/boot/app-boot/tests/profile-resolution.spec.ts b/packages/boot/app-boot/tests/profile-resolution.spec.ts index 82cf24b0ea..b56d1a1774 100644 --- a/packages/boot/app-boot/tests/profile-resolution.spec.ts +++ b/packages/boot/app-boot/tests/profile-resolution.spec.ts @@ -18,6 +18,7 @@ import { getEnvironmentData } from 'node:worker_threads' import { afterEach, describe, expect, it } from 'vitest' import { installRuntimeInterception, + ResolutionRestartRequired, registerWorkerResolution, type RuntimeInterception, } from '../src/profile-resolution/resolver.ts' @@ -1642,14 +1643,128 @@ describe('runtime resolution', { concurrent: false }, () => { registration.replace({ ...withLocal, linkedRoots: [linked] }) registration.replace({ ...withLocal, linkedRoots: [] }) registration.replace({ ...withLocal, linkedRoots: [linked] }) - expect(() => { - registration.replace({ ...withLocal, linkedRoots: [{ ...linked, realPath: join(f.root, 'work', 'b') }] }) - }).toThrow(/relinking "linked-plugin" requires a process restart/u) + // A link name may point to another directory once its previous target's plugins stopped. + registration.replace({ ...withLocal, linkedRoots: [{ ...linked, realPath: join(f.root, 'work', 'b') }] }) expect(registration.packageDir( '@deepseek-ai/dsh-core', pathToFileURL(join(f.profile.dir, 'entry.mjs')).href, )).toBe(f.installed) }) + it('selects the current target for a native lookup through a link Node resolved before it moved', async () => { + const f = fixture() + const first = join(f.root, 'work', 'first') + const second = join(f.root, 'work', 'second') + pkg(first, 'linked-lib', 1) + pkg(second, 'linked-lib', 2) + // An importer outside every interception layer resolves natively through the link. + const outside = join(f.root, 'outside') + const link = join(outside, 'node_modules', 'linked-lib') + mkdirSync(dirname(link), { recursive: true }) + symlinkSync(first, link, process.platform === 'win32' ? 'junction' : 'dir') + const parentPath = join(outside, 'entry.mjs') + file(parentPath, '') + const parent = pathToFileURL(parentPath).href + const base = await resolutionOf(f) + const linked = { name: 'linked-lib', realPath: realpathSync.native(first) } + const registration = installRuntimeInterception({ ...base, linkedRoots: [linked] }) + registrations.push(registration) + expect(fileURLToPath(resolveFrom('linked-lib', parent))).toBe(join(realpathSync(first), 'index.js')) + expect(createRequire(parentPath).resolve('linked-lib')).toBe(join(realpathSync(first), 'index.cjs')) + unlinkSync(link) + symlinkSync(second, link, process.platform === 'win32' ? 'junction' : 'dir') + registration.replace({ ...base, linkedRoots: [{ ...linked, realPath: realpathSync.native(second) }] }) + expect(fileURLToPath(resolveFrom('linked-lib', parent))).toBe(join(realpathSync.native(second), 'index.js')) + expect(createRequire(parentPath).resolve('linked-lib')).toBe(join(realpathSync.native(second), 'index.cjs')) + // A file only the previous target has, or a builtin, stays as Node selected it. + file(join(first, 'only-first.cjs'), '') + expect(createRequire(parentPath).resolve(join(first, 'only-first.cjs'))).toBe(join(realpathSync(first), 'only-first.cjs')) + expect(resolveFrom('node:fs', parent)).toBe('node:fs') + // An entry point's lookup has no importer. + expect(fileURLToPath(resolveFrom(pathToFileURL(join(link, 'index.js')).href, undefined))).toBe(join(realpathSync.native(second), 'index.js')) + // A file system without a native realpath, as a packed worker has, records the JavaScript spelling alone. + const native: unknown = Reflect.get(realpathSync, 'native') + Reflect.deleteProperty(realpathSync, 'native') + try { + registration.replace({ ...base, linkedRoots: [{ ...linked, realPath: realpathSync(second) }] }) + } finally { Reflect.set(realpathSync, 'native', native) } + registration.replace({ ...base, linkedRoots: [{ ...linked, realPath: realpathSync.native(second) }] }) + // A second move takes files of the first target straight to the latest one. + const third = join(f.root, 'work', 'third') + pkg(third, 'linked-lib', 3) + unlinkSync(link) + symlinkSync(third, link, process.platform === 'win32' ? 'junction' : 'dir') + registration.replace({ ...base, linkedRoots: [{ ...linked, realPath: realpathSync.native(third) }] }) + expect(createRequire(parentPath).resolve('linked-lib')).toBe(join(realpathSync.native(third), 'index.cjs')) + // Another link's move leaves this link's relocation as it is, and the innermost previous directory decides. + const outer = { name: 'outer-lib', realPath: realpathSync.native(join(f.root, 'work')) } + const atThird = { ...linked, realPath: realpathSync.native(third) } + registration.replace({ ...base, linkedRoots: [atThird, outer] }) + registration.replace({ ...base, linkedRoots: [atThird, { ...outer, realPath: realpathSync.native(f.root) }] }) + expect(createRequire(parentPath).resolve(join(first, 'index.cjs'))).toBe(join(realpathSync.native(third), 'index.cjs')) + unlinkSync(link) + symlinkSync(second, link, process.platform === 'win32' ? 'junction' : 'dir') + registration.replace({ ...base, linkedRoots: [{ ...linked, realPath: realpathSync.native(second) }] }) + // An importer below the previous target keeps resolving there, and pointing the link back ends the relocation. + expect(createRequire(join(first, 'index.cjs')).resolve('./index.js')).toBe(join(realpathSync(first), 'index.js')) + unlinkSync(link) + symlinkSync(first, link, process.platform === 'win32' ? 'junction' : 'dir') + registration.replace({ ...base, linkedRoots: [linked] }) + expect(createRequire(parentPath).resolve('linked-lib')).toBe(join(realpathSync(first), 'index.cjs')) + // A link installed again as a directory at the same path keeps Node's cached link real path, which is relocated too. + expect(createRequire(parentPath).resolve('linked-lib')).toBe(join(realpathSync(first), 'index.cjs')) + unlinkSync(link) + pkg(link, 'linked-lib', 4) + registration.replace({ ...base, linkedRoots: [], entries: [...base.entries, { + name: 'linked-lib', packageDir: link, version: '4.0.0', declarer: join(link, 'package.json'), scope: 'profile', + }] }) + expect(createRequire(parentPath).resolve('linked-lib')).toBe(join(realpathSync.native(link), 'index.cjs')) + }) + + it('rejects a retained profile package moved to another directory, and maps it there once it was removed', async () => { + const f = fixture() + const firstDir = join(f.root, 'bundle-a', 'node_modules', 'private-lib') + const secondDir = join(f.root, 'bundle-b', 'node_modules', 'private-lib') + pkg(firstDir, 'private-lib', 1) + pkg(secondDir, 'private-lib', 2) + const base = await resolutionOf(f) + const entry = { + name: 'private-lib', packageDir: firstDir, version: '1.0.0', + declarer: join(firstDir, 'package.json'), scope: 'profile' as const, + } + const first = { ...base, entries: [...base.entries, entry] } + const registration = installRuntimeInterception(first) + registrations.push(registration) + const parent = pathToFileURL(join(f.profile.dir, 'entry.mjs')).href + const moved = { ...first, entries: [...base.entries, { + ...entry, packageDir: secondDir, version: '2.0.0', declarer: join(secondDir, 'package.json'), + }] } + expect(() => { registration.replace(moved) }).toThrow(ResolutionRestartRequired) + registration.replace(base) + registration.replace(moved) + expect(registration.packageDir('private-lib', parent)).toBe(secondDir) + }) + + it('reports a package whose link keeps its path but points to another directory', async () => { + const f = fixture() + const firstDir = join(f.root, 'source-a') + const secondDir = join(f.root, 'source-b') + pkg(firstDir, 'linked-lib', 1) + pkg(secondDir, 'linked-lib', 2) + const link = join(f.root, 'links', 'linked-lib') + mkdirSync(dirname(link), { recursive: true }) + symlinkSync(firstDir, link, process.platform === 'win32' ? 'junction' : 'dir') + const base = await resolutionOf(f) + const entry = { name: 'linked-lib', packageDir: link, version: '1.0.0', declarer: join(link, 'package.json'), scope: 'profile' as const } + const resolution = { ...base, entries: [...base.entries, entry], linkedRoots: [{ name: 'linked-lib', realPath: realpathSync(firstDir) }] } + const registration = installRuntimeInterception(resolution) + registrations.push(registration) + unlinkSync(link) + symlinkSync(secondDir, link, process.platform === 'win32' ? 'junction' : 'dir') + // The mapping and the linked root report the same package once. + const relinked = { ...resolution, linkedRoots: [{ name: 'linked-lib', realPath: realpathSync(secondDir) }] } + expect(() => { registration.replace(relinked) }).toThrow(/^profile resolution: replacing "linked-lib" requires a process restart$/u) + }) + it('removes profile mappings and local names while retaining changed profile mappings as restarts', async () => { const f = fixture() const firstDir = join(f.root, 'bundle-a', 'node_modules', 'private-lib') @@ -1708,7 +1823,19 @@ describe('runtime resolution', { concurrent: false }, () => { } }) - it.each(['directory', 'version', 'scope'] as const)('rejects a retained profile mapping with a different %s', async (change) => { + it('accepts a retained profile mapping upgraded in place', async () => { + const f = fixture() + const packageDir = join(f.root, 'bundle', 'node_modules', 'private-lib') + pkg(packageDir, 'private-lib', 1) + const base = await resolutionOf(f) + const entry = { name: 'private-lib', packageDir, declarer: join(packageDir, 'package.json'), version: '1.0.0', scope: 'profile' as const } + const registration = installRuntimeInterception({ ...base, entries: [...base.entries, entry] }) + registrations.push(registration) + registration.replace({ ...base, entries: [...base.entries, { ...entry, version: '2.0.0' }] }) + expect(registration.packageDir('private-lib', pathToFileURL(join(f.profile.dir, 'entry.mjs')).href)).toBe(packageDir) + }) + + it.each(['directory', 'scope'] as const)('rejects a retained profile mapping with a different %s', async (change) => { const f = fixture() const packageDir = join(f.root, 'bundle', 'node_modules', 'private-lib') pkg(packageDir, 'private-lib', 1) @@ -1719,7 +1846,6 @@ describe('runtime resolution', { concurrent: false }, () => { const successor = { ...entry, packageDir: change === 'directory' ? join(f.root, 'different') : entry.packageDir, - version: change === 'version' ? '2.0.0' : entry.version, scope: change === 'scope' ? 'installation' as const : entry.scope, } @@ -2205,7 +2331,7 @@ describe('runtime resolution', { concurrent: false }, () => { }) it.each(['initial', 'added'] as const)( - 'rejects a removed %s root retarget without reactivating either directory', async (generation) => { + 'accepts a removed %s root retargeted to another directory', async (generation) => { const { f, linkedRoot } = unsavedFixture() const link = join(f.profile.dir, 'node_modules', 'my-plugin') const linkType = process.platform === 'win32' ? 'junction' : 'dir' @@ -2237,11 +2363,10 @@ describe('runtime resolution', { concurrent: false }, () => { const successor = await resolutionOf(f) expect(successor.localPackageNames).toEqual([]) expect(successor.linkedRoots).toEqual([{ name: 'my-plugin', realPath: target }]) - expect(() => { registration.replace(successor) }) - .toThrow(/^profile resolution: relinking "my-plugin" requires a process restart$/u) + registration.replace(successor) expectPeerLookup(registration, require, parent, join(linkedRoot, 'node_modules', peer)) expectPeerLookup(registration, createRequire(join(target, 'entry.cjs')), - pathToFileURL(join(target, 'entry.mjs')).href, join(target, 'node_modules', peer)) + pathToFileURL(join(target, 'entry.mjs')).href, f.installed) } finally { registration.dispose() registrations.pop() @@ -2249,64 +2374,6 @@ describe('runtime resolution', { concurrent: false }, () => { }, ) - it('does not retain targets from a rejected successor', async () => { - const { f, linkedRoot } = unsavedFixture() - const modules = join(f.profile.dir, 'node_modules') - const originalLink = join(modules, 'my-plugin') - const newLink = join(modules, 'a-new-plugin') - const replacement = join(f.root, 'work', 'replacement') - const rejectedTarget = join(f.root, 'work', 'rejected-target') - const acceptedTarget = join(f.root, 'work', 'accepted-target') - for (const [dir, name, marker] of [ - [replacement, 'my-plugin', 22], - [rejectedTarget, 'a-new-plugin', 31], - [acceptedTarget, 'a-new-plugin', 32], - ] as const) { - pkg(dir, name, 0, {}, { [peer]: '*' }) - pkg(join(dir, 'node_modules', peer), peer, marker) - } - const require = createRequire(join(linkedRoot, 'entry.cjs')) - const parent = pathToFileURL(join(linkedRoot, 'entry.mjs')).href - const first = await resolutionOf(f) - expect(first.localPackageNames).toEqual([]) - const registration = installRuntimeInterception(first) - registrations.push(registration) - try { - expectPeerLookup(registration, require, parent, f.installed) - unlinkSync(originalLink) - const removed = await resolutionOf(f) - expect(removed.linkedRoots).toEqual([]) - registration.replace(removed) - expectPeerLookup(registration, require, parent, join(linkedRoot, 'node_modules', peer)) - - const linkType = process.platform === 'win32' ? 'junction' : 'dir' - symlinkSync(replacement, originalLink, linkType) - symlinkSync(rejectedTarget, newLink, linkType) - const rejected = await resolutionOf(f) - expect(rejected.linkedRoots).toEqual([ - { name: 'a-new-plugin', realPath: rejectedTarget }, - { name: 'my-plugin', realPath: replacement }, - ]) - expect(() => { registration.replace(rejected) }) - .toThrow(/^profile resolution: relinking "my-plugin" requires a process restart$/u) - expectPeerLookup(registration, require, parent, join(linkedRoot, 'node_modules', peer)) - expectPeerLookup(registration, createRequire(join(rejectedTarget, 'entry.cjs')), - pathToFileURL(join(rejectedTarget, 'entry.mjs')).href, join(rejectedTarget, 'node_modules', peer)) - - unlinkSync(originalLink) - unlinkSync(newLink) - symlinkSync(acceptedTarget, newLink, linkType) - const accepted = await resolutionOf(f) - expect(accepted.linkedRoots).toEqual([{ name: 'a-new-plugin', realPath: acceptedTarget }]) - registration.replace(accepted) - expectPeerLookup(registration, createRequire(join(acceptedTarget, 'entry.cjs')), - pathToFileURL(join(acceptedTarget, 'entry.mjs')).href, f.installed) - expectPeerLookup(registration, require, parent, join(linkedRoot, 'node_modules', peer)) - } finally { - registration.dispose() - registrations.pop() - } - }) }) it.each(['src', 'dist'] as const)( diff --git a/packages/boot/app-boot/tests/user-patches.spec.ts b/packages/boot/app-boot/tests/user-patches.spec.ts index 465f9d82c6..09e5653c56 100644 --- a/packages/boot/app-boot/tests/user-patches.spec.ts +++ b/packages/boot/app-boot/tests/user-patches.spec.ts @@ -337,7 +337,10 @@ describe('profile reconciliation settlement', () => { const patches = [{ insert: [{ id: 'missing-plugin', name: './missing.mjs' }] }] const ctx = await boot(NAME, join(dir, 'cordis.yml'), patches) onTestFinished(() => ctx.fiber.dispose()) - expect(await reconcileProfilePatches(ctx, patches, NAME)).toEqual(['missing-plugin (./missing.mjs): failed to import']) + // Reload diagnostics carry the import error's message without stack frames. + const prefix = 'missing-plugin \\(\\./missing\\.mjs\\): failed to import: Error \\[ERR_MODULE_NOT_FOUND\\]' + const diagnostic = new RegExp(`^${prefix}: Cannot find module '[^']*missing\\.mjs' imported from [^\\n]*$`, 'u') + expect(await reconcileProfilePatches(ctx, patches, NAME)).toEqual([expect.stringMatching(diagnostic)]) }) it('rejects a context without the launcher root Include', async () => { diff --git a/packages/boot/hmr/README.i18n.yaml b/packages/boot/hmr/README.i18n.yaml index 0c7fcc7a1f..776c0d0fe7 100644 --- a/packages/boot/hmr/README.i18n.yaml +++ b/packages/boot/hmr/README.i18n.yaml @@ -18,11 +18,11 @@ en: 9fd376ac44e7f515 zh: 83bd165982e99d2a /deepseek-ai-dsh-hmr/use-this-package/configuration: - en: 45ee194ad781d603 - zh: b3d7fcd8ed73717c + en: 11ef18c6d08f39f3 + zh: 2911cdd03ff3747d /deepseek-ai-dsh-hmr/understand-the-implementation: - en: 8c19121bdb5176c1 - zh: 8a01a60ecca6175c + en: bd608f418630317f + zh: 053fa77a3fa571a9 /deepseek-ai-dsh-hmr/model-experience: en: 215e7ba838619b7b zh: a311da3843709f90 @@ -39,8 +39,8 @@ en: 89e0624fafd701fb zh: 19d36a33c1d9af9d /deepseek-ai-dsh-hmr/known-limitations-and-deferred-work: - en: 0f46d4df1dab7f3b - zh: d172835159a20cce + en: 709368e0d6a644c2 + zh: 2d0f76c2cece79dc /deepseek-ai-dsh-hmr/known-limitations-and-deferred-work/dev-note: en: 7972e4b2005ec9a4 zh: 754c49ec538c164c diff --git a/packages/boot/hmr/README.md b/packages/boot/hmr/README.md index 97ee67c8e9..8d2270b4a1 100644 --- a/packages/boot/hmr/README.md +++ b/packages/boot/hmr/README.md @@ -46,6 +46,8 @@ Existing configurations replace the module name `@deepseek-ai/cordis-plugin-hmr` Chokidar options, including polling, retain their existing meaning. Exact configuration watches also observe additions, removals and initially missing parent directories. They default to `awaitWriteFinish: true`: edits wait for Chokidar's 2-second write-stability window, avoiding its lossy change-event throttle. Configure `awaitWriteFinish` to adjust that window; disabling it can miss rapid consecutive edits. Direct Plugin Manager operations apply without waiting for file events. +Profile recompositions run by HMR's profile watches and by Plugin Manager also replace plugins whose loaded files below the profile directory changed on disk. This covers installed packages below `node_modules` and the targets of linked packages. A package whose files changed in place, such as a linked package whose build output changed or a dependency another installation upgraded, therefore takes effect without a restart at the next such recomposition; source watching through `root` is not required. A replacement that fails to load stops the previous plugin and is reported as `failed` with its load error instead of as a restart, which would load the same files; every later recomposition imports it again, so it starts once its files or dependencies are corrected. + ----- @@ -58,6 +60,16 @@ Chokidar options, including polling, retain their existing meaning. Exact config App-boot owns profile parsing and patch precedence. HMR reads the launcher’s data-only `profileContext`, registers the profile manifest and both user patch watches during initialization, and waits for application readiness before processing changes. Its disposal closes the watchers and cancels reloads waiting for startup. Configuration watchers start outside the active transaction context so later notifications can enter the queue. +`reconcileProfile()` is shared by HMR’s profile watches and Plugin Manager; callers hold `runExclusive()`. Config Editor recomposes through app-boot without replacing modules. App-boot prepares and preflights the generation before caches or running entries change. + +File comparison covers the profile directory and linked package targets, recorded when the application becomes ready. [ModuleFiles](src/module-files.ts) records inode, size and modification time; adding a hard link alone does not invalidate a module. Existing modules retain the identities read before replacement, preserving later writes. Newly loaded modules are recorded at settlement so a subsequent installation does not restart them merely because they are recent. + +Replacement follows ES module links and every CommonJS parent’s `children`. A module's plugins are read from its ES module namespace and from the CommonJS exports Node already cached, without evaluating anything, so a CommonJS plugin registered outside a Loader entry keeps its cache. Host dependencies and plugins outside the traversable Loader tree stay pending. The remaining changed modules and their importers are evicted without a backup, and the Loader entries running affected plugins stop. HMR imports no replacement code itself: the Loader imports the current files when the generation starts an entry, so disabled, removed and incompatible rows never execute it, and dynamic `disabled` expressions and nested Includes are evaluated as at startup. + +Affected runtimes stop without making their entries disabled, and `reconcileProfile()` passes them to app-boot's single `recomposeProfile()` call, which starts them, including those below an unchanged Include and those outside the profile composition, without importing an entry twice. A failure before any stop leaves every plugin running; stopped plugins start again with the current resolution when no generation applied. After eviction, a replacement that fails to import or start is an activation failure and rejects the operation; its previous code no longer runs. Every recomposition imports entries whose import failed again, after expiring the package configuration below the profile roots and evicting modules whose links failed, so a corrected file or a dependency installed later takes effect without a restart. [ProfileReconciliation](../../../docs/subsystems/boot.md#profile-reconciliation) lists changes requiring a restart. A module first loaded during a recomposition whose file was written after the comparison stays stale, so a write during activation is loaded next time; comparison or cache-inspection failure requires a restart even when no changed URL is known. HMR’s watches log the result. + +After each recomposition, HMR evicts the loaded modules below the directories of the mappings the recomposition dropped and expires their package configuration, so the packages a removed or deselected bundle depends on, including those it imports dynamically, are imported afresh when it returns. A bundle package is not a mapping: its own files are replaced when they changed on disk, and a bundle switched off while its files stay unchanged keeps its modules loaded. A publication the runtime resolution rejects, such as a retained profile mapping that moved to another directory, stops and starts again the rows the generation would drop and leaves the composition as it was; the result has `restartReason: 'package-moved'`. + A changed `package.json` outside `node_modules` expires that package's cached configuration. A configuration-only manifest triggers no module reload; a manifest imported as a JSON module retains dependency-driven reloads, and one in the host dependency graph retains host reloads. Dedicated configuration watches keep their existing ownership. A new package entry takes effect when its Loader entry restarts. Loader entries retain their raw import results. HMR retains every distinct imported namespace for each entry name and configuration-tree base URL; an uninitialized entry does not overwrite another entry's namespace. HMR matches those objects to cached ModuleJob namespaces instead of resolving entry names again. Names without an imported namespace and `cordis:` builtins keep name-based resolution. Source reloads therefore use the loaded modules after `exports` or `main` changes. @@ -94,7 +106,16 @@ Reloading a contributing plugin can change later request prefixes; HMR does not - Module replacement requires Node loader internals. Framework dependency changes call the host-provided `loader.exit()` hook; HMR itself does not restart the process. -- Replacing installed package versions still requires a restart through Plugin Manager; manifests below `node_modules` keep Node's cached configuration. The browser Client module graph retains its separate browser-side loading mechanism. +- A change confined to `package.json` is not detected. Watcher notifications for manifests below `node_modules` are still ignored. The browser Client module graph retains its separate browser-side loading mechanism. +- Replacement follows static imports. A dynamic `import()` of a stale module evaluates the new file on its next call while its importer keeps running; existing Workers and the TSX loader thread keep their module caches. +- Every symbolic link below the profile's `node_modules` widens the comparison scope. Linking a package that the process entry point, located through the real path of a bin link, imports statically, such as `dsh-app-boot`, Cordis or the Loader from a dsh checkout, brings host modules into scope; their changes stay pending, and Plugin Manager reports `restart-required` for later operations until the process restarts. Linked dsh packages that Loader entries import are replaced like other plugins. +- The comparison records belong to one HMR instance. A recomposition that restarts the `hmr` entry itself records the current files again, so changes pending at that point are no longer reported or retried. +- A replacement that fails to import or start stays inactive until its files or dependencies are corrected; the previous version is not restored. +- Compatibility checks run only where DSH composes rows: startup, profile recomposition and preset mounting; the Loader checks nothing when it imports a module. A replaced profile row passes the recomposition's preflight, but a row outside the profile composition, such as a preset row or an entry added through `ctx.loader.create()`, restarts with replaced code without a check. Plugin Manager refuses incompatible versions, so this affects packages changed outside it, such as a manual `pnpm add` in the profile directory or a rebuilt `link:` package. +- The [packed worker loader](../../experimental/webworker-runtime/src/module-system/module-loader.ts) exposes neither Node’s `loadCache` nor `require.cache`; configuration recomposition remains available, but code replacement requires a restart. Native Node retains these caches even with asynchronous module hooks. The [packed worker browser test](../../../apps/web/tests/preview-boot.e2e.ts) exercises this deployment. +- A newly loaded file is first recorded after recomposition settles and counts as changed when its modification time follows the comparison. The comparison time is the modification time of a marker file HMR writes in the temporary directory, so on one machine both come from the clock local file writes are stamped with; an edit within the timestamp granularity of the profile's filesystem after the comparison counts as earlier and can be missed, and network filesystems stamp writes with the server's clock. +- A file replaced by a hard link to an older store file keeps an older modification time; when its module was first loaded after the last recording, the change is not detected. +- Profile recomposition caused by HMR's own watches does not run for dependency-only manifest changes, so an install performed outside Plugin Manager takes effect at the next recomposition or restart. - TSX loaders and ordinary application-created Workers are outside the supported package-cache refresh scope. - CommonJS requests must be resolvable by Node's default resolver. Synchronous hooks may postprocess the resulting filenames, as Desktop's Office resolver does; hooks requiring the original request name or introducing virtual requests are unsupported. Builtins retain Node's original loading behavior. - Future work: retain package invalidation across HMR disposal and replacement. Restoring the native readers can expose their older cached configuration again. diff --git a/packages/boot/hmr/README.zh.md b/packages/boot/hmr/README.zh.md index a7a9f84b1b..21044fe7d1 100644 --- a/packages/boot/hmr/README.zh.md +++ b/packages/boot/hmr/README.zh.md @@ -46,6 +46,8 @@ kind: "package-reference" Chokidar 选项(包括轮询)保持原有含义。精确配置监听同时观察新增、删除及初始不存在的父目录。它们默认使用 `awaitWriteFinish: true`:编辑后等待 Chokidar 的 2 秒写入稳定窗口,避免其变化事件节流丢失通知。可通过 `awaitWriteFinish` 调整窗口;禁用它可能漏掉快速连续编辑。直接通过 Plugin Manager 发起的操作无需等待文件事件即可应用。 +HMR 的 profile 监听和 Plugin Manager 执行的 profile 重新组合,还会替换 profile 目录下已加载文件在磁盘上发生变化的插件。这包括 `node_modules` 之下的已安装包,以及已链接包的目标。因此,文件被原地更改的包,例如构建输出发生变化的已链接包,或被另一次安装升级的依赖,会在下一次此类重新组合时生效,无需重启;不要求通过 `root` 监听源码。替换版本加载失败时,原插件被停止,并以 `failed` 及其加载错误报告,而不是报告为需要重启,因为重启会加载相同的文件;此后每次重新组合都会再次导入它,因此其文件或依赖被修正后它即启动。 + ----- @@ -58,6 +60,16 @@ Chokidar 选项(包括轮询)保持原有含义。精确配置监听同时 App-boot 负责 profile 解析和 patch 优先级规则。HMR 读取启动器提供的纯数据 `profileContext`,在初始化时注册 profile manifest 和两份用户 patch 的监听,并等待应用就绪后处理更改。销毁 HMR 时会关闭监听器并取消等待启动的重载。配置监听器在当前事务上下文之外启动,使后续通知可以进入队列。 +`reconcileProfile()` 由 HMR 的 profile 监听和 Plugin Manager 共用;调用方持有 `runExclusive()`。Config Editor 通过 app-boot 重新组合,不替换模块。app-boot 在缓存或运行中条目发生变化前准备新一代组合并执行预检。 + +文件比较覆盖 profile 目录及已链接包的目标,在应用就绪时记录。[ModuleFiles](src/module-files.ts) 记录 inode、大小和修改时间;仅添加硬链接不会使模块失效。已有模块保留替换前读取的身份,以检测之后的写入。新加载的模块在结算时记录,因此后续安装不会仅因其文件较新而将其重启。 + +替换沿 ES 模块链接以及每个 CommonJS 父模块的 `children` 传播。模块的插件从其 ES 模块命名空间以及 Node 已缓存的 CommonJS exports 中读取,不求值任何代码,因此在 Loader entry 之外注册的 CommonJS 插件保留其缓存。宿主依赖及可遍历 Loader 树之外的插件保持待处理。其余已变化模块及其导入方被逐出且不保留备份,运行受影响插件的 Loader entry 被停止。HMR 本身不导入替换代码:新一代组合启动条目时,Loader 导入当前文件,因此停用、删除和不兼容的行永远不会执行替换代码,动态 `disabled` 表达式和嵌套 Include 按启动时的方式求值。 + +受影响的运行时停止,但其条目不会被标记为停用;`reconcileProfile()` 把它们传给 app-boot 的单次 `recomposeProfile()` 调用,由该调用启动它们,包括未变化 Include 之下的条目和 profile 组合之外的条目,且不会重复导入同一条目。任何插件停止之前发生的失败会让所有插件继续运行;没有新一代组合被应用时,已停止的插件以当前 resolution 重新启动。逐出之后,导入或启动失败的替换版本属于激活失败,使操作失败;其旧代码不再运行。每次重新组合都会先让 profile 根目录下的包配置失效并逐出链接失败的模块,再次导入此前导入失败的条目,因此修正后的文件或之后安装的依赖无需重启即生效。[ProfileReconciliation](../../../docs/subsystems/boot.zh.md#profile-reconciliation) 列出需要重启的变化。在重新组合期间首次加载、且其文件在比较之后被写入的模块保持陈旧,因此激活期间的写入会在下一次加载;比较或缓存检查失败时,即使无法确定已变化的 URL,也要求重启。HMR 的监听记录这些结果。 + +每次重新组合之后,HMR 逐出重新组合删除的映射所在目录之下的已加载模块,并使这些包的配置过期,因此已删除或取消选入的组合包连同其动态导入的依赖,在它再次出现时重新导入。组合包的包本身不是映射:其文件在磁盘上变化时被替换;关闭组合包而其文件未变化时,它的模块保持加载。runtime resolution 拒绝的发布(例如移动到其他目录的保留 profile 映射)会停止并重新启动新一代组合将删除的行,组合保持不变;结果带有 `restartReason: 'package-moved'`。 + `node_modules` 之外的 `package.json` 变化使该包的配置缓存失效。仅作为配置的 manifest 不触发模块重载;作为 JSON 模块导入的 manifest 保留依赖驱动重载,位于宿主依赖图中的 manifest 保留宿主重载。专属配置监听保留原有归属。新包入口在 Loader entry 重启时生效。 Loader entry 保留原始导入结果。HMR 为每个 entry 名称与配置树 base URL 保留所有不同的已导入命名空间;尚未初始化的 entry 不会覆盖其他 entry 的命名空间。HMR 将这些对象与缓存中 ModuleJob 的模块命名空间按对象身份匹配,不再重新解析 entry 名称。没有已导入命名空间的名称和 `cordis:` 内置入口仍按名称解析。因此 `exports` 或 `main` 改变后,源码重载仍使用已加载模块。 @@ -94,7 +106,16 @@ Loader entry 保留原始导入结果。HMR 为每个 entry 名称与配置树 b - 模块替换需要 Node loader 内部接口。框架依赖变化调用宿主提供的 `loader.exit()` 钩子;HMR 本身不重启进程。 -- 通过插件管理器替换已安装包版本仍需要重启;`node_modules` 之下的 manifest 保留 Node 缓存的配置。浏览器 Client 模块图保留独立的浏览器侧加载机制。 +- 仅限于 `package.json` 的变化不会被检测到。`node_modules` 之下 manifest 的监听通知仍被忽略。浏览器 Client 模块图保留独立的浏览器侧加载机制。 +- 替换沿静态导入传播。对陈旧模块的动态 `import()` 在下次调用时求值新文件,而其导入方继续运行;已存在的 Worker 和 TSX loader 线程保留各自的模块缓存。 +- profile 的 `node_modules` 之下的每个符号链接都会扩大比较范围。链接进程入口(通过 bin 链接的真实路径定位)静态导入的包(例如来自 dsh checkout 的 `dsh-app-boot`、Cordis 或 Loader)会把宿主模块纳入范围;这些模块的变化保持待处理,Plugin Manager 对后续操作报告 `restart-required`,直到进程重启。Loader entry 导入的已链接 dsh 包与其他插件一样被替换。 +- 比较记录属于单个 HMR 实例。重启 `hmr` 条目本身的重新组合会重新记录当前文件,因此此时待处理的变化不再被报告或重试。 +- 导入或启动失败的替换版本保持未激活,直到其文件或依赖被修正;旧版本不会被恢复。 +- 兼容性检查只在 DSH 组合行时运行:启动、profile 重新组合和 preset 挂载;Loader 导入模块时不做任何检查。被替换的 profile 行会通过重新组合的预检,但 profile 组合之外的行(例如 preset 行,或通过 `ctx.loader.create()` 添加的条目)以替换后的代码重启,不经检查。Plugin Manager 拒绝不兼容的版本,因此这只影响在它之外变化的包,例如在 profile 目录中手动执行 `pnpm add`,或重新构建的 `link:` 包。 +- [打包 worker loader](../../experimental/webworker-runtime/src/module-system/module-loader.ts) 不暴露 Node 的 `loadCache` 或 `require.cache`;配置重新组合仍可执行,但代码替换需要重启。原生 Node 即使使用异步模块 hook 也保留这些缓存。[打包 worker 浏览器测试](../../../apps/web/tests/preview-boot.e2e.ts) 覆盖此部署。 +- 新加载文件首次在重新组合结算后记录,其修改时间晚于比较时间时计为已变化。比较时间是 HMR 在临时目录中写入的标记文件的修改时间,因此在同一台机器上,两者都来自为本地文件写入记时的时钟;在比较之后、处于 profile 所在文件系统时间戳精度之内的修改被视为更早,可能被漏检;网络文件系统使用服务器的时钟为写入记时。 +- 若文件被替换为指向较旧 store 文件的硬链接,它保留较旧的修改时间;当其模块在上次记录之后才首次加载时,该变化不会被检测到。 +- HMR 自身监听触发的 profile 重新组合不会因仅涉及依赖的 manifest 变化而执行,因此在 Plugin Manager 之外执行的安装在下一次重新组合或重启时生效。 - TSX loader 和普通应用自行创建的 Worker 不在包缓存刷新支持范围内。 - CommonJS 请求必须能由 Node 默认 resolver 解析。同步 hook 可以像 Desktop 的 Office resolver 一样处理解析后的文件名;依赖原始请求名或引入虚拟请求的 hook 不受支持。内置模块保留 Node 原有的加载行为。 - 后续工作:跨 HMR 销毁和替换保留包配置失效状态。恢复原生 reader 后,其旧配置缓存可能重新可见。 diff --git a/packages/boot/hmr/src/index.ts b/packages/boot/hmr/src/index.ts index cad957dbda..47d71d9109 100644 --- a/packages/boot/hmr/src/index.ts +++ b/packages/boot/hmr/src/index.ts @@ -2,16 +2,19 @@ import assert from 'node:assert/strict' import { AsyncLocalStorage } from 'node:async_hooks' import { watchConfig as watchExactConfig } from './watch-config.ts' -import { Context, Inject, Service, type Fiber, type Plugin } from '@deepseek-ai/cordis' -import type { ModuleLoader, ModuleJob, ResolveResult } from '@deepseek-ai/cordis-plugin-loader' +import { Context, FiberState, Inject, Service, type Fiber, type Plugin } from '@deepseek-ai/cordis' +import type { Entry, ModuleLoader, ModuleJob, ResolveResult } from '@deepseek-ai/cordis-plugin-loader' import type { Include } from '@deepseek-ai/cordis-plugin-include' import { FSWatcher, watch, type ChokidarOptions } from 'chokidar' import { basename, dirname, join, relative, resolve, sep } from 'node:path' -import { readFileSync, realpathSync } from 'node:fs' -import { readProfileManifest, readProfilePatches, reconcileProfilePatches, PROFILE_PATCH_FILENAME } from '@deepseek-ai/dsh-app-boot' +import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync } from 'node:fs' +import { + readProfileManifest, readProfilePatches, recomposeProfile, PROFILE_PATCH_FILENAME, type RuntimeResolution, +} from '@deepseek-ai/dsh-app-boot' import type {} from '@deepseek-ai/dsh-cmdline' import { handleError } from './error.ts' import { PackageManifests } from './package-manifest.ts' +import { ModuleFiles } from './module-files.ts' import type {} from '@deepseek-ai/cordis-plugin-timer' import { fileURLToPath, pathToFileURL } from 'node:url' import { createRequire } from 'node:module' @@ -63,16 +66,16 @@ export interface HmrConfig extends ChokidarOptions { /** * Recursively collect all module dependencies from a ModuleJob. - * Skips node: builtins and node_modules to focus on user code. + * Skips node: builtins, and node_modules unless `includeNodeModules` is set, to focus on user code. */ -async function loadDependencies(job: ModuleJob, ignored = new Set()) { +async function loadDependencies(job: ModuleJob, ignored = new Set(), includeNodeModules = false) { const dependencies = new Set() async function traverse(job: ModuleJob) { if (ignored.has(job.url) || dependencies.has(job.url)) return - if (job.url.startsWith('node:') || job.url.includes('/node_modules/')) return + if (job.url.startsWith('node:') || (!includeNodeModules && job.url.includes('/node_modules/'))) return dependencies.add(job.url) - const children = await job.linked - await Promise.all(Array.prototype.map.call(children, traverse)) + const children = await linkedJobs(job) + await Promise.all(children.map(traverse)) } await traverse(job) return dependencies @@ -254,6 +257,9 @@ class Hmr extends Service { private closing = false private readonly configPaths = new Set() private readonly manifests = new PackageManifests() + private readonly files = new ModuleFiles() + /** Every module statically reachable from the process entry point, including those under node_modules; collected on first use. */ + private hostModules!: () => Promise> /** Serialize a caller-owned mutation with all automatic reload paths. * @param operation Work that must not overlap module or configuration replacement. @@ -308,7 +314,48 @@ class Hmr extends Service { this.baseDir = fileURLToPath(new URL(config.base || '.', ctx.baseUrl)) } + /** + * The cached ES module job for a URL. The packed worker loader exposes no Node module cache; native Node + * retains this cache even when asynchronous module hooks run in a separate thread. + */ + private esmJob(url: string): ModuleJob | undefined { + return this.hasEsmCache() ? this.internal.loadCache.get(url) : undefined + } + + /** Whether this thread keeps a Node ES module cache; the packed worker loader keeps none. */ + private hasEsmCache(): boolean { + const cache: unknown = Reflect.get(this.internal, 'loadCache') + return cache !== undefined + } + + /** Whether the ES module cache holds a job for a URL; `false` when this thread keeps no ES module cache. */ + private esmHas(url: string, type?: string): boolean { + return this.hasEsmCache() && (type === undefined ? this.internal.loadCache.has(url) : this.internal.loadCache.has(url, type)) + } + + /** ES module cache keys, empty when this thread keeps no ES module cache. */ + private esmUrls(): string[] { + return this.hasEsmCache() ? [...Map.prototype.keys.call(this.internal.loadCache) as Iterable] : [] + } + + /** ES module cache keys and CommonJS cache filenames as URLs. */ + private* loadedUrls(): Generator { + yield* this.esmUrls() + for (const filename of Object.keys(commonJsCache())) yield pathToFileURL(filename).href + } + + /** The module job of the process entry point, when the loader cached it under the entry's real path. */ + private mainJob(): ModuleJob | undefined { + const entry = process.argv[1] + if (entry === undefined) return undefined + // A package bin starts through a link, and Node caches the module under its target. + return this.esmJob(pathToFileURL(canonicalPath(resolve(entry))).href) + } + async* [Service.init](): AsyncGenerator<() => Promise, void, unknown> { + const mainJob = this.mainJob() + let host: Promise> | undefined + this.hostModules = () => host ??= mainJob === undefined ? Promise.resolve(new Set()) : loadDependencies(mainJob, new Set(), true) yield async () => { this.closing = true await this.watcher?.close() @@ -323,7 +370,16 @@ class Hmr extends Service { if (ready === undefined) throw new Error('Profile HMR requires application readiness') const started = Promise.withResolvers() this.applicationReady = started.promise - const unsubscribe = ready.onReady(() => { started.resolve(true) }) + const unsubscribe = ready.onReady(() => { + // Plugins started with the application are recorded; modules loaded later are compared by modification time. + try { + this.files.observe(this.profileModules(profileRoots(profile.dir))) + } catch (error) { + // Without a first record, modules are compared by modification time against this HMR instance’s creation time. + this.ctx.logger.warn(error) + } + started.resolve(true) + }) yield () => { unsubscribe(); started.resolve(false); return Promise.resolve() } const manifestPath = join(profile.dir, 'package.json') const patchFiles = [profile.patchPath, join(profile.home, PROFILE_PATCH_FILENAME)] @@ -340,8 +396,9 @@ class Hmr extends Service { } })]) if (inputs === lastInputs) return - const patches = readProfilePatches('dsh', profile) - const warnings = await reconcileProfilePatches(this.ownerContext.root, patches, 'dsh') + const { warnings, pending, restartReason } = await this.reconcileProfile() + if (restartReason !== undefined) this.ctx.logger.warn('restart required: %s', restartReason) + if (pending.length > 0) this.ctx.logger.warn('changed modules keep running their previous code until a restart: %o', pending) lastInputs = inputs lastBundles = bundles for (const diagnostic of warnings) this.ctx.logger.warn(diagnostic) @@ -363,8 +420,6 @@ class Hmr extends Service { // Collect externals before opening the watcher so every post-ready change // is observed by listeners that already have their classification state. - const mainJob = process.argv[1] === undefined ? undefined - : this.internal.loadCache.get(pathToFileURL(resolve(process.argv[1])).href) if (mainJob) { this.externals = await loadDependencies(mainJob) } else { @@ -399,7 +454,7 @@ class Hmr extends Service { fullReload = true continue } - if (this.internal.loadCache.has(url) || this.internal.loadCache.has(url, 'json')) { + if (this.esmHas(url) || this.esmHas(url, 'json')) { this.stashed.add(url) continue } @@ -454,7 +509,7 @@ class Hmr extends Service { * @returns Linked module URLs, or an empty list for an uncached module. */ async getLinked(url: string): Promise { - const job = this.internal.loadCache.get(url) + const job = this.esmJob(url) if (!job) return [] const linked = await job.linked return Array.prototype.map.call(linked, (job: ModuleJob) => job.url) as string[] @@ -546,7 +601,7 @@ class Hmr extends Service { try { const { url } = await names.resolve(name, baseUrl, moduleNamespace) if (this.declined.has(url)) continue - const job = this.internal.loadCache.get(url) + const job = this.esmJob(url) const plugin = this.ctx.loader.unwrapExports(job?.module?.getNamespace()) as Plugin | undefined if (!job || !plugin) continue pending.set(job, plugin) @@ -584,49 +639,7 @@ class Hmr extends Service { if (ReloadModules.include(reloads, plugin, job, this.ctx.registry.get(plugin))) this.accepted.add(job.url) } - /** - * Clear module caches for all accepted files before re-importing. - * - * We need to clear both: - * 1. ESM loadCache — managed by Node's internal ModuleLoader - * 2. CJS Module._cache — for CJS modules that were imported via import() - * - * In Node 24, CJS modules loaded via import() appear in both caches. - * If we only clear loadCache, the CJS cache may serve stale modules. - * - * We use Map.prototype methods directly on loadCache because: - * - In Node 22/23, loadCache is a plain Map - * - In Node 24, loadCache is a LoadCache extends Map - * where .delete() only sets the type slot to undefined (doesn't remove the entry) - * Using Map.prototype.delete ensures complete removal in both versions. - */ - const esmBackup = new Map() - const cjsBackup = new Map() - const require = createRequire(import.meta.url) - for (const filename of this.accepted) { - // Backup and clear ESM loadCache - const job: unknown = Map.prototype.get.call(this.internal.loadCache, filename) - esmBackup.set(filename, job) - Map.prototype.delete.call(this.internal.loadCache, filename) - - // Backup and clear CJS Module._cache - try { - const filepath = fileURLToPath(filename) - if (require.cache[filepath]) { - cjsBackup.set(filepath, require.cache[filepath]) - Reflect.deleteProperty(require.cache, filepath) - } - } catch { - // filename might not be a file: URL (e.g. node: protocol), ignore - } - } - - const rollback = () => { - for (const [filename, job] of esmBackup) { - Map.prototype.set.call(this.internal.loadCache, filename, job) - } - for (const [filepath, module] of cjsBackup) require.cache[filepath] = module - } + const rollback = this.evict(this.accepted) // Attempt to re-import all plugin entry files const generations = [...reloads].map(([previous, info]) => ({ @@ -726,8 +739,386 @@ class Hmr extends Service { replacements.updateEntries(this.ctx.loader) } this.ctx.emit('hmr/reload', reloads) + this.files.refresh(this.accepted) this.stashed = new Set() } + + /** + * Remove modules from the ES module and CommonJS caches so the next import evaluates their files again. + * + * CommonJS modules loaded through `import()` appear in both caches. Node's `LoadCache` maps each URL to per-type + * jobs, and its own `delete()` only clears one type slot, so whole entries are removed through `Map.prototype`. + * @returns Restores the removed cache entries. + */ + private evict(urls: Iterable): () => void { + const esmBackup = new Map() + const cjsBackup = new Map() + const cache = commonJsCache() + const list = [...urls] + // CommonJS-only files have no loadCache entry to restore. + const cached = new Set(this.esmUrls()) + for (const url of list) { + if (cached.has(url)) { + esmBackup.set(url, Map.prototype.get.call(this.internal.loadCache, url)) + Map.prototype.delete.call(this.internal.loadCache, url) + } + } + for (const filepath of list.filter(url => url.startsWith('file:')).map(url => fileURLToPath(url))) { + const module = cache[filepath] + if (module !== undefined) { + cjsBackup.set(filepath, module) + Reflect.deleteProperty(cache, filepath) + } + } + return () => { + for (const [url, job] of esmBackup) Map.prototype.set.call(this.internal.loadCache, url, job) + for (const [filepath, module] of cjsBackup) cache[filepath] = module + } + } + + /** Loaded `file:` modules below the given profile roots. */ + private profileModules(roots: readonly string[]): string[] { + return [...this.loadedUrls()].filter((url) => { + if (!url.startsWith('file:')) return false + const filename = fileURLToPath(url) + return roots.some(root => filename.startsWith(root)) + }) + } + + /** Static import edges between loaded modules, across ES module links and CommonJS `require` children. */ + private async moduleGraph(): Promise>> { + const importers = new Map>() + const add = (parent: string, child: string) => { + importers.set(child, (importers.get(child) ?? new Set()).add(parent)) + } + await Promise.all([...moduleJobs(this.internal)].map(async (job) => { + for (const child of await linkedJobs(job)) add(job.url, child.url) + })) + for (const [filename, module] of Object.entries(commonJsCache())) { + // Code that writes `require.cache` directly can store objects that are not Node modules. + /* v8 ignore next -- Node stores no undefined values in require.cache; the index type admits them */ + const children: unknown = module === undefined ? undefined : Reflect.get(module, 'children') + if (!Array.isArray(children)) continue + for (const child of children as NodeJS.Module[]) add(pathToFileURL(filename).href, pathToFileURL(child.filename).href) + } + return importers + } + + /** + * Plan the replacement of changed modules without changing anything. A changed module whose importers reach the + * process entry point's static graph, or a plugin that no affected Loader entry starts, stays loaded with its + * importers and is pending. The other changed modules and every module importing them are evicted by + * {@link stopPlanned}, which also expires the manifests from each changed file up to its root and stops the Loader + * entries running affected plugins. + * @param stale Changed module URLs. + * @param roots Directories, with a trailing separator, that bound the expired manifests. + * @returns The pending modules and the replacement of the others. + */ + private async planReplacement(stale: ReadonlySet, roots: readonly string[]): Promise { + const importers = await this.moduleGraph() + const host = await this.hostModules() + const managed = new Set(this.ctx.loader.entries()) + const plan: ReplacementPlan = { + pending: [], evicted: new Set(), manifests: new Set(), plugins: new Set(), fibers: new Set(), + } + for (const url of stale) { + const affected = new Set([url]) + for (const module of affected) for (const importer of importers.get(module) ?? []) affected.add(importer) + const running = [...new Set([...affected].flatMap(module => this.pluginsOf(module)))] + .flatMap(plugin => [...this.ctx.registry.get(plugin)?.fibers ?? []].map(fiber => ({ plugin, fiber }))) + const affectedFibers = new Set(running.map(({ fiber }) => fiber)) + /** The Loader entry whose own fiber runs an affected plugin and starts this fiber directly or through its ancestors. */ + const owner = (fiber: Fiber): Entry | undefined => { + for (let current = fiber; current.uid !== 0; current = current.parent.fiber) { + const entry = current.entry + if (entry?.fiber === current && managed.has(entry) && affectedFibers.has(current)) return entry + } + return undefined + } + const entries = running.flatMap(({ fiber }) => owner(fiber) ?? []) + // A plugin registered outside a Loader entry has no owner that can start its replacement. + const reason = [...affected].some(module => host.has(module)) ? 'host-module' + : entries.length < running.length ? 'unmanaged-plugin' : undefined + if (reason !== undefined) { + plan.pending.push({ url, reason }) + continue + } + for (const manifest of packageManifests(url, roots)) plan.manifests.add(manifest) + for (const module of affected) plan.evicted.add(module) + for (const { plugin, fiber } of running) { + plan.plugins.add(plugin) + plan.fibers.add(fiber) + } + } + return plan + } + + /** + * The plugins a loaded module exports, read from the ES module namespace and the CommonJS exports Node cached for it + * without evaluating anything. + * @param url Loaded module URL. + * @returns The exported plugins. + */ + private pluginsOf(url: string): Plugin[] { + const namespace = namespaceOf(this.esmJob(url)) + const required = url.startsWith('file:') ? commonJsCache()[fileURLToPath(url)] : undefined + const exports: unknown = required?.loaded === true ? required.exports : undefined + return [namespace, exports].filter(value => value !== undefined).map(value => this.ctx.loader.unwrapExports(value) as Plugin) + } + + /** + * Carry out a planned replacement: expire its manifests, evict its modules without a backup, and stop its Loader + * entries, so applying the generation imports the current files when it starts them. + * @param plan The planned replacement. + */ + private async stopPlanned(plan: ReplacementPlan): Promise { + for (const manifest of plan.manifests) this.manifests.invalidate(manifest) + this.evict(plan.evicted) + // Deleting the runtime stops its fibers without the Loader recording the entries as disabled. + for (const plugin of plan.plugins) this.ctx.registry.delete(plugin) + // Cordis contains disposal failures in the fiber, which logs them. + await Promise.allSettled([...plan.fibers].map(fiber => fiber.await())) + } + + /** + * Evict the loaded modules below the directories of mappings a recomposition dropped and expire their package + * configuration, so a disabled or removed package that is installed or enabled again is imported afresh. Modules of + * DSH's own static graph, and those below a directory that is still mapped, stay loaded. + * @param dropped Real directories, with a trailing separator, of the dropped mappings. + * @param kept Real directories, with a trailing separator, of the mappings that stay. + */ + private async evictDropped(dropped: readonly string[], kept: ReadonlySet): Promise { + if (dropped.length === 0) return + const host = new Set(await this.hostModules()) + const urls = this.profileModules(dropped).filter((url) => { + const filename = fileURLToPath(url) + return !host.has(url) && ![...kept].some(dir => filename.startsWith(dir)) + }) + for (const url of urls) for (const manifest of packageManifests(url, dropped)) this.manifests.invalidate(manifest) + this.evict(urls) + } + + /** Report a failed replacement step and retain the caller’s fallback outcome. */ + private async contained(step: string, operation: () => T | Promise, fallback: T): Promise { + try { + return await operation() + } catch (error) { + this.ctx.logger.warn('%s failed; module replacement was not completed', step) + this.ctx.logger.warn(error) + return fallback + } + } + + /** + * Recompose the profile from its manifest and patch files, replacing running plugins whose loaded files below the + * profile directory or its linked package targets changed on disk, and publishing its runtime resolution. Changed + * modules are evicted and the Loader entries running them stop; app-boot's {@link recomposeProfile} then publishes the + * resolution, applies the generation, starts the stopped entries with the current files and configuration, + * imports entries whose import failed again, and judges activation. Afterwards the modules below the mappings the + * recomposition dropped are evicted, so a removed or deselected package is imported afresh when it returns. Callers run + * inside {@link runExclusive}. + * @param requiredIds Entry ids whose inactive state is reported as a failure even when it predates this call. + * @returns Inactive-entry diagnostics, the changed modules that load after a restart, and a profile-wide restart reason. + * @throws The patch read, publication, preparation, apply, or activation failure of the recomposition, including a + * replacement plugin that fails to load or start, whose previous code no longer runs. + */ + async reconcileProfile(requiredIds: readonly string[] = []): Promise { + if (this.executing.getStore() !== true) throw new Error('HMR profile reconciliation must run inside runExclusive') + const profile = this.ownerContext.get('profileContext') + if (profile === undefined) throw new Error('HMR profile reconciliation requires profileContext') + // Entries this recomposition stops start again, and fail it when they cannot. + const running = [...this.ctx.loader.entries()].filter(entry => entry.fiber?.state === FiberState.ACTIVE) + // Read while the packages are still on disk, so a removed link's target is known in its real spellings. + const mapped = mappedDirectories(this.ownerContext.get('pluginPackages')?.resolution) + let comparison: ProfileComparison | undefined + let comparedAt = 0 + let pending: PendingModule[] = [] + try { + const patches = readProfilePatches('dsh', profile) + comparedAt = this.files.now() + // A failed comparison keeps every record, so the next recomposition compares the same modules again. + comparison = await this.contained('comparing loaded modules with disk', () => { + const roots = profileRoots(profile.dir) + return Promise.resolve({ roots, ...this.files.compare(this.profileModules(roots)) }) + }, undefined) + // Changed modules stay stale until replaced, including when the generation cannot be applied. + pending = [...comparison?.stale ?? []].map(url => ({ url, reason: 'module-comparison' })) + const compared = comparison + const unavailable = !this.hasEsmCache() + if (!unavailable && compared !== undefined && pending.length > 0) { + pending = await this.contained('planning module replacement', async () => { + const plan = await this.planReplacement(compared.stale, compared.roots) + await this.stopPlanned(plan) + return plan.pending + }, pending) + } + // Modules whose links or evaluation failed are evicted, so the recomposition retries failed imports against a + // corrected file or a dependency installed since. + await this.contained('expiring failed imports', () => { + // Without an ES module cache no failed module is known. + if (unavailable) return + const roots = compared?.roots ?? [realpathSync(profile.dir) + sep] + // A dependency installed since the failed import can sit in a directory Node recorded as having no manifest. + for (const root of roots) this.manifests.invalidate(join(root, 'package.json')) + // Node keeps a module whose links or evaluation failed. + this.evict(this.profileModules(roots).filter((url) => { + const job = this.esmJob(url) + return job !== undefined && namespaceOf(job) === undefined + })) + }, undefined) + const result = await recomposeProfile(this.ownerContext.root, patches, 'dsh', { requiredIds, running }) + const restartReason = compared === undefined || unavailable ? 'module-comparison' : result.restartReason + return { warnings: result.warnings, pending, ...restartReason === undefined ? {} : { restartReason } } + } finally { + const recorded = comparison + await this.contained('evicting dropped packages', () => { + const kept = mappedDirectories(this.ownerContext.get('pluginPackages')?.resolution) + return this.evictDropped([...mapped].filter(dir => !kept.has(dir)), kept) + }, undefined) + await this.contained('recording loaded module files', () => { + if (recorded === undefined) return + // Identities read before replacement keep writes made since then detectable. + const urls = this.profileModules(recorded.roots) + this.files.observe(urls, { seen: recorded.seen, retained: new Set(pending.map(({ url }) => url)), at: comparedAt }) + }, undefined) + } + } +} + +/** Loaded profile modules compared with disk, with the roots that bound them. */ +interface ProfileComparison { + roots: string[] + stale: Set + seen: Map +} + +/** The modules a replacement evicts, the manifests it expires, and the plugins it stops; pending modules stay loaded. */ +interface ReplacementPlan { + pending: PendingModule[] + evicted: Set + manifests: Set + plugins: Set + fibers: Set +} + +/** Outcome of {@link Hmr.reconcileProfile}. */ +export interface ProfileReconciliation { + /** Diagnostics for Loader entries left inactive. */ + warnings: string[] + /** Loaded modules whose files changed on disk and whose previous code keeps running until a restart. */ + pending: PendingModule[] + /** + * Why every change waits for a restart: `module-comparison` when module caches cannot be inspected or their files + * compared, even when no URLs are known; `package-moved` when the runtime resolution cannot publish a moved package. + */ + restartReason?: 'module-comparison' | 'package-moved' +} + +/** A changed module that loads only after a restart. */ +export interface PendingModule { + url: string + /** + * `host-module`: the process entry point's static graph imports it. `unmanaged-plugin`: it is imported by a plugin + * that runs outside a traversable Loader entry. `module-comparison`: loaded modules could not be compared with disk. + */ + reason: 'host-module' | 'unmanaged-plugin' | 'module-comparison' +} + +/** Node's `kErrored` module status, the same in every supported Node version. */ +const MODULE_ERRORED = 5 + +/** The namespace of a cached module job, or `undefined` when it has none or its evaluation threw. */ +function namespaceOf(job: ModuleJob | undefined): unknown { + if (job?.module === undefined) return undefined + // A module whose evaluation threw keeps a namespace whose bindings were never initialized. `getStatus()` exists on + // ModuleWrap in every supported Node version, like the other loader internals HMR reads. + const status: unknown = Reflect.apply(Reflect.get(job.module, 'getStatus') as () => number, job.module, []) + if (status === MODULE_ERRORED) return undefined + try { + return job.module.getNamespace() + /* v8 ignore start -- a cached module still linking in a concurrent import has no namespace yet; tests cannot hold an import there */ + } catch (_error) { + // A module that has not been instantiated exports no running plugin. + return undefined + } + /* v8 ignore stop */ +} + +/** + * The jobs a module job links to. Node 24+ may expose them as an array; Node 22 keeps the job of a failed import + * whose links reject, and such a job contributes no links. + */ +async function linkedJobs(job: ModuleJob): Promise { + /* v8 ignore next -- CI coverage runs Node 24+, which drops failed jobs from the cache */ + const linked = await Promise.resolve(job.linked).catch((_error: unknown) => []) + // Node 22 may return a null-prototype array without methods or an iterator. + return Array.from(linked) +} + +/** The Node-compatible CommonJS cache; the packed worker implements require without exposing this cache. */ +function commonJsCache(): NodeJS.Dict { + // Node's type declares the cache present for every `require`. + const cache: unknown = Reflect.get(createRequire(import.meta.url), 'cache') + return (cache ?? {}) as NodeJS.Dict +} + +/** Every module job in the loader cache; Node's `LoadCache` maps each URL to per-type jobs. */ +function* moduleJobs(internal: ModuleLoader): Generator { + for (const value of Map.prototype.values.call(internal.loadCache) as Iterable>>) { + yield* Object.values(value).filter(job => job !== undefined) + } +} + +/** + * The directories of a runtime resolution's profile mappings and linked roots, in every real spelling of those that + * exist, each with a trailing separator. + * @param resolution The published resolution, if any. + * @returns The directories; none without a resolution. + */ +function mappedDirectories(resolution: RuntimeResolution | undefined): Set { + const directories = new Set() + if (resolution === undefined) return directories + const dirs = [...resolution.entries.filter(entry => entry.scope === 'profile').map(entry => entry.packageDir), + ...resolution.linkedRoots.map(root => root.realPath)] + for (const dir of dirs) for (const spelling of existsSync(dir) ? realSpellings(dir) : [dir]) directories.add(spelling + sep) + return directories +} + +/** + * The JavaScript and native real paths of an existing path. A packed worker's file system has no native realpath. + * @param path Existing path. + * @returns The distinct spellings. + */ +function realSpellings(path: string): string[] { + const native: unknown = Reflect.get(realpathSync, 'native') + return [realpathSync(path), ...typeof native === 'function' ? [String(Reflect.apply(native, realpathSync, [path]))] : []] +} + +/** + * Real directories, with a trailing separator, of the profile and of the targets its linked packages point to. Each + * appears in both real-path spellings: Node's module caches use the JavaScript one, the runtime resolution the native + * one, and on Windows they differ for 8.3 short names. + */ +function profileRoots(dir: string): string[] { + const real = realpathSync(dir) + const modules = join(real, 'node_modules') + const names = existsSync(modules) ? readdirSync(modules) : [] + const packages = names.flatMap(name => name.startsWith('@') + ? readdirSync(join(modules, name)).map(child => join(modules, name, child)) + : [join(modules, name)]) + const linked = packages.filter(path => lstatSync(path).isSymbolicLink() && existsSync(path)) + return [...new Set([dir, ...linked].flatMap(path => realSpellings(path)).map(root => root + sep))] +} + +/** Every `package.json` from a module file's directory up to the profile root or linked package target containing it. */ +function packageManifests(url: string, roots: readonly string[]): string[] { + const manifests: string[] = [] + const contains = (directory: string) => roots.some(root => (directory + sep).startsWith(root)) + for (let directory = dirname(fileURLToPath(url)); contains(directory); directory = dirname(directory)) { + const candidate = join(directory, 'package.json') + if (existsSync(candidate)) manifests.push(candidate) + } + return manifests } namespace Hmr { diff --git a/packages/boot/hmr/src/module-files.ts b/packages/boot/hmr/src/module-files.ts new file mode 100644 index 0000000000..b734401ea4 --- /dev/null +++ b/packages/boot/hmr/src/module-files.ts @@ -0,0 +1,124 @@ +/** Disk identities of loaded module files, used to find modules whose files changed after loading. */ +import { mkdtempSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' + +/** + * Coarsest file timestamp resolution among supported filesystems (FAT stores modification times in 2-second units). + * An unrecorded file modified within this interval before the last observation is treated as possibly changed. + */ +const TIMESTAMP_RESOLUTION_MS = 2000 + +interface FileIdentity { + /** Inode, size and modification time. */ + key: string + /** Modification time in epoch milliseconds. */ + modifiedAt: number +} + +function identityOf(url: string): FileIdentity | undefined { + let stats + try { + stats = statSync(fileURLToPath(url)) + } catch (_error) { + // A removed or unreadable file no longer provides the loaded module. + return undefined + } + return { key: `${String(stats.ino)}:${String(stats.size)}:${String(stats.mtimeMs)}`, modifiedAt: stats.mtimeMs } +} + +/** + * Records the inode, size and modification time of loaded `file:` modules. A module is stale when its file + * disappeared or its identity differs from the recorded one. Change time is not recorded: creating a hard link to a + * file, as pnpm does when it imports from its store, changes it without changing content. A module first loaded after + * the last observation has no record; it is stale when its file was modified at or after that observation, less the + * timestamp resolution, so a reinstall between loading and the next comparison is detected at the cost of reloading a + * module that was first loaded after such a write. + */ +export class ModuleFiles { + /** Identity per recorded module; `undefined` marks a stale module, such as a pending one first loaded after the last observation. */ + private records = new Map() + private observedAt = Date.now() + + /** + * The current time of the clock the operating system stamps local file writes with, read from a new marker file that is + * removed again. The system clock and file timestamps can differ by a clock tick in either direction, so module files + * are compared with this time; when no marker can be created, the system clock is used. + * @returns Epoch milliseconds. + */ + now(): number { + let directory: string + try { + directory = mkdtempSync(join(tmpdir(), 'dsh-hmr-clock-')) + } catch (_error) { + // An unwritable temporary directory leaves the less precise system clock. + return Date.now() + } + try { + const marker = join(directory, 'now') + writeFileSync(marker, '') + return statSync(marker).mtimeMs + } finally { + rmSync(directory, { recursive: true, force: true }) + } + } + + /** + * Compare loaded file modules with disk. + * @param urls `file:` URLs of the loaded modules to compare. + * @returns URLs of stale modules, and the identity read for each compared URL (`undefined` for a missing file). + */ + compare(urls: Iterable): { stale: Set; seen: Map } { + const stale = new Set() + const seen = new Map() + for (const url of urls) { + const identity = identityOf(url) + seen.set(url, identity?.key) + if (identity === undefined) { + stale.add(url) + continue + } + if (this.records.has(url) ? this.records.get(url) !== identity.key + : identity.modifiedAt >= this.observedAt - TIMESTAMP_RESOLUTION_MS) stale.add(url) + } + return { stale, seen } + } + + /** + * Replace every record with the identities of the given loaded modules. + * @param urls `file:` URLs of the loaded modules to record. + * @param options `seen` supplies identities read earlier for modules whose cache entry has not changed since, so a + * later write stays detectable; other modules are read now, and stay stale when their file was modified after `at`. + * `retained` modules keep their record, or are marked stale when they have none. `at` is the observation time from + * {@link now} used for unrecorded modules. + */ + observe(urls: Iterable, options: { + seen?: ReadonlyMap + retained?: ReadonlySet + at?: number + } = {}): void { + const { seen = new Map(), retained = new Set(), at = this.now() } = options + const records = new Map() + for (const url of urls) { + if (retained.has(url)) records.set(url, this.records.get(url)) + else if (seen.has(url)) records.set(url, seen.get(url)) + else { + // A module first loaded since `at` may have been evaluated before a write that followed it. A write in the same + // file clock tick as `at` counts as earlier, so files installed just before the comparison are not reloaded. + const identity = identityOf(url) + records.set(url, identity === undefined || identity.modifiedAt > at ? undefined : identity.key) + } + } + this.records = records + this.observedAt = at + } + + /** + * Record the current identity of modules just evaluated again, leaving every other record unchanged. + * @param urls `file:` URLs of the re-evaluated modules. + */ + refresh(urls: Iterable): void { + for (const url of urls) this.records.set(url, identityOf(url)?.key) + } +} diff --git a/packages/boot/hmr/tests/module-files.spec.ts b/packages/boot/hmr/tests/module-files.spec.ts new file mode 100644 index 0000000000..1a0c32de83 --- /dev/null +++ b/packages/boot/hmr/tests/module-files.spec.ts @@ -0,0 +1,24 @@ +/** The file clock ModuleFiles compares module files with. */ +import * as fs from 'node:fs' +import { expect, it, vi } from 'vitest' +import { ModuleFiles } from '../src/module-files.ts' + +vi.mock('node:fs', async original => ({ ...await original() })) + +it('reads the time from a new marker file and removes it', () => { + const created = vi.spyOn(fs, 'mkdtempSync') + try { + const time = new ModuleFiles().now() + expect(time).toBeGreaterThan(0) + const directory = created.mock.results[0]?.value as string + expect(fs.existsSync(directory)).toBe(false) + } finally { created.mockRestore() } +}) + +it('falls back to the system clock when no marker can be created', () => { + const created = vi.spyOn(fs, 'mkdtempSync').mockImplementationOnce(() => { throw new Error('read-only') }) + try { + const before = Date.now() + expect(new ModuleFiles().now()).toBeGreaterThanOrEqual(before) + } finally { created.mockRestore() } +}) diff --git a/packages/boot/hmr/tests/package-reload.spec.ts b/packages/boot/hmr/tests/package-reload.spec.ts new file mode 100644 index 0000000000..5b3dc27571 --- /dev/null +++ b/packages/boot/hmr/tests/package-reload.spec.ts @@ -0,0 +1,1280 @@ +/** Profile reconciliation replaces plugins whose installed package files changed, through a real Loader and Node cache. */ +import { existsSync, linkSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, symlinkSync, unlinkSync, utimesSync, writeFileSync } from 'node:fs' +import NodeModule, { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { dirname, isAbsolute, join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { Context, FiberState, type Plugin } from '@deepseek-ai/cordis' +import Loader, { ModuleLoader, type Entry } from '@deepseek-ai/cordis-plugin-loader' +import { + boot, initProfile, ProfileRuntimeResolution, readProfileManifest, readProfilePatches, reconcileProfilePatches, ResolutionRestartRequired, + type PluginPackages, type ProfileContext, type RuntimeResolution, +} from '@deepseek-ai/dsh-app-boot' +import Timer from '@deepseek-ai/cordis-plugin-timer' +import { expect, it, onTestFinished, vi } from 'vitest' +import Hmr from '../src/index.ts' + +const watchers = vi.hoisted(() => [] as import('chokidar').FSWatcher[]) +vi.mock('chokidar', async (original) => { + const native = await original() + return { ...native, watch: () => { + const watcher = new native.FSWatcher() + watchers.push(watcher) + queueMicrotask(() => watcher.emit('ready')) + return watcher + } } +}) + +/** Silence the Loader's report of an entry whose import fails. */ +function quiet(ctx: Context): void { + const error = vi.spyOn(ctx.logger, 'error').mockImplementation(() => {}) + onTestFinished(() => { error.mockRestore() }) +} + +function file(path: string, source: string): void { + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, source) +} + +function plugin(version: string, imports = 'shared-lib'): string { + return `import { value } from '${imports}' +export const name = 'addon-${version}' +export function apply(ctx, config = {}) { + ctx.get('reloadTrace').push('start:${version}:' + value + ':' + (config.mode ?? '-')) + ctx.effect(() => () => { ctx.get('reloadTrace').push('stop:${version}') }) +} +` +} + +function addon(dir: string, version: string, source = plugin(version), mode?: string): void { + const root = join(dir, 'node_modules', 'addon') + file(join(root, 'package.json'), JSON.stringify({ + name: 'addon', version, type: 'module', exports: './index.mjs', dsh: { bundle: { patch: './cordis.patch.yml' } }, + })) + file(join(root, 'index.mjs'), source) + file(join(root, 'cordis.patch.yml'), `- insert:\n - id: addon\n name: addon\n${mode === undefined ? '' : ` config: { mode: ${mode} }\n`}`) +} + +function shared(dir: string, value: number): void { + const root = join(dir, 'node_modules', 'shared-lib') + file(join(root, 'package.json'), JSON.stringify({ name: 'shared-lib', version: `${String(value)}.0.0`, type: 'module', exports: './index.mjs' })) + file(join(root, 'index.mjs'), `export const value = ${String(value)}\n`) +} + +function other(dir: string): void { + const root = join(dir, 'node_modules', 'other') + file(join(root, 'package.json'), JSON.stringify({ + name: 'other', version: '1.0.0', type: 'module', exports: './index.mjs', dsh: { bundle: { patch: './cordis.patch.yml' } }, + })) + file(join(root, 'index.mjs'), `export function apply(ctx) { + ctx.get('reloadTrace').push('start:other') + ctx.effect(() => () => { ctx.get('reloadTrace').push('stop:other') }) +} +`) + file(join(root, 'cordis.patch.yml'), '- insert:\n - id: other\n name: other\n') +} + +function commonjs(dir: string, value: number): void { + const root = join(dir, 'node_modules', 'cjs-addon') + file(join(root, 'package.json'), JSON.stringify({ + name: 'cjs-addon', version: '1.0.0', main: './index.cjs', dsh: { bundle: { patch: './cordis.patch.yml' } }, + })) + file(join(root, 'index.cjs'), `const { value } = require('./helper.cjs') +exports.apply = (ctx) => { + ctx.get('reloadTrace').push('start:cjs:' + value) + ctx.effect(() => () => { ctx.get('reloadTrace').push('stop:cjs') }) +} +`) + file(join(root, 'helper.cjs'), `exports.value = ${String(value)}\n`) + file(join(root, 'cordis.patch.yml'), '- insert:\n - id: cjs-addon\n name: cjs-addon\n') +} + +/** Date every file below `dir` an hour back, as for packages installed before the application started. */ +function age(dir: string): void { + const past = new Date(Date.now() - 3_600_000) + for (const entry of readdirSync(dir, { recursive: true, withFileTypes: true })) { + if (entry.isFile()) utimesSync(join(entry.parentPath, entry.name), past, past) + } +} + +function linked(home: string, dir: string, value: number): string { + const target = join(home, 'work', 'linked') + file(join(target, 'package.json'), JSON.stringify({ + name: '@local/linked', version: '1.0.0', type: 'module', exports: './index.mjs', dsh: { bundle: { patch: './cordis.patch.yml' } }, + })) + file(join(target, 'index.mjs'), `export function apply(ctx) { + ctx.get('reloadTrace').push('start:linked:${String(value)}') + ctx.effect(() => () => { ctx.get('reloadTrace').push('stop:linked') }) +} +`) + file(join(target, 'cordis.patch.yml'), '- insert:\n - id: linked\n name: "@local/linked"\n') + const link = join(dir, 'node_modules', '@local', 'linked') + mkdirSync(dirname(link), { recursive: true }) + if (lstatSync(link, { throwIfNoEntry: false }) !== undefined) unlinkSync(link) + symlinkSync(target, link, process.platform === 'win32' ? 'junction' : 'dir') + return target +} + +async function fixture(bundles = ['addon', 'other'], before?: (home: string, dir: string) => Promise, ready = true) { + const home = realpathSync(mkdtempSync(join(tmpdir(), 'hmr-package-reload-'))) + const dir = join(home, 'profiles', 'test') + initProfile(dir, bundles) + writeFileSync(join(dir, 'cordis.yml'), '[]\n') + addon(dir, '1') + shared(dir, 1) + other(dir) + commonjs(dir, 1) + linked(home, dir, 1) + age(home) + await before?.(home, dir) + const profile: ProfileContext = { + name: 'test', dir, patchPath: join(dir, 'cordis.patch.yml'), home, cwd: home, + installAnchor: join(home, 'package.json'), startedBundles: bundles, overlays: [], telemetryDisabledEnv: undefined, + } + writeFileSync(profile.patchPath, JSON.stringify([{ insert: [ + { id: 'timer', name: 'cordis:timer' }, + { id: 'hmr', name: 'cordis:hmr', config: { root: [] } }, + ] }])) + const trace: string[] = [] + let commit: (() => void) | undefined + const firstWatcher = watchers.length + const ctx = await boot('test', join(dir, 'cordis.yml'), readProfilePatches('test', profile), (host) => { + host.provide('profileContext', profile) + host.provide('reloadTrace', trace) + host.provide('appReady', { onReady(listener) { commit = listener; return () => { commit = undefined } } }) + host.loader.builtins.timer = Timer + host.loader.builtins.hmr = Hmr + }) + onTestFinished(async () => { await ctx.fiber.dispose(); rmSync(home, { recursive: true, force: true }) }) + await ctx.loader.await() + if (ready) commit?.() + const reconcile = () => ctx.hmr.runExclusive(() => ctx.hmr.reconcileProfile()) + const select = (bundles: string[]) => { + const manifest = readProfileManifest('test', dir) + manifest.dsh = { profile: { bundles } } + writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest)) + } + /** Deliver a change of the profile patch file to HMR's own watch and wait for its recomposition. */ + const editPatch = async () => { + // Each configuration watch ignores paths other than its own file. + for (const watcher of watchers.slice(firstWatcher)) watcher.emit('change', profile.patchPath) + await vi.waitFor(async () => { await ctx.hmr.runExclusive(async () => {}) }) + await ctx.hmr.runExclusive(async () => {}) + } + return { ready: () => commit?.(), ctx, home, dir, trace, reconcile, select, editPatch, profile } +} + +it('leaves plugins running when no loaded file changed', async () => { + const f = await fixture() + expect(f.trace.toSorted()).toEqual(['start:1:1:-', 'start:other']) + f.trace.length = 0 + await expect(f.reconcile()).resolves.toEqual({ warnings: [], pending: [] }) + expect(f.trace).toEqual([]) +}) + +it('keeps a module whose file gains a hard link', async () => { + const f = await fixture() + f.trace.length = 0 + linkSync(join(f.dir, 'node_modules', 'addon', 'index.mjs'), join(f.home, 'store-copy.mjs')) + await expect(f.reconcile()).resolves.toEqual({ warnings: [], pending: [] }) + expect(f.trace).toEqual([]) +}) + +it('replaces a plugin upgraded in place with the configuration of its new bundle patch', async () => { + const f = await fixture() + f.trace.length = 0 + addon(f.dir, '2', plugin('2'), 'fast') + await expect(f.reconcile()).resolves.toEqual({ warnings: [], pending: [] }) + expect(f.trace).toEqual(['stop:1', 'start:2:1:fast']) +}) + +it('starts an upgraded plugin with a configuration its previous version rejects', async () => { + const f = await fixture() + addon(f.dir, '1', plugin('1').replace('export function apply(ctx, config = {}) {', "export function apply(ctx, config = {}) {\n if (config.mode) throw new Error('mode is unsupported')")) + age(f.dir) + await f.reconcile() + f.trace.length = 0 + addon(f.dir, '2', plugin('2'), 'fast') + await expect(f.reconcile()).resolves.toEqual({ warnings: [], pending: [] }) + expect(f.trace).toEqual(['stop:1', 'start:2:1:fast']) +}) + +it('links an entry inserted by the upgraded bundle to the current files of a changed dependency', async () => { + const f = await fixture() + const extra = join(f.dir, 'node_modules', 'addon', 'extra.mjs') + f.trace.length = 0 + shared(f.dir, 2) + addon(f.dir, '1') + file(extra, `import { value } from 'shared-lib' +export function apply(ctx) { ctx.get('reloadTrace').push('start:extra:' + value) } +`) + file(join(f.dir, 'node_modules', 'addon', 'cordis.patch.yml'), '- insert:\n - id: addon\n name: addon\n - id: extra\n name: ./extra.mjs\n') + await expect(f.reconcile()).resolves.toEqual({ warnings: [], pending: [] }) + expect(f.trace.toSorted()).toEqual(['start:1:2:-', 'start:extra:2', 'stop:1']) +}) + +it('leaves a changed plugin that no Loader entry owns for a restart', async () => { + const f = await fixture([]) + const warn = vi.spyOn(f.ctx.logger, 'warn').mockImplementation(() => {}) + onTestFinished(() => { warn.mockRestore() }) + const url = pathToFileURL(join(f.dir, 'node_modules', 'addon', 'index.mjs')).href + await f.ctx.plugin(f.ctx.loader.unwrapExports(await f.ctx.loader.import(url)) as Plugin) + expect(f.trace).toEqual(['start:1:1:-']) + addon(f.dir, '2') + await expect(f.reconcile()).resolves.toEqual({ warnings: [], pending: [{ url, reason: 'unmanaged-plugin' }] }) + expect(f.trace).toEqual(['start:1:1:-']) +}) + +/** A runtime resolution table mapping `addon` to a directory, with further dropped profile packages. */ +function table(addonDir: string, ...dropped: string[]): RuntimeResolution { + const entry = (name: string, packageDir: string) => ({ + name, packageDir, version: '1.0.0', declarer: join(packageDir, 'package.json'), scope: 'profile' as const, + }) + return { + profilesDir: '/profiles', profileDir: '/profiles/test', localPackageNames: ['addon', ...dropped], + entries: [entry('addon', addonDir), ...dropped.map(name => entry(name, isAbsolute(name) ? name : `/packages/${name}`))], + linkedRoots: [], + } +} + +/** + * Install a recomputable runtime resolution whose latest generation is `latest`; `replace` decides each publication. + * @returns Each publication's mapped packages. + */ +function resolution( + ctx: Context, latest: RuntimeResolution, replace: () => void = () => {}, + published = table('/packages/addon', 'gone'), +) { + const source = { installAnchor: '', home: '', profileDir: undefined } + let current: RuntimeResolution = new ProfileRuntimeResolution(source, published) + const compute = vi.spyOn(ProfileRuntimeResolution.prototype, 'computeLatestResolution') + .mockResolvedValue(new ProfileRuntimeResolution(source, latest)) + onTestFinished(() => { compute.mockRestore() }) + const calls: string[] = [] + ctx.provide('pluginPackages', { + // The compatibility preflight finds no package metadata and admits every row. + packageOf: () => undefined, + get resolution() { return current }, + replace(next: RuntimeResolution) { + calls.push(`publish:${next.entries.map(entry => entry.name).join(',')}`) + replace() + current = next + }, + } as Pick as PluginPackages) + return calls +} + +it('stops the rows a generation drops before publishing its resolution, then applies it', async () => { + const f = await fixture() + f.trace.length = 0 + const calls = resolution(f.ctx, table('/packages/addon-2'), () => { f.trace.push('published') }) + f.select(['addon']) + await expect(f.reconcile()).resolves.toEqual({ warnings: [], pending: [] }) + expect(calls).toEqual(['publish:addon']) + expect(f.trace).toEqual(['stop:other', 'published']) +}) + +it.each(['unchanged', 'changed'] as const)('rejects a publication failure, starting %s plugins it stopped again', async (kind) => { + const f = await fixture() + f.trace.length = 0 + resolution(f.ctx, table('/packages/addon-2'), () => { throw new Error('resolution unreadable') }) + if (kind === 'changed') addon(f.dir, '2') + await expect(f.reconcile()).rejects.toThrow('resolution unreadable') + expect(f.trace).toEqual(kind === 'changed' ? ['stop:1', 'start:2:1:-'] : []) +}) + +it('reports an entry left waiting for a service the replacement no longer provides as a warning', async () => { + const f = await fixture(['addon', 'other'], async (_home, dir) => { + addon(dir, '1', `export function apply(ctx) { + ctx.get('reloadTrace').push('start:1') + ctx.provide('addonService', {}) +} +`) + file(join(dir, 'node_modules', 'other', 'index.mjs'), `export const inject = ['addonService'] +export function apply(ctx) { ctx.get('reloadTrace').push('start:other') } +`) + age(join(dir, 'node_modules')) + }) + quiet(f.ctx) + f.trace.length = 0 + addon(f.dir, '2', "export function apply(ctx) { ctx.get('reloadTrace').push('start:2') }\n") + // The entry starts once a provider appears. + await expect(f.reconcile()).resolves.toMatchObject({ warnings: [expect.stringContaining('addonService')] }) + expect(f.trace).toEqual(['start:2']) +}) + +it('imports a row the update inserts once when its import fails', async () => { + const f = await fixture(['addon']) + const error = vi.spyOn(f.ctx.logger, 'error').mockImplementation(() => {}) + onTestFinished(() => { error.mockRestore() }) + file(join(f.dir, 'node_modules', 'other', 'index.mjs'), "import 'missing-lib'\n") + f.select(['addon', 'other']) + await expect(f.reconcile()).rejects.toThrow(/missing-lib/u) + expect(error.mock.calls.filter(([value]) => String(value).includes('missing-lib'))).toHaveLength(1) +}) + +it('keeps the publication failure when starting the stopped plugins again throws', async () => { + const f = await fixture() + const warn = vi.spyOn(f.ctx.logger, 'warn').mockImplementation(() => {}) + onTestFinished(() => { warn.mockRestore() }) + resolution(f.ctx, table('/packages/addon-2'), () => { throw new Error('resolution unreadable') }) + addon(f.dir, '2') + const settle = vi.spyOn(f.ctx.loader, 'await').mockRejectedValueOnce(new Error('loader unavailable')) + onTestFinished(() => { settle.mockRestore() }) + await expect(f.reconcile()).rejects.toThrow('resolution unreadable') + expect(warn).toHaveBeenCalledWith(expect.objectContaining({ message: 'loader unavailable' })) +}) + +it('logs a stopped plugin that does not start again after the publication fails', async () => { + const f = await fixture() + const warn = vi.spyOn(f.ctx.logger, 'warn').mockImplementation(() => {}) + onTestFinished(() => { warn.mockRestore() }) + resolution(f.ctx, table('/packages/addon-2'), () => { throw new Error('resolution unreadable') }) + addon(f.dir, '2', "export function apply() { throw new Error('addon cannot start') }\n") + quiet(f.ctx) + // An unrelated failed entry is left to the next judgment. + await f.ctx.loader.create({ name: 'late-plugin' }) + await f.ctx.loader.await() + await expect(f.reconcile()).rejects.toThrow('resolution unreadable') + expect(warn).toHaveBeenCalledWith(expect.stringMatching(/^dsh: addon \(addon\): .*addon cannot start/su)) + expect(warn).not.toHaveBeenCalledWith(expect.stringContaining('late-plugin')) +}) + +it('reports a stopped entry that no recomposition started again by its fiber state', async () => { + const f = await fixture() + const url = pathToFileURL(join(f.dir, 'node_modules', 'addon', 'index.mjs')).href + const plugin = f.ctx.loader.unwrapExports(await f.ctx.loader.import(url)) as Plugin + const fibers = [...f.ctx.registry.get(plugin)?.fibers ?? []] + f.ctx.registry.delete(plugin) + await Promise.all(fibers.map(fiber => fiber.await())) + // Applying the same patches leaves the unchanged Include's rows alone. + const warnings = await f.ctx.hmr.runExclusive(() => reconcileProfilePatches(f.ctx.root, readProfilePatches('test', f.profile), 'test')) + expect(warnings).toEqual([expect.stringMatching(/^addon \(addon\): fiber state 4/u)]) +}) + +it('logs a failure to restore the stopped rows and still reports the rejected publication', async () => { + const f = await fixture() + const warn = vi.spyOn(f.ctx.logger, 'warn').mockImplementation(() => {}) + onTestFinished(() => { warn.mockRestore() }) + resolution(f.ctx, table('/packages/addon-2'), () => { throw new ResolutionRestartRequired('installation mapping changed') }) + const include = [...f.ctx.loader.entries()].find(entry => entry.options.id === 'include') as Entry + const restoring = vi.spyOn(include.subgroup!, 'create').mockRejectedValueOnce(new Error('restore failed')) + onTestFinished(() => { restoring.mockRestore() }) + f.select(['addon']) + await expect(f.reconcile()).resolves.toEqual({ warnings: [], pending: [], restartReason: 'package-moved' }) + expect(warn).toHaveBeenCalledWith(expect.objectContaining({ message: 'restore failed' })) +}) + +it('restores the composition while the resolution waits for a restart', async () => { + const f = await fixture() + f.trace.length = 0 + resolution(f.ctx, table('/packages/addon-2'), () => { throw new ResolutionRestartRequired('installation mapping changed') }) + f.select(['addon']) + const config = readFileSync(join(f.dir, 'cordis.yml'), 'utf8') + await expect(f.reconcile()).resolves.toEqual({ warnings: [], pending: [], restartReason: 'package-moved' }) + // The row the generation drops stops before the publication and starts again once it is rejected. + expect(f.trace).toEqual(['stop:other', 'start:other']) + // Stopping and restoring rows leaves the profile's configuration file as it was. + await new Promise(resolve => setTimeout(resolve, 50)) + expect(readFileSync(join(f.dir, 'cordis.yml'), 'utf8')).toBe(config) +}) + +it('waits for a dropped row to finish disposing, and keeps a stopped kept row stopped, until the resolution publishes', async () => { + const f = await fixture(['addon', 'other'], async (_home, dir) => { + file(join(dir, 'node_modules', 'other', 'index.mjs'), `export function apply(ctx) { + ctx.get('reloadTrace').push('start:other') + ctx.effect(() => async () => { await new Promise(resolve => setTimeout(resolve, 20)); ctx.get('reloadTrace').push('stop:other') }) +} +`) + }) + f.trace.length = 0 + // The addon's own file changed, so HMR stops its entry before the recomposition. + addon(f.dir, '2') + resolution(f.ctx, table('/packages/addon-2'), () => { f.trace.push('published') }) + f.select(['addon']) + await f.reconcile() + expect(f.trace).toEqual(['stop:1', 'stop:other', 'published', 'start:2:1:-']) +}) + +it('puts several dropped rows back at their places when the resolution waits for a restart', async () => { + const f = await fixture() + const rows = [{ id: 'timer', name: 'cordis:timer' }, { id: 'hmr', name: 'cordis:hmr', config: { root: [] } }] + const order = ['first', 'middle', 'second'] + writeFileSync(f.profile.patchPath, JSON.stringify([{ insert: [...rows, { id: 'first', name: 'other' }, { id: 'middle', name: 'other' }, { id: 'second', name: 'other' }] }])) + await f.reconcile() + resolution(f.ctx, table('/packages/addon-2'), () => { throw new ResolutionRestartRequired('installation mapping changed') }) + writeFileSync(f.profile.patchPath, JSON.stringify([{ insert: [...rows, { id: 'middle', name: 'other' }] }])) + await expect(f.reconcile()).resolves.toMatchObject({ restartReason: 'package-moved' }) + const include = [...f.ctx.loader.entries()].find(entry => entry.options.id === 'include') as Entry + expect(include.subgroup!.data.map(row => row.id).filter(id => order.includes(id))).toEqual(order) +}) + +it('puts a dropped row back into its group when the resolution waits for a restart', async () => { + const f = await fixture() + const rows = [{ id: 'timer', name: 'cordis:timer' }, { id: 'hmr', name: 'cordis:hmr', config: { root: [] } }] + writeFileSync(f.profile.patchPath, JSON.stringify([{ insert: [...rows, { id: 'group', name: 'cordis:group', config: [{ id: 'child', name: 'other' }] }] }])) + await f.reconcile() + f.trace.length = 0 + resolution(f.ctx, table('/packages/addon-2'), () => { throw new ResolutionRestartRequired('installation mapping changed') }) + writeFileSync(f.profile.patchPath, JSON.stringify([{ insert: [...rows, { id: 'group', name: 'cordis:group', config: [] }] }])) + await expect(f.reconcile()).resolves.toMatchObject({ restartReason: 'package-moved' }) + expect(f.trace).toEqual(['stop:other', 'start:other']) + const group = [...f.ctx.loader.entries()].find(entry => entry.options.id === 'group') as Entry + expect(group.subgroup!.data.map(row => row.id)).toEqual(['child']) +}) + +it('removes a disabled row a generation drops without waiting for a fiber', async () => { + const f = await fixture() + const rows = [{ id: 'timer', name: 'cordis:timer' }, { id: 'hmr', name: 'cordis:hmr', config: { root: [] } }] + writeFileSync(f.profile.patchPath, JSON.stringify([{ insert: [...rows, { id: 'idle', name: 'other', disabled: true }] }])) + await f.reconcile() + f.trace.length = 0 + resolution(f.ctx, table('/packages/addon-2'), () => { f.trace.push('published') }) + writeFileSync(f.profile.patchPath, JSON.stringify([{ insert: rows }])) + await f.reconcile() + expect(f.trace).toEqual(['published']) + expect([...f.ctx.loader.entries()].some(entry => entry.options.id === 'idle')).toBe(false) +}) + +it('stops a row a generation drops from a group before publishing its resolution', async () => { + const f = await fixture() + const rows = [{ id: 'timer', name: 'cordis:timer' }, { id: 'hmr', name: 'cordis:hmr', config: { root: [] } }] + writeFileSync(f.profile.patchPath, JSON.stringify([{ insert: [...rows, { id: 'group', name: 'cordis:group', config: [{ id: 'child', name: 'other' }] }] }])) + await f.reconcile() + f.trace.length = 0 + resolution(f.ctx, table('/packages/addon-2'), () => { f.trace.push('published') }) + writeFileSync(f.profile.patchPath, JSON.stringify([{ insert: [...rows, { id: 'group', name: 'cordis:group', config: [] }] }])) + await f.reconcile() + expect(f.trace).toEqual(['stop:other', 'published']) +}) +it.each(['added', 'nested', 'renamed'] as const)('leaves a generation with an %s row unapplied while the resolution waits for a restart', async (kind) => { + const f = await fixture(['addon']) + f.trace.length = 0 + resolution(f.ctx, table('/packages/addon-2'), () => { throw new ResolutionRestartRequired('installation mapping changed') }) + const rows = [{ id: 'timer', name: 'cordis:timer' }, { id: 'hmr', name: 'cordis:hmr', config: { root: [] } }] + if (kind === 'added') f.select(['addon', 'other']) + else { + writeFileSync(f.profile.patchPath, JSON.stringify(kind === 'nested' + ? [{ insert: [...rows, { id: 'group', name: 'cordis:group', config: [{ id: 'child', name: 'other' }] }] }] + : [{ insert: rows }, { id: 'addon', name: 'other' }])) + } + await expect(f.reconcile()).resolves.toEqual({ warnings: [], pending: [], restartReason: 'package-moved' }) + expect(f.trace).toEqual([]) +}) + +it('judges the activation without importing an entry whose import failed before the resolution publishes', async () => { + const f = await fixture([]) + quiet(f.ctx) + const id = await f.ctx.loader.create({ name: 'late-plugin' }) + await f.ctx.loader.await() + await f.reconcile() + const order: string[] = [] + resolution(f.ctx, table('/packages/addon-2'), () => { order.push('publish') }) + const tree = f.ctx.loader.resolve(id).parent.tree + const original = tree.import.bind(tree) + const load = vi.spyOn(tree, 'import').mockImplementation((...args): Promise => { order.push('import'); return original(...args) as Promise }) + onTestFinished(() => { load.mockRestore() }) + await f.reconcile() + // Importing before the resolution publishes could evaluate plugin code against the previous mappings. + expect(order[0]).toBe('publish') + expect(order).toContain('import') +}) + +it('keeps an entry that failed to import before as a warning when it fails with another error', async () => { + const f = await fixture([]) + quiet(f.ctx) + const id = await f.ctx.loader.create({ name: 'late-plugin' }) + await f.ctx.loader.await() + await f.reconcile() + const tree = f.ctx.loader.resolve(id).parent.tree + const load = vi.spyOn(tree, 'import').mockRejectedValue(new Error('another missing dependency')) + onTestFinished(() => { load.mockRestore() }) + const result = await f.reconcile() + expect(result.warnings).toEqual([expect.stringContaining('another missing dependency')]) +}) + +it('records plugins started before application readiness without restarting a recently installed one', async () => { + const f = await fixture(['addon'], undefined, false) + // A plugin installed just now starts after HMR but before the application is ready; writing its files stamps them + // with the file clock, as an installation does. + other(f.dir) + await f.ctx.loader.create({ name: 'other' }) + await f.ctx.loader.await() + f.ready() + f.trace.length = 0 + await expect(f.reconcile()).resolves.toEqual({ warnings: [], pending: [] }) + expect(f.trace).toEqual([]) +}) + +// The recomposition that disables the failed entry still retries it, which expires the profile's package +// configuration and evicts its failed modules, so enabling it later reads the installed dependency. +it('loads a failed entry enabled again after its dependency was installed', async () => { + const f = await fixture(undefined, async (_home, dir) => { + addon(dir, '1', plugin('1', 'late-lib')) + }) + quiet(f.ctx) + const rows = [{ id: 'timer', name: 'cordis:timer' }, { id: 'hmr', name: 'cordis:hmr', config: { root: [] } }] + writeFileSync(f.profile.patchPath, JSON.stringify([{ insert: rows }, { id: 'addon', disabled: true }])) + await f.reconcile() + file(join(f.dir, 'node_modules', 'late-lib', 'package.json'), JSON.stringify({ name: 'late-lib', type: 'module', exports: './index.mjs' })) + file(join(f.dir, 'node_modules', 'late-lib', 'index.mjs'), 'export const value = 7\n') + writeFileSync(f.profile.patchPath, JSON.stringify([{ insert: rows }])) + await expect(f.reconcile()).resolves.toEqual({ warnings: [], pending: [] }) + expect(f.trace).toContain('start:1:7:-') +}) + +it('starts a service plugin again once the dependency whose evaluation failed is corrected', async () => { + const f = await fixture(['addon'], async (_home, dir) => { + file(join(dir, 'node_modules', 'addon', 'dep.mjs'), "throw new Error('dep fails')\n") + file(join(dir, 'node_modules', 'addon', 'index.mjs'), `import './dep.mjs' +export default class Addon { + constructor(ctx) { ctx.get('reloadTrace').push('start:service') } +} +`) + }) + quiet(f.ctx) + file(join(f.dir, 'node_modules', 'addon', 'dep.mjs'), 'export {}\n') + await expect(f.reconcile()).resolves.toEqual({ warnings: [], pending: [] }) + expect(f.trace).toEqual(['start:service']) +}) + +it.each(['link', 'missing'] as const)('finds the host module graph through a process entry given as a %s path', async (kind) => { + const argv = process.argv + onTestFinished(() => { process.argv = argv }) + const f = await fixture(undefined, async (home, dir) => { + const main = join(dir, 'main.mjs') + file(main, "import './node_modules/shared-lib/index.mjs'\n") + await ModuleLoader.fromInternal()!.import(pathToFileURL(main).href, import.meta.url, {}) + const entry = join(home, 'bin-dsh') + if (kind === 'link') symlinkSync(main, entry) + process.argv = [argv[0]!, entry] + }) + shared(f.dir, 2) + const url = pathToFileURL(join(f.dir, 'node_modules', 'shared-lib', 'index.mjs')).href + await expect(f.reconcile()).resolves.toEqual({ warnings: [], + pending: kind === 'link' ? [{ url, reason: 'host-module' }] : [] }) +}) + +it('leaves a CommonJS plugin registered outside a Loader entry, and its cache, for a restart', async () => { + const f = await fixture([]) + const require = createRequire(import.meta.url) + const root = join(f.dir, 'node_modules', 'cjs-addon') + await f.ctx.plugin(f.ctx.loader.unwrapExports(require(join(root, 'index.cjs'))) as Plugin) + expect(f.trace).toEqual(['start:cjs:1']) + const cached = require.cache[join(root, 'helper.cjs')] + commonjs(f.dir, 2) + const result = await f.reconcile() + expect(result.pending).toContainEqual({ url: pathToFileURL(join(root, 'helper.cjs')).href, reason: 'unmanaged-plugin' }) + expect(require.cache[join(root, 'helper.cjs')]).toBe(cached) + expect(f.trace).toEqual(['start:cjs:1']) +}) + +it('replaces a changed module that a data URL module imports', async () => { + const f = await fixture() + const url = pathToFileURL(join(f.dir, 'node_modules', 'shared-lib', 'index.mjs')).href + await import(`data:text/javascript,import ${JSON.stringify(url)}`) + f.trace.length = 0 + shared(f.dir, 2) + await expect(f.reconcile()).resolves.toEqual({ warnings: [], pending: [] }) + expect(f.trace).toEqual(['stop:1', 'start:1:2:-']) +}) + +it('reads no plugin from a CommonJS module that is still loading', async () => { + const f = await fixture() + const require = createRequire(import.meta.url) + const partial = join(f.dir, 'node_modules', 'addon', 'partial.cjs') + file(partial, 'module.exports = {}\n') + // A module whose evaluation has not finished is cached with `loaded: false`. + require.cache[partial] = { exports: { apply() {} }, loaded: false } as NodeJS.Module + onTestFinished(() => { Reflect.deleteProperty(require.cache, partial) }) + f.trace.length = 0 + await expect(f.reconcile()).resolves.toEqual({ warnings: [], pending: [] }) + expect(f.trace).toEqual([]) +}) + +it('replaces a module first loaded during a recomposition whose file changed before it settled', async () => { + const f = await fixture(['addon'], async (_home, dir) => { + const root = join(dir, 'node_modules', 'other') + file(join(root, 'dep.mjs'), 'export const value = 1\n') + // The write lands well after the comparison; file clocks can date a write within one tick of it earlier. + file(join(root, 'index.mjs'), `import { writeFileSync } from 'node:fs' +import { setTimeout } from 'node:timers/promises' +import { value } from './dep.mjs' +export async function apply(ctx) { + ctx.get('reloadTrace').push('start:other:' + value) + await setTimeout(50) + writeFileSync(${JSON.stringify(join(root, 'dep.mjs'))}, 'export const value = 2\\n') +} +`) + }) + f.trace.length = 0 + f.select(['addon', 'other']) + await f.reconcile() + expect(f.trace).toEqual(['start:other:1']) + f.trace.length = 0 + await f.reconcile() + expect(f.trace).toEqual(['start:other:2']) +}) + +it('replaces other changed plugins when one changed plugin cannot be replaced', async () => { + const f = await fixture(['other']) + const warn = vi.spyOn(f.ctx.logger, 'warn').mockImplementation(() => {}) + onTestFinished(() => { warn.mockRestore() }) + const url = pathToFileURL(join(f.dir, 'node_modules', 'addon', 'index.mjs')).href + await f.ctx.plugin(f.ctx.loader.unwrapExports(await f.ctx.loader.import(url)) as Plugin) + f.trace.length = 0 + addon(f.dir, '2') + other(f.dir) + file(join(f.dir, 'node_modules', 'other', 'index.mjs'), `export function apply(ctx) { + ctx.get('reloadTrace').push('start:other:2') +} +`) + await expect(f.reconcile()).resolves.toEqual({ warnings: [], pending: [{ url, reason: 'unmanaged-plugin' }] }) + expect(f.trace).toEqual(['stop:other', 'start:other:2']) +}) + +it('starts a replaced entry outside the profile composition once and judges its activation', async () => { + const f = await fixture([]) + const warn = vi.spyOn(f.ctx.logger, 'warn').mockImplementation(() => {}) + onTestFinished(() => { warn.mockRestore() }) + const id = await f.ctx.loader.create({ name: 'addon' }) + await f.ctx.loader.await() + f.trace.length = 0 + addon(f.dir, '2') + await expect(f.reconcile()).resolves.toEqual({ warnings: [], pending: [] }) + expect(f.trace).toEqual(['stop:1', 'start:2:1:-']) + addon(f.dir, '3', "export function apply() { throw new Error('version 3 cannot start') }\n") + await expect(f.reconcile()).rejects.toThrow('version 3 cannot start') + expect(f.ctx.loader.resolve(id).fiber?.state).not.toBe(FiberState.ACTIVE) +}) + +it('reports a replaced entry whose package entry point now fails to import', async () => { + const f = await fixture([]) + const error = vi.spyOn(f.ctx.logger, 'error').mockImplementation(() => {}) + onTestFinished(() => { error.mockRestore() }) + await f.ctx.loader.create({ name: 'addon' }) + await f.ctx.loader.await() + const root = join(f.dir, 'node_modules', 'addon') + addon(f.dir, '2') + file(join(root, 'moved.mjs'), "import 'missing-lib'\n") + file(join(root, 'package.json'), JSON.stringify({ name: 'addon', version: '2', type: 'module', exports: './moved.mjs' })) + await expect(f.reconcile()).rejects.toThrow('did not activate') +}) + +it('does not start the new code of a plugin its new bundle patch disables', async () => { + const f = await fixture() + f.trace.length = 0 + addon(f.dir, '2', plugin('2')) + file(join(f.dir, 'node_modules', 'addon', 'cordis.patch.yml'), '- insert:\n - id: addon\n name: addon\n disabled: true\n') + await expect(f.reconcile()).resolves.toEqual({ warnings: [], pending: [] }) + expect(f.trace).toEqual(['stop:1']) +}) + +it('replaces the importers of a changed shared dependency only', async () => { + const f = await fixture() + f.trace.length = 0 + shared(f.dir, 2) + await f.reconcile() + expect(f.trace).toEqual(['stop:1', 'start:1:2:-']) +}) + +it('replaces a plugin of a linked package whose target changed', async () => { + const f = await fixture(['@local/linked']) + expect(f.trace).toEqual(['start:linked:1']) + linked(f.home, f.dir, 2) + await expect(f.reconcile()).resolves.toEqual({ warnings: [], pending: [] }) + expect(f.trace).toEqual(['start:linked:1', 'stop:linked', 'start:linked:2']) +}) + +it('stops a plugin whose replacement fails to load and starts its corrected files', async () => { + const f = await fixture() + quiet(f.ctx) + f.trace.length = 0 + addon(f.dir, '2', plugin('2', 'missing-lib')) + await expect(f.reconcile()).rejects.toThrow(/failed to import: .*'missing-lib'/u) + expect(f.trace).toEqual(['stop:1']) + // An unchanged failure is reported without rejecting later recompositions. + await expect(f.reconcile()).resolves.toEqual({ warnings: [expect.stringContaining("'missing-lib'")], pending: [] }) + addon(f.dir, '3') + await expect(f.reconcile()).resolves.toEqual({ warnings: [], pending: [] }) + expect(f.trace).toEqual(['stop:1', 'start:3:1:-']) +}) + +it('loads a changed dependency together with the corrected plugin that failed to load', async () => { + const f = await fixture() + quiet(f.ctx) + f.trace.length = 0 + shared(f.dir, 2) + addon(f.dir, '2', plugin('2', 'missing-lib')) + await expect(f.reconcile()).rejects.toThrow('missing-lib') + addon(f.dir, '3') + await expect(f.reconcile()).resolves.toEqual({ warnings: [], pending: [] }) + expect(f.trace).toEqual(['stop:1', 'start:3:2:-']) +}) + +it('loads a plugin whose missing dependency was installed after its replacement failed', async () => { + const f = await fixture() + quiet(f.ctx) + f.trace.length = 0 + addon(f.dir, '2', plugin('2', 'late-lib')) + await expect(f.reconcile()).rejects.toThrow('late-lib') + file(join(f.dir, 'node_modules', 'late-lib', 'package.json'), JSON.stringify({ name: 'late-lib', type: 'module', exports: './index.mjs' })) + file(join(f.dir, 'node_modules', 'late-lib', 'index.mjs'), 'export const value = 7\n') + await expect(f.reconcile()).resolves.toEqual({ warnings: [], pending: [] }) + expect(f.trace).toEqual(['stop:1', 'start:2:7:-']) +}) + +it('loads a package reinstalled at the same path after removal', async () => { + const f = await fixture() + f.select(['other']) + await f.reconcile() + rmSync(join(f.dir, 'node_modules', 'addon'), { recursive: true }) + await expect(f.reconcile()).resolves.toEqual({ warnings: [], pending: [] }) + addon(f.dir, '2') + f.select(['addon', 'other']) + f.trace.length = 0 + await f.reconcile() + expect(f.trace).toEqual(['start:2:1:-']) +}) + +it('replaces a CommonJS plugin whose required file changed', async () => { + const f = await fixture(['cjs-addon']) + expect(f.trace).toEqual(['start:cjs:1']) + commonjs(f.dir, 2) + await expect(f.reconcile()).resolves.toEqual({ warnings: [], pending: [] }) + expect(f.trace).toEqual(['start:cjs:1', 'stop:cjs', 'start:cjs:2']) +}) + +it('stops a CommonJS plugin whose required file fails and applies its correction', async () => { + const f = await fixture(['cjs-addon']) + quiet(f.ctx) + const helper = join(f.dir, 'node_modules', 'cjs-addon', 'helper.cjs') + file(helper, "throw new Error('broken helper')\n") + await expect(f.reconcile()).rejects.toThrow('broken helper') + expect(f.trace).toEqual(['start:cjs:1', 'stop:cjs']) + commonjs(f.dir, 3) + await expect(f.reconcile()).resolves.toEqual({ warnings: [], pending: [] }) + expect(f.trace).toEqual(['start:cjs:1', 'stop:cjs', 'start:cjs:3']) +}) + +it('keeps a module first loaded after the last observation when its file predates it', async () => { + const f = await fixture() + const late = join(f.dir, 'node_modules', 'late', 'index.mjs') + file(late, 'export const loaded = Symbol("late")\nexport function apply() {}\n') + age(join(f.dir, 'node_modules', 'late')) + const url = pathToFileURL(late).href + const first = await f.ctx.loader.import(url) as { loaded: symbol } + // A running plugin keeps its package loaded. + await f.ctx.plugin(f.ctx.loader.unwrapExports(first) as Plugin) + await f.reconcile() + expect((await f.ctx.loader.import(url) as { loaded: symbol }).loaded).toBe(first.loaded) +}) + +it('compares module files on a file system without a native realpath, as a packed worker has', async () => { + const f = await fixture() + const native: unknown = Reflect.get(realpathSync, 'native') + Reflect.deleteProperty(realpathSync, 'native') + onTestFinished(() => { Reflect.set(realpathSync, 'native', native) }) + f.trace.length = 0 + addon(f.dir, '2') + try { + await expect(f.reconcile()).resolves.toEqual({ warnings: [], pending: [] }) + } finally { Reflect.set(realpathSync, 'native', native) } + expect(f.trace).toEqual(['stop:1', 'start:2:1:-']) +}) + +it('evicts a package a stopped plugin imported dynamically once the resolution drops its mapping', async () => { + const f = await fixture() + const lazy = join(f.dir, 'node_modules', 'lazy-dep') + file(join(lazy, 'package.json'), JSON.stringify({ name: 'lazy-dep', type: 'module' })) + file(join(lazy, 'index.mjs'), 'export const loaded = Symbol("lazy")\n') + age(lazy) + const url = pathToFileURL(join(realpathSync(lazy), 'index.mjs')).href + const first = await f.ctx.loader.import(url) as { loaded: symbol } + resolution(f.ctx, table('/packages/addon'), () => {}, table('/packages/addon', realpathSync(lazy))) + await f.reconcile() + expect((await f.ctx.loader.import(url) as { loaded: symbol }).loaded).not.toBe(first.loaded) +}) + +it('keeps the modules below a directory that stays mapped inside a dropped one', async () => { + const f = await fixture() + const outer = join(f.dir, 'node_modules', 'outer-lib') + const inner = join(outer, 'node_modules', 'inner-lib') + file(join(outer, 'package.json'), JSON.stringify({ name: 'outer-lib', type: 'module' })) + file(join(outer, 'index.mjs'), 'export const loaded = Symbol("outer")\n') + file(join(inner, 'package.json'), JSON.stringify({ name: 'inner-lib', type: 'module' })) + file(join(inner, 'index.mjs'), 'export const loaded = Symbol("inner")\n') + age(outer) + const outerUrl = pathToFileURL(join(realpathSync(outer), 'index.mjs')).href + const innerUrl = pathToFileURL(join(realpathSync(inner), 'index.mjs')).href + const outerFirst = await f.ctx.loader.import(outerUrl) as { loaded: symbol } + const innerFirst = await f.ctx.loader.import(innerUrl) as { loaded: symbol } + resolution(f.ctx, table('/packages/addon', realpathSync(inner)), () => {}, table('/packages/addon', realpathSync(outer), realpathSync(inner))) + await f.reconcile() + expect((await f.ctx.loader.import(outerUrl) as { loaded: symbol }).loaded).not.toBe(outerFirst.loaded) + expect((await f.ctx.loader.import(innerUrl) as { loaded: symbol }).loaded).toBe(innerFirst.loaded) +}) + +it('keeps the modules of a stopped plugin whose package stays mapped', async () => { + const f = await fixture() + const url = pathToFileURL(join(f.dir, 'node_modules', 'addon', 'index.mjs')).href + const first: unknown = await f.ctx.loader.import(url) + f.select(['other']) + await f.reconcile() + // Only the packages of dropped mappings are evicted; the deselected bundle's unchanged files stay loaded. + expect(await f.ctx.loader.import(url)).toBe(first) +}) + +it('evaluates again a module first loaded after the last observation when its file was written since', async () => { + const f = await fixture() + const late = join(f.dir, 'node_modules', 'late', 'index.mjs') + file(late, 'export const loaded = Symbol("late")\n') + const url = pathToFileURL(late).href + const first = await f.ctx.loader.import(url) as { loaded: symbol } + await f.reconcile() + expect((await f.ctx.loader.import(url) as { loaded: symbol }).loaded).not.toBe(first.loaded) +}) + +it('ignores modules whose loading failed while collecting importers', async () => { + const f = await fixture() + const broken = join(f.dir, 'node_modules', 'broken', 'index.mjs') + file(broken, "import 'missing-lib'\n") + await expect(f.ctx.loader.import(pathToFileURL(broken).href)).rejects.toThrow() + f.trace.length = 0 + shared(f.dir, 2) + await f.reconcile() + expect(f.trace).toEqual(['stop:1', 'start:1:2:-']) +}) + +it('leaves changed modules of the host module graph for a restart', async () => { + const argv = process.argv + onTestFinished(() => { process.argv = argv }) + const f = await fixture(undefined, async (_home, dir) => { + const main = join(dir, 'main.mjs') + file(main, "import './node_modules/shared-lib/index.mjs'\n") + process.argv = [argv[0]!, main] + await ModuleLoader.fromInternal()!.import(pathToFileURL(main).href, import.meta.url, {}) + }) + const warn = vi.spyOn(f.ctx.logger, 'warn').mockImplementation(() => {}) + onTestFinished(() => { warn.mockRestore() }) + const url = pathToFileURL(join(f.dir, 'node_modules', 'shared-lib', 'index.mjs')).href + f.trace.length = 0 + shared(f.dir, 2) + await expect(f.reconcile()).resolves.toEqual({ warnings: [], pending: [{ url, reason: 'host-module' }] }) + expect(f.trace).toEqual([]) +}) + +it('still recomposes when planning the replacement fails', async () => { + const f = await fixture() + const warn = vi.spyOn(f.ctx.logger, 'warn').mockImplementation(() => {}) + onTestFinished(() => { warn.mockRestore() }) + Reflect.set(f.ctx.hmr, 'hostModules', () => Promise.reject(new Error('graph unavailable'))) + f.trace.length = 0 + shared(f.dir, 2) + f.select(['addon']) + const url = pathToFileURL(join(f.dir, 'node_modules', 'shared-lib', 'index.mjs')).href + await expect(f.reconcile()).resolves.toEqual({ warnings: [], pending: [{ url, reason: 'module-comparison' }] }) + expect(f.trace).toEqual(['stop:other']) + expect(warn).toHaveBeenCalledWith(expect.objectContaining({ message: 'graph unavailable' })) +}) + +it('replaces running plugins before reporting a recomposition failure', async () => { + const f = await fixture() + f.trace.length = 0 + shared(f.dir, 2) + writeFileSync(join(f.dir, 'cordis.patch.yml'), JSON.stringify([{ insert: [ + { id: 'timer', name: 'cordis:timer' }, + { id: 'hmr', name: 'cordis:hmr', config: { root: [] } }, + { id: 'broken', name: './missing.mjs' }, + ] }])) + const error = vi.spyOn(f.ctx.logger, 'error').mockImplementation(() => {}) + onTestFinished(() => { error.mockRestore() }) + await expect(f.ctx.hmr.runExclusive(() => f.ctx.hmr.reconcileProfile(['broken']))).rejects.toThrow('broken') + expect(f.trace).toEqual(['stop:1', 'start:1:2:-']) +}) + +it('reconciles only inside an HMR transaction of a profile application', async () => { + const f = await fixture() + await expect(f.ctx.hmr.reconcileProfile()).rejects.toThrow('must run inside runExclusive') + const ctx = new Context() + ctx.baseUrl = pathToFileURL(f.dir + '/').href + onTestFinished(() => ctx.fiber.dispose()) + await ctx.plugin(Loader) + await ctx.plugin(Timer) + await ctx.plugin(Hmr, { root: [], ignored: [], debounce: 0 }) + await expect(ctx.hmr.runExclusive(() => ctx.hmr.reconcileProfile())).rejects.toThrow('requires profileContext') +}) + +it('replaces changed modules when the process has no entry module', async () => { + const argv = process.argv + process.argv = [] + onTestFinished(() => { process.argv = argv }) + const f = await fixture() + f.trace.length = 0 + shared(f.dir, 2) + await f.reconcile() + expect(f.trace).toEqual(['stop:1', 'start:1:2:-']) +}) + +it('retries a failed replacement of a plugin loaded after the last observation', async () => { + const f = await fixture([]) + quiet(f.ctx) + await f.ctx.loader.create({ name: 'addon' }) + await f.ctx.loader.await() + addon(f.dir, '2', plugin('2', 'missing-lib')) + await expect(f.reconcile()).rejects.toThrow('missing-lib') + addon(f.dir, '3') + await expect(f.reconcile()).resolves.toEqual({ warnings: [], pending: [] }) + expect(f.trace).toEqual(['start:1:1:-', 'stop:1', 'start:3:1:-']) +}) + +it('logs the changed modules its own profile watch leaves', async () => { + const argv = process.argv + onTestFinished(() => { process.argv = argv }) + const f = await fixture(undefined, async (_home, dir) => { + const main = join(dir, 'main.mjs') + file(main, "import './node_modules/shared-lib/index.mjs'\n") + process.argv = [argv[0]!, main] + await ModuleLoader.fromInternal()!.import(pathToFileURL(main).href, import.meta.url, {}) + }) + const warn = vi.spyOn(f.ctx.logger, 'warn').mockImplementation(() => {}) + onTestFinished(() => { warn.mockRestore() }) + shared(f.dir, 2) + await f.editPatch() + const url = pathToFileURL(join(f.dir, 'node_modules', 'shared-lib', 'index.mjs')).href + expect(warn).toHaveBeenCalledWith(expect.stringContaining('keep running their previous code'), [{ url, reason: 'host-module' }]) +}) + +it('reports a required restart when the profile packages cannot be listed', async () => { + const f = await fixture(undefined, async (_home, dir) => { + // A scope entry that is a file makes listing its packages fail. + file(join(dir, 'node_modules', '@broken'), '') + }) + const warn = vi.spyOn(f.ctx.logger, 'warn').mockImplementation(() => {}) + onTestFinished(() => { warn.mockRestore() }) + f.trace.length = 0 + f.select(['addon']) + await expect(f.reconcile()).resolves.toEqual({ warnings: [], pending: [], restartReason: 'module-comparison' }) + expect(f.trace).toEqual(['stop:other']) + expect(warn).toHaveBeenCalledWith(expect.objectContaining({ code: 'ENOTDIR' })) +}) + +it('compares only CommonJS modules when module hooks keep the ES module cache on another thread', async () => { + const f = await fixture(['cjs-addon']) + const internal = ModuleLoader.fromInternal()! + const cache: unknown = Reflect.get(internal, 'loadCache') + Reflect.set(internal, 'loadCache', undefined) + onTestFinished(() => { Reflect.set(internal, 'loadCache', cache) }) + const warn = vi.spyOn(f.ctx.logger, 'warn') + onTestFinished(() => { warn.mockRestore() }) + commonjs(f.dir, 2) + await expect(f.reconcile()).resolves.toMatchObject({ warnings: [] }) + expect(warn).not.toHaveBeenCalled() +}) + +it('reads no module links and ignores source changes with a worker-style loader', async () => { + const f = await fixture() + const internal = ModuleLoader.fromInternal()! + const cache: unknown = Reflect.get(internal, 'loadCache') + Reflect.set(internal, 'loadCache', undefined) + onTestFinished(() => { Reflect.set(internal, 'loadCache', cache) }) + const warn = vi.spyOn(f.ctx.logger, 'warn') + onTestFinished(() => { warn.mockRestore() }) + const changes: string[] = [] + f.ctx.on('hmr/change', (url) => { changes.push(url) }) + const source = join(f.dir, 'node_modules', 'addon', 'index.mjs') + await expect(f.ctx.hmr.getLinked(pathToFileURL(source).href)).resolves.toEqual([]) + for (const watcher of watchers.slice(-1)) watcher.emit('change', source) + await vi.waitFor(() => { expect(changes).toEqual([pathToFileURL(source).href]) }) + expect(warn).not.toHaveBeenCalled() +}) + +it('requests a restart for module replacement without Node-compatible caches', async () => { + const f = await fixture() + const internal = ModuleLoader.fromInternal()! + const esm: unknown = Reflect.get(internal, 'loadCache') + const commonjs: unknown = Reflect.get(NodeModule, '_cache') + Reflect.set(internal, 'loadCache', undefined) + Reflect.set(NodeModule, '_cache', undefined) + onTestFinished(() => { Reflect.set(internal, 'loadCache', esm); Reflect.set(NodeModule, '_cache', commonjs) }) + const warn = vi.spyOn(f.ctx.logger, 'warn') + onTestFinished(() => { warn.mockRestore() }) + f.trace.length = 0 + // Patches remain identical: native imports cannot run while these cache fields are hidden. + shared(f.dir, 2) + const result = await f.reconcile() + Reflect.set(internal, 'loadCache', esm) + Reflect.set(NodeModule, '_cache', commonjs) + expect(result).toEqual({ warnings: [], pending: [], restartReason: 'module-comparison' }) + expect(f.trace).toEqual([]) + expect(warn).not.toHaveBeenCalled() +}) + +it('removes a plugin whose replacement fails to load without reporting the failure', async () => { + const f = await fixture() + addon(f.dir, '2', plugin('2', 'missing-lib')) + f.select(['other']) + f.trace.length = 0 + await expect(f.reconcile()).resolves.toEqual({ warnings: [], pending: [] }) + expect(f.trace).toEqual(['stop:1']) +}) + +it('replaces changed plugins beside CommonJS cache entries that are not Node modules', async () => { + const f = await fixture() + const require = createRequire(import.meta.url) + const planted = join(f.dir, 'planted.cjs') + // Some libraries store plain objects in require.cache to provide a module without loading it. + require.cache[planted] = { exports: {} } as NodeJS.Module + onTestFinished(() => { Reflect.deleteProperty(require.cache, planted) }) + f.trace.length = 0 + addon(f.dir, '2') + await expect(f.reconcile()).resolves.toEqual({ warnings: [], pending: [] }) + expect(f.trace).toEqual(['stop:1', 'start:2:1:-']) +}) + +it('imports the corrected files of an entry whose import failed', async () => { + const f = await fixture() + addon(f.dir, '2', plugin('2', 'missing-lib')) + f.select(['other']) + await f.reconcile() + // Selected again, the entry imports the broken files and has no fiber. + // The Loader logs the entry's import failure. + const error = vi.spyOn(f.ctx.logger, 'error').mockImplementation(() => {}) + onTestFinished(() => { error.mockRestore() }) + f.select(['addon', 'other']) + await expect(f.reconcile()).rejects.toThrow(/failed to import: .*missing-lib/u) + f.trace.length = 0 + addon(f.dir, '3') + await expect(f.reconcile()).resolves.toEqual({ warnings: [], pending: [] }) + expect(f.trace).toEqual(['start:3:1:-']) +}) + +it('leaves a disabled entry whose import failed until it is enabled', async () => { + const f = await fixture() + addon(f.dir, '2', plugin('2', 'missing-lib')) + f.select(['other']) + await f.reconcile() + // The Loader logs the entry's import failure. + const error = vi.spyOn(f.ctx.logger, 'error').mockImplementation(() => {}) + onTestFinished(() => { error.mockRestore() }) + f.select(['addon', 'other']) + await expect(f.reconcile()).rejects.toThrow('failed to import') + const rows = [{ id: 'timer', name: 'cordis:timer' }, { id: 'hmr', name: 'cordis:hmr', config: { root: [] } }] + writeFileSync(f.profile.patchPath, JSON.stringify([{ insert: rows }, { id: 'addon', disabled: true }])) + await f.reconcile() + f.trace.length = 0 + addon(f.dir, '3') + await expect(f.reconcile()).resolves.toEqual({ warnings: [], pending: [] }) + expect(f.trace).toEqual([]) + writeFileSync(f.profile.patchPath, JSON.stringify([{ insert: rows }])) + await expect(f.reconcile()).resolves.toEqual({ warnings: [], pending: [] }) + expect(f.trace).toEqual(['start:3:1:-']) +}) + + +/** Install a plugin package that records its start. */ +function late(dir: string): void { + const root = join(dir, 'node_modules', 'late-plugin') + file(join(root, 'package.json'), JSON.stringify({ name: 'late-plugin', type: 'module', exports: './index.mjs' })) + file(join(root, 'index.mjs'), "export function apply(ctx) { ctx.get('reloadTrace').push('start:late') }\n") +} + +it('retries a failed import while the profile packages cannot be listed', async () => { + const f = await fixture([]) + quiet(f.ctx) + await f.ctx.loader.create({ name: 'late-plugin' }) + await f.ctx.loader.await() + // A scope entry that is a file makes listing its packages fail. + file(join(f.dir, 'node_modules', '@broken'), '') + const warn = vi.spyOn(f.ctx.logger, 'warn').mockImplementation(() => {}) + onTestFinished(() => { warn.mockRestore() }) + late(f.dir) + await expect(f.reconcile()).resolves.toEqual({ warnings: [], pending: [], restartReason: 'module-comparison' }) + expect(f.trace).toEqual(['start:late']) +}) + +it('starts an entry whose missing dependency was installed after startup', async () => { + const f = await fixture([], async (_home, dir) => { + file(join(dir, 'node_modules', 'needs-late', 'package.json'), JSON.stringify({ name: 'needs-late', type: 'module', exports: './index.mjs' })) + file(join(dir, 'node_modules', 'needs-late', 'index.mjs'), "export { apply } from 'late-plugin'\n") + }) + quiet(f.ctx) + await f.ctx.loader.create({ name: 'needs-late' }) + await f.ctx.loader.await() + await f.reconcile() + late(f.dir) + await expect(f.reconcile()).resolves.toEqual({ warnings: [], pending: [] }) + expect(f.trace).toEqual(['start:late']) +}) + +it('imports a failing replacement with a new configuration once', async () => { + const f = await fixture() + const error = vi.spyOn(f.ctx.logger, 'error').mockImplementation(() => {}) + onTestFinished(() => { error.mockRestore() }) + addon(f.dir, '2', plugin('2', 'missing-lib'), 'fast') + await expect(f.reconcile()).rejects.toThrow('missing-lib') + expect(error.mock.calls.filter(([value]) => String(value).includes('missing-lib'))).toHaveLength(1) +}) + +it('reports an entry whose import failed and whose disabling expression throws', async () => { + const f = await fixture([]) + quiet(f.ctx) + const id = await f.ctx.loader.create({ name: 'late-plugin' }) + await f.ctx.loader.await() + Reflect.set(f.ctx.loader.resolve(id).options, 'disabled', { __jsExpr: 'undefined.x' }) + // Recomposition still applies and judges; the unchanged expression failure is reported. + await expect(f.reconcile()).resolves.toEqual({ warnings: [expect.stringContaining('disabled expression failed')], pending: [] }) +}) + +it('leaves a changed plugin that an unchanged plugin registered dynamically for a restart', async () => { + const f = await fixture(['other'], async (_home, dir) => { + const root = join(dir, 'node_modules', 'other') + file(join(root, 'child.mjs'), "export function apply(ctx) { ctx.get('reloadTrace').push('start:child:1') }\n") + file(join(root, 'index.mjs'), "const child = await import('./child.mjs')\nexport function apply(ctx) { ctx.plugin(child) }\n") + }) + f.trace.length = 0 + const child = join(f.dir, 'node_modules', 'other', 'child.mjs') + file(child, "export function apply(ctx) { ctx.get('reloadTrace').push('start:child:2') }\n") + await expect(f.reconcile()).resolves.toEqual({ warnings: [], pending: [{ url: pathToFileURL(child).href, reason: 'unmanaged-plugin' }] }) + expect(f.trace).toEqual([]) +}) + +it('recovers from an initial listing failure without replacing unchanged old files', async () => { + const f = await fixture(undefined, async (_home, dir) => { file(join(dir, 'node_modules', '@broken'), '') }) + unlinkSync(join(f.dir, 'node_modules', '@broken')) + f.trace.length = 0 + await expect(f.reconcile()).resolves.toEqual({ warnings: [], pending: [] }) + expect(f.trace).toEqual([]) +}) + +it('records freshly mounted plugin files so installing the next bundle does not restart them', async () => { + const f = await fixture([]) + addon(f.dir, '2') + f.select(['addon']) + await f.reconcile() + f.trace.length = 0 + other(f.dir) + f.select(['addon', 'other']) + await f.reconcile() + expect(f.trace).toEqual(['start:other']) +}) + +it('walks Node 22 null-prototype linked arrays', async () => { + const f = await fixture() + const internal = ModuleLoader.fromInternal()! + const url = pathToFileURL(join(f.dir, 'node_modules', 'addon', 'index.mjs')).href + const job = internal.loadCache.get(url)! + const linked = job.linked + const children = Array.from(await linked) + Object.setPrototypeOf(children, null) + Reflect.set(job, 'linked', Promise.resolve(children)) + onTestFinished(() => { Reflect.set(job, 'linked', linked) }) + shared(f.dir, 2) + f.trace.length = 0 + await f.reconcile() + expect(f.trace).toEqual(['stop:1', 'start:1:2:-']) +}) + +it('keeps CommonJS plugins and their caches pending when no ES module jobs are available', async () => { + const f = await fixture(['cjs-addon']) + const internal = ModuleLoader.fromInternal()! + const esm: unknown = Reflect.get(internal, 'loadCache') + const require = createRequire(import.meta.url) + const helper = join(f.dir, 'node_modules', 'cjs-addon', 'helper.cjs') + const cached = require.cache[helper] + file(helper, 'exports.value = 2\n') + Reflect.set(internal, 'loadCache', undefined) + onTestFinished(() => { Reflect.set(internal, 'loadCache', esm) }) + try { + const expected = { warnings: [], restartReason: 'module-comparison', + pending: [{ url: pathToFileURL(helper).href, reason: 'module-comparison' }] } + await expect(f.reconcile()).resolves.toEqual(expected) + await expect(f.reconcile()).resolves.toEqual(expected) + expect(require.cache[helper]).toBe(cached) + expect(f.trace).toEqual(['start:cjs:1']) + } finally { Reflect.set(internal, 'loadCache', esm) } +}) + +it.each(['disabled', 'removed', 'incompatible', 'dynamic'] as const)('never evaluates the replacement of a %s row', async (kind) => { + const f = await fixture() + const marker = join(f.home, 'evaluated') + addon(f.dir, '2', `import { writeFileSync } from 'node:fs'; writeFileSync(${JSON.stringify(marker)}, 'yes');\n` + plugin('2')) + if (kind === 'incompatible') { + const path = join(f.dir, 'node_modules', 'addon', 'package.json') + file(path, JSON.stringify({ name: 'addon', version: '2.0.0', type: 'module', exports: './index.mjs', + peerDependencies: { '@deepseek-ai/dsh': '<0.0.0' }, dsh: { bundle: { patch: './cordis.patch.yml' } } })) + } else if (kind === 'removed') { + f.select(['other']) + } else { + file(join(f.dir, 'node_modules', 'addon', 'cordis.patch.yml'), + `- insert:\n - id: addon\n name: addon\n disabled: ${kind === 'dynamic' ? '!!js "true"' : 'true'}\n`) + } + f.trace.length = 0 + await f.reconcile() + expect(existsSync(marker)).toBe(false) + expect(f.trace).toEqual(['stop:1']) + addon(f.dir, '3') + f.select(['addon', 'other']) + await f.reconcile() + expect(f.trace).toContain('start:3:1:-') +}) + +it('restarts both CommonJS requirers of a changed shared child', async () => { + const f = await fixture(['cjs-addon'], async (_home, dir) => { + const root = join(dir, 'node_modules', 'other') + file(join(root, 'package.json'), JSON.stringify({ name: 'other', version: '1.0.0', main: './index.cjs', + dsh: { bundle: { patch: './cordis.patch.yml' } } })) + file(join(root, 'index.cjs'), `const helper = require('../cjs-addon/helper.cjs') +exports.apply = ctx => { + ctx.get('reloadTrace').push('start:second:' + helper.value) + ctx.effect(() => () => ctx.get('reloadTrace').push('stop:second')) +} +`) + }) + f.select(['cjs-addon', 'other']) + await f.reconcile() + f.trace.length = 0 + file(join(f.dir, 'node_modules', 'cjs-addon', 'helper.cjs'), 'exports.value = 2\n') + await f.reconcile() + expect(f.trace.toSorted()).toEqual(['start:cjs:2', 'start:second:2', 'stop:cjs', 'stop:second']) +}) + + +it('replaces a row whose disabling is an expression', async () => { + const f = await fixture(undefined, async (_home, dir) => { + file(join(dir, 'node_modules', 'addon', 'cordis.patch.yml'), + '- insert:\n - id: addon\n name: addon\n disabled: !!js "false"\n') + }) + f.trace.length = 0 + shared(f.dir, 2) + await expect(f.reconcile()).resolves.toEqual({ warnings: [], pending: [] }) + expect(f.trace).toEqual(['stop:1', 'start:1:2:-']) +}) + +it('logs a profile-wide restart requirement from its watch when caches are unavailable', async () => { + const f = await fixture() + const internal = ModuleLoader.fromInternal()! + const esm: unknown = Reflect.get(internal, 'loadCache') + const warn = vi.spyOn(f.ctx.logger, 'warn').mockImplementation(() => {}) + onTestFinished(() => { warn.mockRestore(); Reflect.set(internal, 'loadCache', esm) }) + Reflect.set(internal, 'loadCache', undefined) + try { + await f.editPatch() + expect(warn).toHaveBeenCalledWith('restart required: %s', 'module-comparison') + } finally { Reflect.set(internal, 'loadCache', esm) } +}) + + +it.each(['group', 'include'] as const)('replaces the children of a nested %s', async (kind) => { + const f = await fixture(['addon'], async (_home, dir) => { + const config = join(dir, 'nested.yml') + file(config, '- id: child\n name: addon\n') + file(join(dir, 'node_modules', 'addon', 'cordis.patch.yml'), JSON.stringify([{ insert: [kind === 'group' + ? { id: 'nested', name: 'cordis:group', config: [{ id: 'child', name: 'addon' }] } + : { id: 'nested', name: 'cordis:include', config: { path: pathToFileURL(config).href } }] }])) + }) + f.trace.length = 0 + addon(f.dir, '2') + await expect(f.reconcile()).resolves.toEqual({ warnings: [], pending: [] }) + expect(f.trace).toEqual(['stop:1', 'start:2:1:-']) +}) diff --git a/packages/boot/plugin-manager/README.i18n.yaml b/packages/boot/plugin-manager/README.i18n.yaml index c95d399d99..9cb4ec1a9f 100644 --- a/packages/boot/plugin-manager/README.i18n.yaml +++ b/packages/boot/plugin-manager/README.i18n.yaml @@ -15,8 +15,8 @@ en: 87679c51aa3693f5 zh: 11684a48a5806afb /deepseek-ai-dsh-plugin-manager/use-this-package: - en: d7dc0c0500c9d381 - zh: afd5faeaef84c714 + en: 95c1b18f8432090a + zh: 213e54a1547a1da1 /deepseek-ai-dsh-plugin-manager/use-this-package/version-compatibility-and-exemptions: en: 920c0acb3b9feaf3 zh: ba6e832a256880a7 @@ -24,8 +24,8 @@ en: d5643a06f300c30a zh: 9348b6a7676a2849 /deepseek-ai-dsh-plugin-manager/understand-the-implementation: - en: 21ccb07cfee8974c - zh: 5790cd89d9c14e9e + en: 495dadf79c48c43c + zh: 9884dd04a5bb49dc /deepseek-ai-dsh-plugin-manager/further-exploration: en: c6538243d8702a0d zh: 04a82d923353c75f @@ -36,8 +36,8 @@ en: 1815ec6ce56c80d4 zh: 2d7ac75d3b25eb6b /deepseek-ai-dsh-plugin-manager/model-experience/management-tool/what-the-model-sees: - en: 72d6c70869b12f22 - zh: 45218d6f1e765fb1 + en: b1dae91f98350290 + zh: 802ae86d923ac6ce /deepseek-ai-dsh-plugin-manager/model-experience/management-tool/token-effect: en: bc70208bd361dbcd zh: bb651d6066066c6c @@ -45,11 +45,11 @@ en: c29f60be7f3edc1a zh: d9b9388b96ef95cc /deepseek-ai-dsh-plugin-manager/known-limitations-and-deferred-work: - en: fde2d9491fe5dd4f - zh: ef6e6db15c4f19d2 + en: 6b93fd9851b79a0e + zh: d2cf1982e8cfc718 /deepseek-ai-dsh-plugin-manager/known-limitations-and-deferred-work/failure-behavior: - en: 4447e4df8d62b274 - zh: c76443e2edb97b1d + en: c670eb2330b441e8 + zh: 2c8223e03e1e3ef0 /deepseek-ai-dsh-plugin-manager/known-limitations-and-deferred-work/dev-note: en: e67f633dc6fe7773 zh: 3bb0c20c93f16f98 diff --git a/packages/boot/plugin-manager/README.md b/packages/boot/plugin-manager/README.md index 1c3ca5f62e..0c36040de2 100644 --- a/packages/boot/plugin-manager/README.md +++ b/packages/boot/plugin-manager/README.md @@ -49,7 +49,7 @@ A selected bundle that cannot load remains in `listBundles` with an `error`; `en Published installations offer `package@`. Source Web and development Desktop offer `link:` from the running CLI installation, including its development symlink. Package names resolve through workspace manifests; current working directory and prerelease suffixes do not select a checkout. Missing packages, bundle patches, or built entry points produce a diagnostic without registry fallback. Prepare the checkout with `pnpm install` and `pnpm run build`. Linked packages use that checkout’s dependencies and peers; rebuilding and restarting remain necessary after code changes. A registry copy or another checkout’s link can need replacement even when its version matches. -Official on-demand installs and explicit updates call the same `installBundle` operation as other bundles. Registry targets use `saveExact: true`; development targets retain the ordinary `link:` dependency. Updates pass the current `enabled` selection; replacing an existing dependency reports `restart-required`. Turning Off only deselects the layer, and Remove uses the existing removal checks. Installation failure restores the profile manifest and lockfile; activation failure after installation can retain the saved dependency and selection. The existing peer-compatibility check still applies, without an additional Official-only version admission rule. +Official on-demand installs call the same `installBundle` operation as other bundles. Registry targets use `saveExact: true`; development targets retain the ordinary `link:` dependency. An explicit update removes the installed package and installs the target with the current `enabled` selection; without HMR, a bundle that runs since startup cannot be removed, so the installation goes over it and reports `restart-required`. A failed or cancelled installation after the removal leaves the package uninstalled, and the reinstalled bundle joins the end of the selection. Turning Off only deselects the layer, and Remove uses the existing removal checks. Installation failure restores the profile manifest and lockfile; activation failure after installation can retain the saved dependency and selection. The existing peer-compatibility check still applies, without an additional Official-only version admission rule. `installBundle` checks GitHub repositories with `git ls-remote` before starting pnpm, using the profile directory and the installer's Git and proxy configuration. `githubConnectionTimeoutMs` defaults to 5000 ms and limits only this check, not package download or builds. The check disables credential helpers and prompts; only network failures and timeouts stop installation, reporting `failedAt: 'spec-host'` with the existing failure kind and diagnostic log. Authentication, repository lookup and other failures are left to pnpm, including its HTTPS-to-SSH fallback. Cancellation and manager disposal stop the check and its descendants. Registry packages, paths, tarballs and other Git hosts skip this check. A reachable repository can still fail during download or bundle validation. @@ -97,7 +97,7 @@ The CLI exposes `dsh plugin --profile version-exemptions`, `allow-vers The service and `dsh plugin` share the package operations in [operations.ts](src/operations.ts). The launcher supplies the current profile; [DSH HMR](../hmr/README.md) serializes module reloads, file watching and management writes. Each configuration refresh re-reads bundle selection and patch layers, updates the original root Include, and awaits removed plugin resources as well as the remaining Loader tree. CLI and service operations share the profile manifest writer lock to prevent concurrent package and manifest writes. HMR does not acquire that lock. Pnpm runs outside the HMR queue; installation selects the bundle after pnpm succeeds, while removal deselects and unloads the bundle before pnpm runs. -Installing a new package, enabling a bundle, disabling it with HMR after its plugins stop, and a successful removal publish the profile's runtime resolution. Shared dependencies may pass to another bundle's declaration when their normalized directory, version, and scope stay unchanged. Without HMR, a deselected startup bundle still runs: while any such bundle is absent from the saved selection, subsequent package operations retain the entire current table and report `restart-required`. Other new installations and removals of non-running bundles can still publish without HMR; activation waits for restart. Overwriting an installed package publishes nothing and reports `restart-required` ([decision](../../../.agents/notes/implemented/architecture/2026-09-30-profile-package-refresh-and-manifest-invalidation.md)). +Without HMR, installing a new package, enabling a bundle and a successful removal publish the profile's runtime resolution; with HMR, every operation leaves publication to [HMR](../hmr/README.md)'s recomposition, which removes dropped mappings after their plugins stop. Shared dependencies may pass to another bundle's declaration when their normalized directory and scope stay unchanged. Without HMR, a deselected startup bundle still runs: while any such bundle is absent from the saved selection, subsequent package operations retain the entire current table and report `restart-required`. Other new installations and removals of non-running bundles can still publish without HMR; activation waits for restart. With HMR, `installBundle()` refuses a package that is already installed with `already-installed`, before pnpm runs, when the spec names it: a registry name, a local directory, or the git or tarball spec its dependency was saved with. A package that cannot be removed, such as a bundle carrying plugin management, therefore cannot be installed over with HMR. Installing another version is a removal and an installation, so the previous version's plugins stop, its modules are evicted and its mappings removed before the new one is installed; the Web page's Update for an Official plugin does both. A git or tarball spec whose package is known only after pnpm ran can still install over an installed package: without HMR it reports `restart-required`, and with HMR the recomposition replaces the running plugins whose loaded files changed. A replacement that fails to import or start reports `failed` with the activation diagnostic, which names the import error; the previous version no longer runs, and every later operation imports it again, so it starts once its files or dependencies are corrected. An unchanged import failure is a warning of later operations; a changed one fails them. A publication the runtime resolution rejects, because an installed package now resolves to another directory or a mapping of the DSH installation changed, leaves the composition as it was, after stopping and starting again the rows it would drop, and reports `restart-required` with `package-moved`. Disabling or removing a bundle evicts the modules of the packages whose mappings it drops. Every `restart-required` result carries `restartReason`: `no-hmr`, `package-moved`, or the [HMR pending reason](../hmr/README.md) of a changed module that loads only after a restart, unless the operation reports `overridden` ([decision](../../../.agents/notes/implemented/architecture/2026-09-30-profile-package-refresh-and-manifest-invalidation.md)). A service run whose captured output stays silent for `idleTimeoutMs` is terminated, reports `timedOut` alongside its exit status, is classified `timeout` whatever status the signal left behind, and is not retried on the next registry, which bounds how long one operation can hold the profile lock; the CLI inherits the terminal, captures no output, and stays unbounded for its operator to interrupt. A run completes when its process exits, and the pipes then drain under a bounded grace period, so a descendant that inherited them cannot hold the operation open. The diagnostic file is closed before the call settles, including preparation or spawn failures. A terminated run stops its whole process tree and waits for it, because a lifecycle script outlives the pnpm process that started it (issue #4981). Each operation records the pnpm run it starts in `.plugin-manager/run.json` and removes the record when the run ends. A lock whose holder process exited is taken over by the next writer, but that process's pnpm tree can still be running, so an operation that finds a record waits up to five seconds for the recorded run to stop and otherwise fails with a diagnostic that names the process and the record, without running pnpm. Dependency-only changes do not trigger configuration reloads. @@ -122,7 +122,7 @@ Results contain the last attempted stage, target, saved-state change, applicatio #### What the model sees -The [`plugin_manager` tool](../../../docs/tool-catalog.md#deepseek-aidsh-plugin-manager) lists plugin entries and bundles and performs profile-wide changes. Its results include saved-state changes, application status and package diagnostics. Management operations do not inject messages into Agents. +The [`plugin_manager` tool](../../../docs/tool-catalog.md#deepseek-aidsh-plugin-manager) lists plugin entries and bundles and performs profile-wide changes. Its results include saved-state changes, application status and package diagnostics. A change result that is not plainly applied adds `nextStep`, a sentence derived from its application status, refusal code, package-manager failure kind or restart reason that says what the model does next or tells the user. Management operations do not inject messages into Agents. #### Token effect @@ -137,7 +137,8 @@ Tool results append to the transcript. Enabling or disabling other tools can cha - Web approves the entire displayed pending group; it has no per-package selection. -- Package replacements require restarting the process to load a fresh JavaScript module generation. +- pnpm verifies every lockfile tarball URL against its default registry when a command changes the lockfile. After a package was installed from another registry, installing a different package with the default registry, or removing that package after a restart, can fail with `ERR_PNPM_TARBALL_URL_MISMATCH`. +- Package replacements require restarting the process without HMR. A package relinked outside Plugin Manager while it is still installed waits for a restart. - Startup-only profiles cannot remove packages used to start the current process; stop it and use `dsh plugin`. - The manager cannot disable its own management components, change another profile, or edit an agent preset's composition. - A failed removal may leave dependencies partially changed, and a failed or cancelled installation can leave downloaded files under `node_modules` or the pnpm store. Inactive dependencies with missing files remain removable. Diagnostic logs remain under the profile's `.plugin-manager/logs` directory. @@ -156,7 +157,7 @@ Failures preserve completed steps and report the actual remaining state. Profile | Resolution publication after a successful package operation fails | Keep completed disk changes and the previous runtime table. Report `application: 'failed'` with the publication diagnostic; pnpm's zero exit code does not mean publication succeeded. | | Remove: any step fails | Stop at the failed step. Preserve completed changes, retain remaining dependencies for retry, and report removal failure. Do not re-enable the bundle. | -Installation finishes after pnpm and bundle validation succeed; subsequent publication or enablement failure does not undo installation. Removal proceeds in order: remove the bundle from `dsh.profile.bundles`, unload its runtime contributions, run `pnpm remove`, then publish runtime resolution unless a deselected startup bundle still runs without HMR. A failed step prevents subsequent steps; publication failure after removal does not restore deleted packages. A selected name that neither the profile's dependencies nor the installation holds, such as a retired bundle, is also removable; its removal stops after deselection and runs no pnpm. +Installation finishes after pnpm and bundle validation succeed; subsequent publication or enablement failure does not undo installation. Removal proceeds in order: remove the bundle from `dsh.profile.bundles`, unload its runtime contributions, run `pnpm remove`, then publish runtime resolution unless a deselected startup bundle still runs without HMR. `pnpm remove` runs with the registry this manager last installed the package from in the running process, because pnpm verifies lockfile tarball URLs against its default registry; a package installed before the process started is removed with the registry an installation asks first. A failed step prevents subsequent steps; publication failure after removal does not restore deleted packages. A selected name that neither the profile's dependencies nor the installation holds, such as a retired bundle, is also removable; its removal stops after deselection and runs no pnpm. Restoration rewrites only the two snapshotted files; user-authored patch configuration, application data, diagnostic logs and files pnpm downloaded remain untouched, and the next package operation prunes packages no manifest references. diff --git a/packages/boot/plugin-manager/README.zh.md b/packages/boot/plugin-manager/README.zh.md index 1442c3c351..d5211beaa5 100644 --- a/packages/boot/plugin-manager/README.zh.md +++ b/packages/boot/plugin-manager/README.zh.md @@ -28,7 +28,7 @@ kind: "package-reference" ## 使用本包 -基于 base 的 profile 提供管理服务。在 Web 中,侧边栏的**插件**页([ui-plugin-manager](../../client/ui-plugin-manager/README.zh.md))管理 profile 的组合包及其能唯一定位的行;设置页的插件列表保持只读。Agent 预设条目保持只读。`plugin_manager` 工具提供相同操作,在 Creator 模式中启用。其他预设仍默认禁用。 每个工具操作都要求 `danger-full-access` 或本次调用的批准。在较低沙箱模式下,`ask` 会请求审批;`never`、拒绝、取消或审批渠道不可用时均不执行。批准不改变会话的权限模式。profile 变更跨会话持久化,已安装的 Host 代码在宿主进程内运行,不受工作区沙箱限制。依赖构建脚本仍需单独批准。 +基于 base 的 profile 提供管理服务。在 Web 中,侧边栏的**插件**页([ui-plugin-manager](../../client/ui-plugin-manager/README.zh.md))管理 profile 的组合包及其能唯一定位的行;设置页的插件列表保持只读。Agent 预设条目保持只读。`plugin_manager` 工具提供相同操作,在 Creator 模式中启用。其他预设仍默认禁用。每个工具操作都要求 `danger-full-access` 或本次调用的批准。在较低沙箱模式下,`ask` 会请求审批;`never`、拒绝、取消或审批渠道不可用时均不执行。批准不改变会话的权限模式。profile 变更跨会话持久化,已安装的 Host 代码在宿主进程内运行,不受工作区沙箱限制。依赖构建脚本仍需单独批准。 未使用 Agent 预设的部署在 profile patch 中启用工具;使用预设的会话由其预设中的 `tool-plugin-manager` 条目控制。 @@ -49,7 +49,7 @@ kind: "package-reference" 已发布的安装提供 `package@<当前 DSH 版本>`。源码 Web 与开发 Desktop 提供 `link:<工作区包的绝对目录>`,目录来自当前 CLI 的安装位置,包括其开发符号链接。包名通过工作区清单解析;当前工作目录和预发布版本后缀不用于选择 checkout。包、bundle patch 或已构建入口缺失时报告诊断,不回退到注册表。使用 `pnpm install` 和 `pnpm run build` 准备 checkout。链接包使用该 checkout 的依赖与 peer;代码改变后仍需重新构建并重启。即使版本相同,注册表副本或其他 checkout 的链接也可能需要替换。 -官方按需安装与显式更新调用和其他组合包相同的 `installBundle` 操作。注册表目标使用 `saveExact: true`;开发目标保留普通 `link:` 依赖。更新传入当前 `enabled` 选择状态;替换已有依赖会报告 `restart-required`。关闭仅取消选择配置层,移除使用原有移除检查。安装失败恢复 profile 清单和锁文件;安装成功后激活失败可能保留已保存的依赖和选择。原有 peer 兼容性检查继续生效,不增加仅针对官方条目的版本准入规则。 +官方按需安装调用和其他组合包相同的 `installBundle` 操作。注册表目标使用 `saveExact: true`;开发目标保留普通 `link:` 依赖。显式更新先删除已安装的包,再以当前 `enabled` 选择状态安装目标;没有 HMR 时,自启动起运行的组合包无法删除,因此安装直接覆盖它并报告 `restart-required`。删除之后安装失败或被取消时,该包保持未安装,重新安装的组合包排在选择列表末尾。关闭仅取消选择配置层,移除使用原有移除检查。安装失败恢复 profile 清单和锁文件;安装成功后激活失败可能保留已保存的依赖和选择。原有 peer 兼容性检查继续生效,不增加仅针对官方条目的版本准入规则。 `installBundle` 在启动 pnpm 前通过 `git ls-remote` 检查 GitHub 仓库,使用 profile 目录及安装器的 Git 与代理配置。`githubConnectionTimeoutMs` 默认为 5000 毫秒,只限制这次检查,不限制包下载或构建。检查禁用凭据助手和认证提示;只有网络失败与超时会停止安装,通过现有失败类型与诊断日志返回,并标记 `failedAt: 'spec-host'`。认证、仓库查找及其他失败继续交给 pnpm,包括其 HTTPS 到 SSH 的回退。取消安装或销毁管理器会停止检查及其子进程。注册表包、本地路径、压缩包和其他 Git 主机跳过此检查。仓库可达后,下载或组合包验证仍可能失败。 @@ -97,7 +97,7 @@ CLI 提供 `dsh plugin --profile version-exemptions`、`allow-version 服务与 `dsh plugin` 共用 [operations.ts](src/operations.ts) 中的包管理操作。启动器提供当前 profile;[DSH HMR](../hmr/README.zh.md) 串行执行模块重载、文件监听和管理写入。每次配置刷新重新读取组合包选择与 patch 层,更新原有根 Include,并等待已移除插件释放资源及剩余 Loader 树稳定。CLI 与 service 操作共用 profile manifest 写锁,防止并发包操作和 manifest 写入。HMR 不获取该锁。pnpm 在 HMR 队列之外执行;安装在 pnpm 成功后选入组合包,删除则在执行 pnpm 前取消选入并完成卸载。 -安装新包、启用组合包、有 HMR 时停用组合包并等插件退出后、以及删除成功后,都会发布 profile 的 runtime resolution。共享依赖的规范化目录、版本和作用域不变时,可以改由另一个组合包声明。没有 HMR 时,已取消选入的启动组合包仍在运行:只要保存的选择列表中缺少任何这样的组合包,后续包操作就保留整张当前表,并报告 `restart-required`。其他新安装和未运行组合包的删除仍可在没有 HMR 时发布;激活等待重启。覆盖已安装的包不发布,并报告 `restart-required`([决策](../../../.agents/notes/implemented/architecture/2026-09-30-profile-package-refresh-and-manifest-invalidation.zh.md))。 +没有 HMR 时,安装新包、启用组合包以及删除成功后,都会发布 profile 的 runtime resolution;有 HMR 时,每个操作都把发布交给 [HMR](../hmr/README.zh.md) 的重新组合,该重新组合在相关插件停止后移除被丢弃的映射。共享依赖的规范化目录和作用域不变时,可以改由另一个组合包声明。没有 HMR 时,已取消选入的启动组合包仍在运行:只要保存的选择列表中缺少任何这样的组合包,后续包操作就保留整张当前表,并报告 `restart-required`。其他新安装和未运行组合包的删除仍可在没有 HMR 时发布;激活等待重启。有 HMR 时,spec 指明已安装的包,`installBundle()` 在运行 pnpm 之前以 `already-installed` 拒绝安装;spec 可以是 registry 包名、本地目录,或保存依赖时使用的 git 或 tarball spec。因此,有 HMR 时无法在不能删除的包(例如带有插件管理的组合包)上安装。安装另一个版本需要先卸载再安装,因此在安装新版本之前,旧版本的插件停止,其模块被逐出,其映射被移除;Web 页面中官方插件的更新会依次执行这两步。运行 pnpm 之后才能确定包名的 git 或 tarball spec 仍可能安装到已安装的包上:没有 HMR 时报告 `restart-required`;有 HMR 时,重新组合替换已加载文件发生变化的运行中插件。替换版本导入或启动失败时报告 `failed` 及激活诊断,该诊断给出导入错误;旧版本不再运行,此后每次操作都会再次导入它,因此其文件或依赖被修正后它即启动。未变化的导入失败在后续操作中作为警告报告;已变化的导入失败使后续操作失败。已安装的包现在解析到其他目录,或 DSH 安装的映射发生变化,使 runtime resolution 拒绝发布时,它停止并重新启动该发布将丢弃的行,组合保持不变,并报告带 `package-moved` 的 `restart-required`。停用或删除组合包时,会逐出因此被删除映射的那些包的模块。每个 `restart-required` 结果都带有 `restartReason`:`no-hmr`、`package-moved`,或只有重启后才能加载的已变化模块的 [HMR 待处理原因](../hmr/README.zh.md),除非操作报告 `overridden`([决策](../../../.agents/notes/implemented/architecture/2026-09-30-profile-package-refresh-and-manifest-invalidation.zh.md))。 service 运行若在 `idleTimeoutMs` 内没有任何捕获输出即被终止,与退出状态一并报告 `timedOut`,不论信号留下什么退出状态都归类为 `timeout`,且不再转问下一个注册表,因此单次操作占用 profile 锁的时长有上界;CLI 继承终端、不捕获输出,因此不受此上界约束,由操作者中断。运行以进程退出为完成点,随后只在一个有界的宽限窗口内排空管道,因此继承管道的孙进程无法让操作挂起。诊断文件会在调用结束前关闭,包括准备或创建进程失败的情况。被终止的运行会停止整棵进程树并等待其消失,因为生命周期脚本的存活时间超过启动它的 pnpm 进程(issue #4981)。每个操作把它启动的 pnpm 运行记录在 `.plugin-manager/run.json` 中,并在运行结束时删除该记录。持有者进程已退出的锁会被下一个写入方接管,但该进程的 pnpm 进程树可能仍在运行,因此发现记录的操作最多等待五秒让记录中的运行停止,否则不运行 pnpm,并以指明该进程与记录文件的诊断失败。仅依赖字段变化不会触发配置重载。 @@ -122,7 +122,7 @@ service 运行若在 `idleTimeoutMs` 内没有任何捕获输出即被终止, #### 模型看到什么 -[`plugin_manager` 工具](../../../docs/tool-catalog.zh.md#deepseek-aidsh-plugin-manager) 列出插件条目和组合包,并执行影响整个 profile 的改动。结果包含保存状态变化、应用状态和包管理诊断。管理操作不会向 Agent 注入消息。 +[`plugin_manager` 工具](../../../docs/tool-catalog.zh.md#deepseek-aidsh-plugin-manager) 列出插件条目和组合包,并执行影响整个 profile 的改动。结果包含保存状态变化、应用状态和包管理诊断。未直接应用的变更结果会增加 `nextStep`,这是一句根据应用状态、拒绝代码、包管理器失败类型或重启原因生成的说明,指出模型下一步做什么或告诉用户什么。管理操作不会向 Agent 注入消息。 #### Token 影响 @@ -137,7 +137,8 @@ service 运行若在 `idleTimeoutMs` 内没有任何捕获输出即被终止, - Web 一次批准显示出来的整组待决定包,没有逐包选择。 -- 替换已有包后需要重启进程,以加载新的 JavaScript 模块版本。 +- 命令改动锁文件时,pnpm 按默认注册表校验锁文件中的每个 tarball URL。某个包从其他注册表安装后,使用默认注册表安装另一个包,或在重启后删除该包,可能以 `ERR_PNPM_TARBALL_URL_MISMATCH` 失败。 +- 没有 HMR 时,替换包需要重启进程。在 Plugin Manager 之外重新链接仍处于安装状态的包,需要等待重启。 - 仅启动时加载的 profile 不能删除当前进程启动时使用的包;停止进程后使用 `dsh plugin`。 - 管理器不能关闭自身所需的管理组件、修改其他 profile 或编辑 agent 预设组合。 - 失败的删除可能留下部分依赖改动,失败或被取消的安装可能在 `node_modules` 或 pnpm 缓存中留下已下载文件。文件缺失的未启用依赖仍可删除。诊断日志保留在 profile 的 `.plugin-manager/logs` 目录中。 @@ -156,7 +157,7 @@ service 运行若在 `idleTimeoutMs` 内没有任何捕获输出即被终止, | 包操作成功后解析表发布失败 | 保留已完成的磁盘改动和上一张运行时表。报告 `application: 'failed'` 及发布诊断;pnpm 的零退出码不代表发布成功。 | | 卸载:任一步失败 | 停在失败步骤,保留已完成的改动和待重试删除的依赖,报告卸载失败。不重新启用组合包。 | -pnpm 执行和组合包校验成功即完成安装,后续发布或启用失败不撤销安装。卸载依次执行:从 `dsh.profile.bundles` 移除组合包、卸载运行时贡献、执行 `pnpm remove`,然后发布 runtime resolution,除非没有 HMR 且已取消选入的启动组合包仍在运行。任一步失败都不继续执行后续步骤;删除后的发布失败不会恢复已删除的包。 profile 依赖和安装都不持有的已选名称(例如已下线的组合包)同样可卸载;其卸载在取消选择后结束,不运行 pnpm。 +pnpm 执行和组合包校验成功即完成安装,后续发布或启用失败不撤销安装。卸载依次执行:从 `dsh.profile.bundles` 移除组合包、卸载运行时贡献、执行 `pnpm remove`,然后发布 runtime resolution,除非没有 HMR 且已取消选入的启动组合包仍在运行。`pnpm remove` 使用本管理器在当前进程中最近一次安装该包时所用的注册表,因为 pnpm 按默认注册表校验锁文件中的 tarball URL;进程启动前安装的包使用安装时首先询问的注册表删除。任一步失败都不继续执行后续步骤;删除后的发布失败不会恢复已删除的包。 profile 依赖和安装都不持有的已选名称(例如已下线的组合包)同样可卸载;其卸载在取消选择后结束,不运行 pnpm。 恢复只重写这两份快照文件;用户编写的 patch 配置、应用数据、诊断日志以及 pnpm 已下载的文件保持原样,没有 manifest 引用的包由下一次包操作清理。 diff --git a/packages/boot/plugin-manager/src/index.ts b/packages/boot/plugin-manager/src/index.ts index 6434b0edfe..ebfb72d6e2 100644 --- a/packages/boot/plugin-manager/src/index.ts +++ b/packages/boot/plugin-manager/src/index.ts @@ -13,10 +13,10 @@ import { TypertRemoteService, Remote } from '@deepseek-ai/dsh-typert-protocol' import { pluginEntryId, readPluginInventory } from '@deepseek-ai/dsh-host-plugin-inventory' import { readPluginMeta, readProfileManifest, resolveBundleDir, loadOverlayPatches, composeEntries, - reconcileProfilePatches, readProfilePatches, OPTIONAL_BUNDLES, bundlePatchPaths, + readProfilePatches, OPTIONAL_BUNDLES, bundlePatchPaths, evaluatePluginCompatibility, readProfileCompatibility, readProfileVersionExemptions, setProfileVersionExemption, PROFILE_COMPATIBILITY_FILENAME, - OFFICIAL_ON_DEMAND_CATALOG, getDshRuntimeVersion, + OFFICIAL_ON_DEMAND_CATALOG, getDshRuntimeVersion, ResolutionRestartRequired, } from '@deepseek-ai/dsh-app-boot' import type {} from '@deepseek-ai/dsh-hmr' import type { ProfileContext, ProfileManifest } from '@deepseek-ai/dsh-app-boot' @@ -151,6 +151,40 @@ function printedError(printed: string): string { return [code, message].filter((part): part is string => typeof part === 'string').join(' ') } +/** What applying the saved profile reports into a change result. */ +type Reload = Pick + +/** The bundles a profile manifest selects. */ +function bundleSelection(manifest: ProfileManifest): readonly string[] { + return manifest.dsh?.profile?.bundles ?? [] +} + +/** + * The package an installation spec names before pnpm runs: a registry name, a local directory's manifest name, or the + * dependency saved with this git or tarball spec, compared as the plugin list shows saved specs. + * @param spec The installation spec. + * @param dependencies The profile's saved dependencies. + * @param profileDir The profile directory saved local paths are relative to. + * @returns The package name, or `undefined` when only the installation tells it. + */ +function specPackageName(spec: string, dependencies: Readonly>, profileDir: string): string | undefined { + const parsed = parsedForRegistry(spec) + if (parsed.kind === 'registry') return parsed.name + if (parsed.kind === 'path') return packageName(parsed.path) + const specs = parsed.kind === 'tarball' && parsed.path !== undefined ? [spec, `file:${parsed.path}`] : [spec] + return Object.entries(dependencies).find(([dependency, saved]) => specs.includes(dependencySpec(dependency, saved, profileDir)))?.[0] +} + +/** The package name a local directory declares, or `undefined` without a readable manifest. */ +function packageName(dir: string): string | undefined { + try { + return readProfileManifest('dsh', dir).name + } catch (_error) { + // pnpm reports the unreadable directory itself. + return undefined + } +} + /** The spec's form, for deciding whether a failed attempt was the registry's; a form the parser refuses has no host of its own. */ function parsedForRegistry(spec: string): ParsedInstallSpec { try { @@ -184,6 +218,11 @@ export class PluginManager extends TypertRemoteService { }) /** Management bundles remain protected if their files become unreadable. */ private readonly managementBundles = new Set() + /** + * The registry each package was last installed from by this manager in this process. pnpm verifies lockfile tarball + * URLs against its default registry unless they belong to a named registry, so a later removal names it again. + */ + private readonly installedFrom = new Map() private readonly ownerEntryId: string | undefined private readonly packageOperations = new Set>() private readonly profile: ProfileContext @@ -242,7 +281,7 @@ export class PluginManager extends TypertRemoteService { setVersionExemption(packageVersion: string, runtimeVersion: string, enabled: boolean, acceptRisk?: boolean): Promise { return this.change(result => this.configure(async () => { await setProfileVersionExemption(this.profile.dir, packageVersion, runtimeVersion, enabled, acceptRisk === true) - result.warnings = await this.reload() + Object.assign(result, await this.reload()) }), { stage: 'enable', target: packageVersion, enabled }, 'bundle') } @@ -277,7 +316,7 @@ export class PluginManager extends TypertRemoteService { listBundles(): Promise { const manifest = readProfileManifest('dsh', this.profile.dir) const exemptions = readProfileVersionExemptions(this.profile.dir) - const selected = manifest.dsh?.profile?.bundles ?? [] + const selected = bundleSelection(manifest) const recorded = manifest.dependencies ?? {} const dependencies = Object.keys(recorded) const installation = JSON.parse(readFileSync(this.profile.installAnchor, 'utf8')) as InstallationManifest @@ -378,7 +417,7 @@ export class PluginManager extends TypertRemoteService { const manifest = readProfileManifest('dsh', this.profile.dir) const installation = JSON.parse(readFileSync(this.profile.installAnchor, 'utf8')) as InstallationManifest const known = new Set([ - ...manifest.dsh?.profile?.bundles ?? [], ...Object.keys(manifest.dependencies ?? {}), ...Object.keys(installation.dependencies ?? {}), + ...bundleSelection(manifest), ...Object.keys(manifest.dependencies ?? {}), ...Object.keys(installation.dependencies ?? {}), ]) const plan = registryPlan(options?.registry, await this.registries()) const registry = plan[0] as Registry @@ -456,7 +495,7 @@ export class PluginManager extends TypertRemoteService { if (row === undefined) throw new ManagementFailure('unknown-plugin') if (row.readOnlyReason !== undefined) throw new ManagementFailure(row.readOnlyReason) await writePluginEnabled(this.profile, row.patchId, row.moduleName, enabled) - result.warnings = await this.reload(enabled ? [row.patchId] : []) + Object.assign(result, await this.reload(enabled ? [row.patchId] : [])) const current = (await this.listPlugins()).find(item => item.entryId === id) return current?.enabled !== enabled && this.ownerContext.get('hmr') !== undefined ? 'overridden' : undefined }), { stage: 'enable', target: id, enabled }, 'plugin') @@ -471,9 +510,7 @@ export class PluginManager extends TypertRemoteService { setBundleEnabled(name: string, enabled: boolean): Promise { return this.change(result => this.configure(async () => { await this.selectBundle(name, enabled) - if (enabled) await this.refreshPackages() - result.warnings = await this.reload(enabled ? this.bundleRows(name).map(row => row.id) : []) - if (!enabled && this.ownerContext.get('hmr') !== undefined) await this.refreshPackages() + Object.assign(result, enabled ? await this.publishAndReload(this.bundleRows(name).map(row => row.id)) : await this.reload()) }), { stage: 'enable', target: name, enabled }, 'bundle') } @@ -500,12 +537,17 @@ export class PluginManager extends TypertRemoteService { const result = this.change(async (result) => { if (spec.trim() === '' || spec.startsWith('-')) throw new ManagementFailure('invalid-spec') if (stopped()) throw new InstallCancelledError() + const before = readProfileManifest('dsh', this.profile.dir).dependencies ?? {} + // With HMR, an installed package is removed before another version is installed, so its plugins stop first. + const known = specPackageName(spec, before, this.profile.dir) + if (this.ownerContext.get('hmr') !== undefined && known !== undefined && Object.hasOwn(before, known)) { + throw new ManagementFailure('already-installed') + } if (options?.approvedBuilds !== undefined) { await approveBuilds(this.profile.dir, options.approvedBuilds) result.approvedBuilds = options.approvedBuilds } const files = await this.readRestoredFiles() - const before = readProfileManifest('dsh', this.profile.dir).dependencies ?? {} let name: string let version: string | undefined try { @@ -528,11 +570,13 @@ export class PluginManager extends TypertRemoteService { // The last run is the result's; the registries asked stay listed whatever the outcome. const plan = registryPlan(options?.registry, await this.registries()) let run: PackageResult | undefined + let used: Registry = null for (const [index, registry] of plan.entries()) { if (index > 0) await this.restoreFiles(files) // A stop that landed while the files went back, or before the first run, starts no run with a dead signal. if (stopped()) throw new InstallCancelledError() result.registries.push(registry) + used = registry announce('installing', { registry, index: index + 1, total: plan.length }) run = await this.runPnpm(['add', spec, ...options?.saveExact === true ? ['--save-exact'] : [], ...registryArguments(registry)], control.abort.signal, requestId) result.packageResult = run @@ -566,11 +610,10 @@ export class PluginManager extends TypertRemoteService { } const after = readProfileManifest('dsh', this.profile.dir).dependencies ?? {} const installed = Object.keys(after).filter(name => before[name] !== after[name]) - // Registry retries can retain the saved range after a partial installation. + // Without HMR a repeated installation can leave the saved dependency as it was. if (installed.length === 0) { - const parsed = parsedForRegistry(spec) - const localName = parsed.kind === 'path' ? readProfileManifest('dsh', parsed.path).name : undefined - installed.push(...Object.keys(after).filter(name => name === localName || spec === name || spec.startsWith(`${name}@`))) + const local = specPackageName(spec, after, this.profile.dir) + if (local !== undefined && Object.hasOwn(after, local)) installed.push(local) } const target = installed[0] if (installed.length !== 1 || target === undefined) throw new ManagementFailure('ambiguous-install') @@ -582,6 +625,7 @@ export class PluginManager extends TypertRemoteService { if (compatibility !== undefined && !compatibility.exempted) throw new ManagementFailure('incompatible-version', [incompatiblePlugin(compatibility)]) for (const file of bundlePatchPaths(dir, manifest.dsh.bundle)) loadOverlayPatches('dsh', file) version = manifest.version + this.installedFrom.set(name, used) } catch (error) { // pnpm has exited by now, so the files it rewrote go back as they were. await this.restoreFiles(files) @@ -595,9 +639,11 @@ export class PluginManager extends TypertRemoteService { result.stage = 'enable' return this.configure(async () => { if (options?.enabled !== false) await this.selectBundle(name, true) - if (Object.hasOwn(before, name)) return 'restart-required' - await this.refreshPackages() - if (options?.enabled !== false) result.warnings = await this.reload() + // Without HMR the replaced package's plugins keep running until the restart the result already reports. + if (Object.hasOwn(before, name) && this.ownerContext.get('hmr') === undefined) return + // A selected bundle's own rows must start. + const selected = bundleSelection(readProfileManifest('dsh', this.profile.dir)).includes(name) + Object.assign(result, await this.publishAndReload(selected ? this.bundleRows(name).map(row => row.id) : [])) }) }, { stage: 'install', target: spec, enabled: options?.enabled !== false }, 'install') control.result = result @@ -650,7 +696,7 @@ export class PluginManager extends TypertRemoteService { const contributions = bundle.error === undefined ? this.bundleRows(name) : [] if (bundle.enabled) { await this.selectBundle(name, false) - result.warnings = await this.reload() + Object.assign(result, await this.reload()) } if ([...this.ctx.loader.entries()].some(entry => entry.fiber?.uid != null && contributions.some(row => row.id === entry.options.id && row.name === entry.options.name))) { @@ -659,11 +705,15 @@ export class PluginManager extends TypertRemoteService { return bundle.installed }) if (!installed) return - result.packageResult = await this.runPnpm(['remove', name]) + // `pnpm remove` takes no `--registry` option; the generic configuration flag sets the same setting. A package + // installed before this process started is removed with the registry an installation asks first. + const registry = this.installedFrom.has(name) ? this.installedFrom.get(name) : this.configuredRegistries.registry + result.packageResult = await this.runPnpm(['remove', name, ...typeof registry === 'string' ? [`--config.registry=${registry}`] : []]) if (result.packageResult.exitCode !== 0 || result.packageResult.timedOut === true) { throw new Error(result.packageResult.output) } - await this.configure(() => this.refreshPackages()) + this.installedFrom.delete(name) + Object.assign(result, await this.configure(() => this.publishAndReload())) }, { stage: 'remove', target: name }, 'remove') } @@ -757,7 +807,7 @@ export class PluginManager extends TypertRemoteService { private async selectBundle(name: string, enabled: boolean): Promise { const manifest = readProfileManifest('dsh', this.profile.dir) - const previous = manifest.dsh?.profile?.bundles ?? [] + const previous = bundleSelection(manifest) if (enabled || !previous.includes(name)) { const metadata = bundleManifest(name, this.profile.dir, this.profile.installAnchor) if (metadata === undefined) throw new ManagementFailure('not-bundle') @@ -802,18 +852,47 @@ export class PluginManager extends TypertRemoteService { return hmr === undefined ? apply() : hmr.runExclusive(apply) } + /** + * Publish the profile's runtime resolution without HMR, unless a deselected startup bundle still runs and needs the + * current table. A publication that needs a restart waits for the restart the result already reports. + */ private async refreshPackages(): Promise { - if (this.ownerContext.get('hmr') === undefined) { - const selected = readProfileManifest('dsh', this.profile.dir).dsh?.profile?.bundles ?? [] - // Deselected startup bundles still run without HMR and need the existing package table. - if (this.profile.startedBundles.some(name => !selected.includes(name))) return + const selected = bundleSelection(readProfileManifest('dsh', this.profile.dir)) + if (this.profile.startedBundles.some(name => !selected.includes(name))) return + try { + const packages = this.ownerContext.get('pluginPackages') + // A dropped mapping's bundle is not running: the guard above keeps the table while a deselected startup bundle runs. + if (packages?.resolution !== undefined) await packages.refresh() + } catch (error) { + if (!(error instanceof ResolutionRestartRequired)) throw error } - await this.ownerContext.get('pluginPackages')?.refresh() } - private async reload(requiredIds: readonly string[] = []): Promise { - if (this.ownerContext.get('hmr') === undefined) return [] - return reconcileProfilePatches(this.ownerContext.root, readProfilePatches('dsh', this.profile), 'dsh', requiredIds) + /** + * Apply a package change: without HMR, publish the runtime resolution when that is safe, then {@link reload}. + * @param requiredIds Entry ids that must start. + * @returns The reload's outcome. + */ + private async publishAndReload(requiredIds: readonly string[] = []): Promise { + if (this.ownerContext.get('hmr') === undefined) await this.refreshPackages() + return this.reload(requiredIds) + } + + /** + * Apply the saved profile; with HMR it recomposes live, including the runtime resolution. + * @param requiredIds Entry ids that must start. + * @returns Warnings about entries that were already inactive, and why the change waits for a restart, if it does. + */ + private async reload(requiredIds: readonly string[] = []): Promise { + const hmr = this.ownerContext.get('hmr') + if (hmr === undefined) { + // Startup-only profiles recompose at restart; the saved patches still have to compose. + composeEntries([readProfilePatches('dsh', this.profile)]) + return { warnings: [] } + } + const { warnings, pending, restartReason } = await hmr.reconcileProfile(requiredIds) + const restart = restartReason ?? pending[0]?.reason + return restart === undefined ? { warnings } : { warnings, restartReason: restart } } private async change( @@ -825,9 +904,12 @@ export class PluginManager extends TypertRemoteService { this.abort.signal.throwIfAborted() const before = this.diskState() const result: ChangeResult = { ...request, changed: false, - application: this.ownerContext.get('hmr') !== undefined ? 'applied' : 'restart-required' } + ...this.ownerContext.get('hmr') !== undefined ? { application: 'applied' } : { application: 'restart-required', restartReason: 'no-hmr' } } try { - result.application = await operation(result) ?? result.application + const outcome = await operation(result) + if (outcome !== undefined) result.application = outcome + // A reload that waits for a restart leaves the change saved but not applied. + else if (result.application === 'applied' && result.restartReason !== undefined) result.application = 'restart-required' } catch (error) { if (error instanceof InstallCancelledError) { result.application = 'cancelled' @@ -836,6 +918,7 @@ export class PluginManager extends TypertRemoteService { result.error = managementError(error) } } + if (result.application !== 'restart-required') delete result.restartReason result.changed = before !== this.diskState() this.ownerContext.emit('plugin-manager/changed', { reason }) return result diff --git a/packages/boot/plugin-manager/src/tools.ts b/packages/boot/plugin-manager/src/tools.ts index 29c58e898d..553f73572c 100644 --- a/packages/boot/plugin-manager/src/tools.ts +++ b/packages/boot/plugin-manager/src/tools.ts @@ -5,10 +5,76 @@ import type {} from './index.ts' import type {} from '@deepseek-ai/dsh-sandbox-policy' import type {} from '@deepseek-ai/dsh-user-approval' import { approveEscalation } from '@deepseek-ai/dsh-sandbox' -import type { PluginEntryId } from './types.ts' +import type { ChangeResult, ManagementError, PluginEntryId, PluginInstallFailureKind, RestartReason } from './types.ts' import { defineTool } from '@deepseek-ai/dsh-tools' import { getDshRuntimeVersion } from '@deepseek-ai/dsh-app-boot' +/** What the model does next for each refusal the manager reports. */ +const ERROR_STEPS: Record = { + 'management-required': 'This entry belongs to plugin management itself and cannot be changed.', + 'unaddressable': 'This entry has no unique profile patch target; tell the user to edit the profile patch file directly.', + 'unknown-plugin': 'Call list_plugins and retry with an exact entryId.', + 'invalid-spec': 'Retry with an npm package name, a git URL, a tarball URL, or an absolute local directory.', + 'already-installed': 'The package is already installed; to install another version, call remove_bundle first, then install_bundle again.', + 'ambiguous-install': 'The dependency change named no single package; call list_bundles to see what was installed.', + 'not-bundle': 'The package declares no dsh.bundle and cannot be managed as a plugin; install a DSH plugin bundle instead.', + 'not-removable': 'Call list_bundles; only bundles marked removable can be removed.', + 'stop-profile': 'This profile runs without HMR; tell the user to stop DSH and run `dsh plugin --profile remove `.', + 'bundle-in-use': 'Call list_plugins, disable the entries from other layers that still use this bundle, then retry.', + 'stale-approval': 'Call install_bundle again without approvedBuilds to read the current pendingBuilds.', + 'incompatible-version': 'Install a version compatible with the running DSH. A version exemption needs the user\'s explicit acceptance of crash and data-loss risk.', + 'operation-error': 'Report the diagnostic to the user; retry only after its cause is addressed.', +} + +/** What the model does next for each package-manager failure. */ +const FAILURE_STEPS: Partial> = { + 'pnpm-missing': 'pnpm is not available to DSH; tell the user to install pnpm.', + 'timeout': 'Retry, or retry with another registry.', + 'not-found': 'Check the package name and version with the user, then retry.', + 'no-matching-version': 'Retry with a published version.', + 'network': 'Retry later or pass another registry.', + 'disk-full': 'Tell the user to free disk space, then retry.', + 'permission': 'Tell the user DSH cannot write the profile directory; retry after they fix its permissions.', + 'build-blocked': 'Ask the user whether to allow the install scripts of the packages in pendingBuilds; if they approve, retry with approvedBuilds.', +} + +/** Why a change waits for a restart, as the model tells the user. */ +const RESTART_STEPS: Record = { + 'no-hmr': 'this profile applies changes only at startup', + 'package-moved': 'an installed package now resolves to another directory, or the change replaces a package the DSH installation provides; remove a package before installing it from another location', + 'host-module': 'the change reaches modules DSH itself loaded', + 'unmanaged-plugin': 'the change reaches a plugin running outside the plugin list', + 'module-comparison': 'DSH could not compare loaded plugins with their files', +} + +/** + * The model-facing result of a change: the manager's result plus the next step its outcome calls for. + * @param result The manager's result. + * @returns The result with `nextStep` when it is not plainly applied. + */ +function withNextStep(result: ChangeResult): ChangeResult & { nextStep?: string } { + switch (result.application) { + case 'applied': + return result + case 'cancelled': + return { ...result, nextStep: 'The installation was cancelled and its files restored; nothing changed.' } + case 'overridden': + return { ...result, nextStep: 'Saved, but a higher-priority layer such as the agent preset overrides it; tell the user it is not in effect.' } + case 'restart-required': { + const reason = result.restartReason === undefined ? '' : `: ${RESTART_STEPS[result.restartReason]}` + return { ...result, nextStep: `Tell the user to restart DSH to apply this change${reason}.` } + } + case 'failed': { + const code = result.error?.code ?? 'operation-error' + // A refusal code is more specific than the package manager's failure kind. + const kind = code === 'operation-error' ? result.packageResult?.kind : undefined + return { ...result, nextStep: (kind === undefined ? undefined : FAILURE_STEPS[kind]) ?? ERROR_STEPS[code] } + } + /* v8 ignore next -- ChangeResult.application is a closed union */ + default: return assertNever(result.application) + } +} + /** Required services for the management tool. */ export const inject = ['tools', 'pluginManager', 'sandboxPolicy'] @@ -18,7 +84,7 @@ export const inject = ['tools', 'pluginManager', 'sandboxPolicy'] export function apply(ctx: Context): void { ctx.tools.register(defineTool({ name: 'plugin_manager', - description: 'List plugins or bundles in the current profile, enable or disable them, install a bundle, or remove an installed bundle. Every action requires danger-full-access permission or approval for this call. Approval does not change the session permission mode. Changes affect every session in this profile. List first to obtain exact identifiers. Package installation can execute allowed build scripts. Live profiles apply changes immediately; startup profiles require restart. Incompatible DSH peer dependencies block installation and activation. Version exemptions risk crashes and data loss: warn the user and obtain explicit permission for the exact plugin and runtime versions before granting one.', + description: 'List plugins or bundles in the current profile, enable or disable them, install a bundle, or remove an installed bundle. Every action requires danger-full-access permission or approval for this call. Approval does not change the session permission mode. Changes affect every session in this profile. List first to obtain exact identifiers. Package installation can execute allowed build scripts. Live profiles apply changes immediately, including upgrades of installed packages; a change result that is not applied carries nextStep, which says what to do or tell the user. Incompatible DSH peer dependencies block installation and activation. Version exemptions risk crashes and data loss: warn the user and obtain explicit permission for the exact plugin and runtime versions before granting one.', parameters: { action: { type: 'string', required: true, enum: ['list_plugins', 'list_bundles', 'set_plugin', 'set_bundle', 'install_bundle', 'remove_bundle', 'list_version_exemptions', 'set_version_exemption'], description: 'Management operation.' }, target: { type: 'string', description: 'Plugin entry id, bundle package name, or installation spec, according to action. For an Official catalog entry, pass list_bundles installTarget.spec to install_bundle.' }, @@ -50,7 +116,8 @@ export function apply(ctx: Context): void { if (args.target === undefined || args.runtimeVersion === undefined || args.enabled === undefined) { throw new Error('target package-name@version, runtimeVersion, and enabled are required') } - return JSON.stringify(await manager.setVersionExemption(args.target, args.runtimeVersion, args.enabled, args.acceptRisk)) + return JSON.stringify(withNextStep( + await manager.setVersionExemption(args.target, args.runtimeVersion, args.enabled, args.acceptRisk))) case 'list_plugins': case 'list_bundles': { const offset = args.offset ?? 0 @@ -69,20 +136,20 @@ export function apply(ctx: Context): void { case 'set_plugin': case 'set_bundle': { if (args.target === undefined || args.enabled === undefined) throw new Error('target and enabled are required') - return JSON.stringify(await (args.action === 'set_plugin' + return JSON.stringify(withNextStep(await (args.action === 'set_plugin' ? manager.setPluginEnabled(args.target as PluginEntryId, args.enabled) - : manager.setBundleEnabled(args.target, args.enabled))) + : manager.setBundleEnabled(args.target, args.enabled)))) } case 'install_bundle': if (args.target === undefined) throw new Error('target package spec is required') - return JSON.stringify(await manager.installBundle(args.target, { + return JSON.stringify(withNextStep(await manager.installBundle(args.target, { ...args.enabled === undefined ? {} : { enabled: args.enabled }, ...args.approvedBuilds === undefined ? {} : { approvedBuilds: args.approvedBuilds }, ...args.registry === undefined ? {} : { registry: args.registry }, - })) + }))) case 'remove_bundle': if (args.target === undefined) throw new Error('target bundle name is required') - return JSON.stringify(await manager.removeBundle(args.target)) + return JSON.stringify(withNextStep(await manager.removeBundle(args.target))) /* v8 ignore next -- tool JSON validation rejects actions outside the declared enum */ default: return assertNever(args.action) } diff --git a/packages/boot/plugin-manager/src/types.ts b/packages/boot/plugin-manager/src/types.ts index 5c780a2575..15e808e198 100644 --- a/packages/boot/plugin-manager/src/types.ts +++ b/packages/boot/plugin-manager/src/types.ts @@ -19,12 +19,21 @@ export interface IncompatiblePlugin { /** Localizable management failure and optional external diagnostic. */ export interface ManagementError { - code: ReadOnlyReason | 'unknown-plugin' | 'invalid-spec' | 'ambiguous-install' | 'not-bundle' | 'not-removable' | 'stop-profile' | 'bundle-in-use' | 'stale-approval' | 'incompatible-version' | 'operation-error' + code: ReadOnlyReason | 'unknown-plugin' | 'invalid-spec' | 'already-installed' | 'ambiguous-install' | 'not-bundle' | 'not-removable' | 'stop-profile' | 'bundle-in-use' | 'stale-approval' | 'incompatible-version' | 'operation-error' diagnostic?: string /** Present with `incompatible-version`: the packages the running DSH version rejects. */ incompatible?: IncompatiblePlugin[] } +/** + * Why a change waits for the next start: `no-hmr` profiles apply changes only at startup; `package-moved` changes map an + * installed package to another directory without removing it first, or change or locally override a package the DSH + * installation provides; `host-module` changes reach + * modules the running process itself imports; `unmanaged-plugin` changes reach a plugin running outside a Loader entry; + * `module-comparison` means loaded modules could not be compared with disk. + */ +export type RestartReason = 'no-hmr' | 'package-moved' | 'host-module' | 'unmanaged-plugin' | 'module-comparison' + /** One running-profile entry and its persistent control availability. */ export type PluginInfo = PluginInventoryEntry & ( | { patchId: string; readOnlyReason?: never } @@ -123,6 +132,8 @@ export interface ChangeResult { changed: boolean /** `cancelled` is an installation the caller stopped, its files restored. */ application: 'applied' | 'restart-required' | 'overridden' | 'failed' | 'cancelled' + /** Present with `restart-required`: why the change takes effect only at the next start. */ + restartReason?: RestartReason /** Last attempted step; successful installation can proceed to enablement. */ stage: 'install' | 'enable' | 'remove' target: string diff --git a/packages/boot/plugin-manager/tests/manager.spec.ts b/packages/boot/plugin-manager/tests/manager.spec.ts index 9ccccb7608..0600956068 100644 --- a/packages/boot/plugin-manager/tests/manager.spec.ts +++ b/packages/boot/plugin-manager/tests/manager.spec.ts @@ -797,14 +797,23 @@ it('restores the manifest when the package pnpm added declares no bundle', async expect((await manager.listBundles()).some(row => row.name === 'plain')).toBe(false) }) -it('never removes an existing dependency after installation validation fails', async () => { +it('refuses to install over an installed package, by name, version or local directory, without running pnpm', async () => { const { manager, dir } = await fixture() - writeFileSync(join(dir, 'node_modules', 'extra', 'package.json'), '{"name":"extra"}') const install = vi.spyOn(operations, 'runProfilePnpm').mockResolvedValue({ exitCode: 0, output: '', truncated: false, logPath: '/operation.log' }) onTestFinished(() => { install.mockRestore() }) - const result = await manager.installBundle('extra') - expect(result).toMatchObject({ application: 'failed', stage: 'install', error: { code: 'not-bundle' } }) - expect(install).toHaveBeenCalledOnce() + for (const spec of ['extra', 'extra@2', join(dir, 'node_modules', 'extra'), `link:${join(dir, 'node_modules', 'extra')}`]) { + expect(await manager.installBundle(spec)).toMatchObject({ application: 'failed', stage: 'install', changed: false, error: { code: 'already-installed' } }) + } + // A tarball saved as a path relative to the profile, and a refused install's script approvals, write nothing. + const manifest = readProfileManifest('test', dir) + writeFileSync(join(dir, 'package.json'), JSON.stringify({ ...manifest, dependencies: { extra: 'file:../extra-1.0.0.tgz' } })) + const workspace = join(dir, 'pnpm-workspace.yaml') + const settings = existsSync(workspace) ? readFileSync(workspace, 'utf8') : undefined + expect(await manager.installBundle(join(dir, '..', 'extra-1.0.0.tgz'), { approvedBuilds: ['native-provider'] })) + .toMatchObject({ application: 'failed', changed: false, error: { code: 'already-installed' } }) + expect(existsSync(workspace) ? readFileSync(workspace, 'utf8') : undefined).toBe(settings) + writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest)) + expect(install).not.toHaveBeenCalled() expect(readProfileManifest('test', dir).dependencies).toEqual({ extra: '1.0.0' }) }) @@ -855,12 +864,18 @@ it('returns unchanged failures as warnings while toggling and removing another b expect(remove).toHaveBeenCalledOnce() }) -it('reports repeated installs as requiring restart and ambiguous package changes as failures', async () => { +it('recognizes a repeated installation without HMR that leaves the saved dependency as it was', async () => { + const { manager, dir } = await fixture('startup') + const install = vi.spyOn(operations, 'runProfilePnpm').mockResolvedValue({ exitCode: 0, output: '', truncated: false, logPath: join(dir, 'pnpm.log') }) + onTestFinished(() => { install.mockRestore() }) + expect(await manager.installBundle(`link:${join(dir, 'node_modules', 'extra')}`, { enabled: false })) + .toMatchObject({ changed: false, application: 'restart-required', bundle: 'extra', enabled: false }) +}) + +it('reports ambiguous package changes as failures', async () => { const { manager, dir } = await fixture() const install = vi.spyOn(operations, 'runProfilePnpm').mockResolvedValue({ exitCode: 0, output: '', truncated: false, logPath: join(dir, 'pnpm.log') }) onTestFinished(() => { install.mockRestore() }) - expect(await manager.installBundle('extra')).toMatchObject({ changed: false, application: 'restart-required' }) - expect(await manager.installBundle('extra@1')).toMatchObject({ changed: false, application: 'restart-required' }) expect(await manager.installBundle('extra-long@1')).toMatchObject({ changed: false, application: 'failed', error: { code: 'ambiguous-install' } }) install.mockImplementationOnce(async () => { writeFileSync(join(dir, 'package.json'), '{}') @@ -870,6 +885,41 @@ it('reports repeated installs as requiring restart and ambiguous package changes expect(readProfileManifest('test', dir).dependencies).toEqual({ extra: '1.0.0' }) }) +it('removes a package with the registry it was installed from, so pnpm verifies its lockfile entries there', async () => { + const { manager, dir, bundle } = await fixture() + const corp = 'https://npm.corp.example/' + const pnpm = vi.spyOn(operations, 'runProfilePnpm').mockImplementation(async (_context, args) => { + const name = String(args[1]) + const manifest = readProfileManifest('test', dir) + if (args[0] === 'add') { + bundle(name, [{ id: name, name: './plugin.mjs', config: { service: name } }]) + manifest.dependencies = { ...manifest.dependencies, [name]: '1.0.0' } + } else { + delete manifest.dependencies?.[name] + } + writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest)) + return { exitCode: 0, output: '', truncated: false, logPath: join(dir, 'pnpm.log') } + }) + onTestFinished(() => { pnpm.mockRestore() }) + expect(await manager.installBundle('mirrored', { enabled: false, registry: corp })).toMatchObject({ application: 'applied' }) + expect(await manager.removeBundle('mirrored')).toMatchObject({ application: 'applied' }) + expect(pnpm.mock.calls.at(-1)?.[1]).toEqual(['remove', 'mirrored', `--config.registry=${corp}`]) + // A failed installation records no registry, and a failed removal keeps the recorded one for the retry. + pnpm.mockResolvedValueOnce({ exitCode: 1, output: 'failed', truncated: false, logPath: join(dir, 'pnpm.log') }) + expect(await manager.installBundle('failing', { enabled: false, registry: corp })).toMatchObject({ application: 'failed' }) + expect(await manager.installBundle('mirrored', { enabled: false, registry: corp })).toMatchObject({ application: 'applied' }) + pnpm.mockResolvedValueOnce({ exitCode: 1, output: 'remove failed', truncated: false, logPath: join(dir, 'pnpm.log') }) + expect(await manager.removeBundle('mirrored')).toMatchObject({ application: 'failed' }) + expect(await manager.removeBundle('mirrored')).toMatchObject({ application: 'applied' }) + expect(pnpm.mock.calls.at(-1)?.[1]).toEqual(['remove', 'mirrored', `--config.registry=${corp}`]) + // A package this manager did not install is removed with the registry an installation asks first. + bundle('mirrored', []) + const manifest = readProfileManifest('test', dir) + writeFileSync(join(dir, 'package.json'), JSON.stringify({ ...manifest, dependencies: { ...manifest.dependencies, mirrored: '1.0.0' } })) + await manager.removeBundle('mirrored') + expect(pnpm.mock.calls.at(-1)?.[1]).toEqual(['remove', 'mirrored']) +}) + it('streams pnpm output, reports the installation phases, and names the installed bundle', async () => { const { ctx, manager, dir, bundle } = await fixture() const chunks: PluginInstallLogChunk[] = [] @@ -1527,30 +1577,6 @@ it('uses the generic exact installer and keeps an Official entry through on, off expect((await manager.listBundles()).find(item => item.name === name)).toMatchObject({ official: true, availability: 'missing', enabled: false, installed: false }) }) -it.each([true, false])('updates an Official dependency while preserving selection=%s and requiring restart', async (enabled) => { - const { manager, dir, bundle, profile } = await fixture() - const name = OFFICIAL_ON_DEMAND_CATALOG[0]!.packageName - bundle(name, [], profile.home) - const manifest = readProfileManifest('test', dir) - manifest.dependencies = { ...manifest.dependencies, [name]: '1.0.0' } - if (enabled) manifest.dsh!.profile!.bundles!.push(name) - writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest)) - const listed = (await manager.listBundles()).find(item => item.name === name) - expect(listed).toMatchObject({ version: '1.0.0', official: true, availability: 'profile', - installTarget: { spec: `${name}@${getDshRuntimeVersion()}`, version: getDshRuntimeVersion() } }) - expect(listed?.error).toBeUndefined() - const pnpm = vi.spyOn(operations, 'runProfilePnpm').mockImplementation(async () => { - const current = readProfileManifest('test', dir) - current.dependencies = { ...current.dependencies, [name]: getDshRuntimeVersion() } - writeFileSync(join(dir, 'package.json'), JSON.stringify(current)) - return { exitCode: 0, output: '', truncated: false, logPath: '/install.log' } - }) - onTestFinished(() => { pnpm.mockRestore() }) - expect(await manager.installBundle(`${name}@${getDshRuntimeVersion()}`, { enabled, saveExact: true })) - .toMatchObject({ application: 'restart-required', enabled }) - expect(readProfileManifest('test', dir).dsh?.profile?.bundles?.includes(name)).toBe(enabled) -}) - it('restores dependency and selection after an exact version is unavailable', async () => { const { manager, dir, bundle, profile } = await fixture() const name = OFFICIAL_ON_DEMAND_CATALOG[0]!.packageName @@ -1658,11 +1684,3 @@ it.each(['absent', 'bundle', 'not-bundle', 'unreadable'])('keeps a missing sourc expect((await manager.listBundles()).find(item => item.name === name)?.error?.diagnostic).toContain('restore the checkout') expect((await manager.listBundles()).find(item => item.name === name)?.installTarget).toBeUndefined() }) - -it('recognizes a repeated local link install without a dependency-spec change', async () => { - const { manager, dir } = await fixture() - const install = vi.spyOn(operations, 'runProfilePnpm').mockResolvedValue({ exitCode: 0, output: '', truncated: false, logPath: join(dir, 'pnpm.log') }) - onTestFinished(() => { install.mockRestore() }) - expect(await manager.installBundle(`link:${join(dir, 'node_modules', 'extra')}`, { enabled: false })) - .toMatchObject({ changed: false, application: 'restart-required', bundle: 'extra', enabled: false }) -}) diff --git a/packages/boot/plugin-manager/tests/package-reload.spec.ts b/packages/boot/plugin-manager/tests/package-reload.spec.ts index d83aab3be3..b5569a6229 100644 --- a/packages/boot/plugin-manager/tests/package-reload.spec.ts +++ b/packages/boot/plugin-manager/tests/package-reload.spec.ts @@ -1,14 +1,15 @@ /** GUI package operations publish profile package resolution around activation and disposal through the real Loader. */ -import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, renameSync, rmSync, symlinkSync, utimesSync, writeFileSync } from 'node:fs' import { createRequire } from 'node:module' import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' -import { FiberState, type Context } from '@deepseek-ai/cordis' +import { FiberState, type Context, type Plugin } from '@deepseek-ai/cordis' import Timer from '@deepseek-ai/cordis-plugin-timer' import { - boot, createRuntimeResolution, initProfile, loadProfileDirectory, PluginPackages, + boot, createRuntimeResolution, initProfile, loadProfileDirectory, PluginPackages, ProfileRuntimeResolution, readProfileManifest, readProfilePatches, type ProfileContext, + ResolutionRestartRequired, } from '@deepseek-ai/dsh-app-boot' import Hmr from '@deepseek-ai/dsh-hmr' import type { ChokidarOptions } from 'chokidar' @@ -160,6 +161,12 @@ export function apply(ctx) { } /** Answer each pnpm run by recording it in the trace and applying `run` to the profile files. */ +/** Remove the installed addon bundle, as an upgrade does before it installs another version. */ +async function removeAddon(manager: PluginManager, dir: string, trace: string[]): Promise { + mockPnpm(dir, trace, () => { removeFiles(dir) }) + expect(await manager.removeBundle('addon')).toMatchObject({ application: 'applied' }) +} + function mockPnpm(dir: string, trace: string[], run: (args: readonly string[]) => void, exitCode = 0) { const pnpm = vi.spyOn(operations, 'runProfilePnpm').mockImplementation(async (_context, args) => { trace.push(`pnpm:${String(args[0])}`) @@ -187,6 +194,11 @@ it('publishes a newly installed enabled bundle before its private bare plugin st expect(ctx.get('addonVersion')).toBe(1) expect(ctx.pluginPackages.packageOf('addon-plugin', parentURL)?.version).toBe('1.0.0') expect(readFileSync(evaluationPath, 'utf8')).toBe('addon:1\n') + // A failure without HMR carries no restart reason. + mockPnpm(dir, trace, () => {}, 1) + const failed = await manager.installBundle('addon@3', { enabled: true }) + expect(failed).toMatchObject({ application: 'failed' }) + expect(failed).not.toHaveProperty('restartReason') }) it('publishes a newly installed disabled bundle without evaluating its plugin', async () => { @@ -296,8 +308,9 @@ it.each(['disable', 'remove'] as const)('retains a shared dependency after %s of const result = operation === 'disable' ? await manager.setBundleEnabled('addon', false) : await manager.removeBundle('addon') expect(result).toMatchObject({ application: 'applied', changed: true }) - expect(published).toHaveBeenCalledOnce() - expect(published.mock.calls[0]?.[0].entries.find(item => item.name === 'addon-plugin')).toMatchObject({ + // A removal publishes when its plugins stopped, and again once pnpm removed the package. + expect(published).toHaveBeenCalledTimes(operation === 'disable' ? 1 : 2) + expect(published.mock.calls.at(-1)?.[0].entries.find(item => item.name === 'addon-plugin')).toMatchObject({ packageDir: sharedDir, version: '1.0.0', scope: 'profile', declarer: join(dir, 'node_modules', 'other', 'package.json'), }) expect(entry()).toBeUndefined() @@ -321,8 +334,9 @@ it('removes a running bundle after its plugin stops and then publishes', async ( application: 'applied', changed: true, packageResult: { exitCode: 0 }, }) expect(pnpm).toHaveBeenCalledOnce() - expect(published).toHaveBeenCalledOnce() - expect(trace).toEqual(['start:sibling', 'start:addon:1', 'stop:addon:1', 'pnpm:remove', 'publish']) + // The bundle's private mappings go once its plugin stopped; the package's own name goes after pnpm removed it. + expect(published).toHaveBeenCalledTimes(2) + expect(trace).toEqual(['start:sibling', 'start:addon:1', 'stop:addon:1', 'publish', 'pnpm:remove', 'publish']) expect(previous?.uid).toBeNull() expect(existsSync(bundleDir)).toBe(false) expect(ctx.pluginPackages.packageOf('addon', parentURL)).toBeUndefined() @@ -356,23 +370,183 @@ it('removes and publishes a bundle that is not running without HMR', async () => expect(ctx.get('packageReloadSibling')).toBe(sibling) }) -it.each([{ live: true }, { live: false }])('keeps the running version and publishes nothing when an installed bundle is overwritten with HMR=$live', async ({ live }) => { - const { ctx, dir, trace, published, manager, evaluationPath, entry } = await fixture({ live, installed: true }) - const previous = entry()?.fiber - expect(previous?.state).toBe(FiberState.ACTIVE) - expect(ctx.get('addonVersion')).toBe(1) - mockPnpm(dir, trace, (args) => { - expect(args.slice(0, 2)).toEqual(['add', 'addon@2']) - installFiles(dir, 2) - }) +it('refuses to overwrite an installed bundle, and installs another version once it is removed', async () => { + const { ctx, dir, trace, manager, evaluationPath, entry } = await fixture({ installed: true }) + const pnpm = mockPnpm(dir, trace, () => { installFiles(dir, 2) }) + expect(await manager.installBundle('addon@2', { enabled: true })) + .toMatchObject({ application: 'failed', changed: false, error: { code: 'already-installed' } }) + expect(pnpm).not.toHaveBeenCalled() + await removeAddon(manager, dir, trace) + mockPnpm(dir, trace, () => { installFiles(dir, 2) }) + expect(await manager.installBundle('addon@2', { enabled: true })).toMatchObject({ application: 'applied', changed: true }) + expect(trace).toEqual(['start:sibling', 'start:addon:1', 'stop:addon:1', 'publish', 'pnpm:remove', 'publish', 'pnpm:add', 'publish', 'start:addon:2']) + expect(entry()?.fiber?.state).toBe(FiberState.ACTIVE) + expect(ctx.get('addonVersion')).toBe(2) + expect(readFileSync(evaluationPath, 'utf8')).toBe('addon:1\naddon:2\n') +}) - expect(await manager.installBundle('addon@2', { enabled: true })).toMatchObject({ application: 'restart-required', changed: true }) +it('reports the load error when a reinstalled version fails to load', async () => { + const { ctx, dir, trace, manager, entry } = await fixture({ installed: true }) + await removeAddon(manager, dir, trace) + mockPnpm(dir, trace, () => { + installFiles(dir, 2) + writeFileSync(join(dir, 'node_modules', 'addon', 'node_modules', 'addon-plugin', 'plugin.mjs'), "import 'missing-lib'\n") + }) + const result = await manager.installBundle('addon@2', { enabled: true }) + expect(result).toMatchObject({ application: 'failed', changed: true, error: { code: 'operation-error', + diagnostic: expect.stringMatching(/failed to import: .*'missing-lib'/u) as string } }) + expect(entry()?.fiber?.uid).toBeFalsy() + expect(ctx.get('addonVersion')).toBeUndefined() +}) + +/** Write a linkable bundle source and return the package directory an installation spec names. */ +function linkSource(source: string, version: number): string { + writeBundle(source, version) + return join(source, 'node_modules', 'addon') +} + +/** Link the profile's addon dependency to a source directory, as a local directory install does. */ +function linkTo(dir: string, source: string, version: number): void { + writeBundle(source, version) + rmSync(join(dir, 'node_modules', 'addon'), { recursive: true, force: true }) + symlinkSync(join(source, 'node_modules', 'addon'), join(dir, 'node_modules', 'addon'), process.platform === 'win32' ? 'junction' : 'dir') + const manifest = readProfileManifest('test', dir) + manifest.dependencies = { ...manifest.dependencies, addon: `link:${source}` } + writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest)) +} + +it('starts a plugin relinked to another directory without a restart', async () => { + const { ctx, dir, trace, manager } = await fixture({ installed: true }) + // Sources outside the profiles tree are linked roots, whose targets the resolution records. + const work = mkdtempSync(join(tmpdir(), 'plugin-manager-relink-')) + onTestFinished(() => { rmSync(work, { recursive: true, force: true }) }) + await removeAddon(manager, dir, trace) + mockPnpm(dir, trace, () => { linkTo(dir, join(work, 'source-2'), 2) }) + expect(await manager.installBundle(linkSource(join(work, 'source-2'), 2), { enabled: true })).toMatchObject({ application: 'applied' }) + expect(ctx.get('addonVersion')).toBe(2) + // Removing the bundle stops its plugins and evicts the previous directory, so the next link target is published. + trace.length = 0 + await removeAddon(manager, dir, trace) + mockPnpm(dir, trace, () => { linkTo(dir, join(work, 'source-3'), 3) }) + expect(await manager.installBundle(linkSource(join(work, 'source-3'), 3), { enabled: true })).toMatchObject({ application: 'applied', changed: true }) + expect(trace).toEqual(['stop:addon:2', 'publish', 'pnpm:remove', 'publish', 'pnpm:add', 'publish', 'start:addon:3']) + expect(ctx.get('addonVersion')).toBe(3) +}) + +it('starts a registry installation of a package that was linked before', async () => { + const { ctx, dir, trace, manager } = await fixture({ installed: true }) + await removeAddon(manager, dir, trace) + mockPnpm(dir, trace, () => { linkTo(dir, join(dir, '..', 'addon-source-2'), 2) }) + expect(await manager.installBundle(linkSource(join(dir, '..', 'addon-source-2'), 2), { enabled: true })).toMatchObject({ application: 'applied' }) + trace.length = 0 + await removeAddon(manager, dir, trace) + mockPnpm(dir, trace, () => { installFiles(dir, 3) }) + expect(await manager.installBundle('addon@3', { enabled: true })).toMatchObject({ application: 'applied', changed: true }) + expect(trace).toEqual(['stop:addon:2', 'publish', 'pnpm:remove', 'publish', 'pnpm:add', 'publish', 'start:addon:3']) + expect(ctx.get('addonVersion')).toBe(3) +}) + +it('stops nothing before installing a directory without a readable manifest', async () => { + const { dir, trace, manager } = await fixture({ installed: true }) + mockPnpm(dir, trace, () => {}, 1) + trace.length = 0 + expect(await manager.installBundle(join(dir, '..', 'missing-source'))).toMatchObject({ application: 'failed' }) + expect(trace).not.toContain('stop:addon:1') +}) + +it('starts a bundle reinstalled from another link target while a plugin outside a Loader entry keeps the previous one', async () => { + const { ctx, dir, trace, manager } = await fixture({ installed: true }) + const source = join(dir, '..', 'addon-source-2') + await removeAddon(manager, dir, trace) + mockPnpm(dir, trace, () => { linkTo(dir, source, 2) }) + expect(await manager.installBundle(linkSource(source, 2), { enabled: true })).toMatchObject({ application: 'applied' }) + const extra = join(realpathSync(source), 'node_modules', 'addon', 'extra.mjs') + writeFileSync(extra, "export function apply(ctx) { ctx.get('packageReloadTrace')?.push('start:extra') }\n") + // A file written before HMR's last observation is not suspected of changing after it loaded. + utimesSync(extra, new Date(Date.now() - 3_600_000), new Date(Date.now() - 3_600_000)) + await ctx.plugin(ctx.loader.unwrapExports(await ctx.loader.import(pathToFileURL(extra).href)) as Plugin) + await removeAddon(manager, dir, trace) + trace.length = 0 + mockPnpm(dir, trace, () => { linkTo(dir, join(dir, '..', 'addon-source-3'), 3) }) + // Plugins outside a Loader entry are the caller's; the reinstalled bundle starts from the new link target. + expect(await manager.installBundle(linkSource(join(dir, '..', 'addon-source-3'), 3), { enabled: true })) + .toMatchObject({ application: 'applied', changed: true }) + expect(trace).toEqual(['pnpm:add', 'publish', 'start:addon:3']) +}) + +it('restores a disable whose publication waits for a restart and reports other publication failures', async () => { + const { ctx, trace, manager } = await fixture({ installed: true }) + // Another package's link points elsewhere since the last publication, so the next one is rejected. + const current = ctx.pluginPackages.resolution as ProfileRuntimeResolution + const next = await current.computeLatestResolution() + const relinked = vi.spyOn(ProfileRuntimeResolution.prototype, 'computeLatestResolution').mockResolvedValueOnce(new ProfileRuntimeResolution( + { installAnchor: '', home: '', profileDir: undefined }, + { profilesDir: next.profilesDir, profileDir: next.profileDir, localPackageNames: next.localPackageNames, entries: next.entries, + linkedRoots: [...next.linkedRoots, { name: 'other', realPath: '/elsewhere' }] }, + )) + onTestFinished(() => { relinked.mockRestore() }) + const packages = ctx.pluginPackages + const replace = packages.replace.bind(packages) + const blocked = vi.spyOn(packages, 'replace').mockImplementation((successor) => { + if (successor.linkedRoots.some(root => root.name === 'other')) throw new ResolutionRestartRequired('installation mapping changed') + replace(successor) + }) + onTestFinished(() => { blocked.mockRestore() }) + // The bundle's plugins stop before the publication and start again once it is rejected. + trace.length = 0 + const disabled = await manager.setBundleEnabled('addon', false) + expect(disabled).toMatchObject({ application: 'restart-required', restartReason: 'package-moved' }) + expect(trace).toEqual(['stop:addon:1', 'start:addon:1']) + blocked.mockRestore() + const latest = vi.spyOn(ProfileRuntimeResolution.prototype, 'computeLatestResolution').mockRejectedValueOnce(new Error('resolution unreadable')) + onTestFinished(() => { latest.mockRestore() }) + expect(await manager.setBundleEnabled('addon', false)) + .toMatchObject({ application: 'failed', error: { code: 'operation-error', diagnostic: 'resolution unreadable' } }) +}) + +it('requires a restart when a reinstalled plugin also runs outside a Loader entry', async () => { + const { ctx, dir, trace, manager } = await fixture({ installed: true, enabled: false }) + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) + onTestFinished(() => { warn.mockRestore() }) + const url = pathToFileURL(join(dir, 'node_modules', 'addon', 'node_modules', 'addon-plugin', 'plugin.mjs')).href + await ctx.plugin(ctx.loader.unwrapExports(await ctx.loader.import(url)) as Plugin) + // Removing the files of a plugin that keeps running outside a Loader entry waits for a restart too. + mockPnpm(dir, trace, () => { removeFiles(dir) }) + expect(await manager.removeBundle('addon')).toMatchObject({ application: 'restart-required', restartReason: 'unmanaged-plugin' }) + mockPnpm(dir, trace, () => { installFiles(dir, 2) }) + expect(await manager.installBundle('addon@2', { enabled: false })) + .toMatchObject({ application: 'restart-required', restartReason: 'unmanaged-plugin', changed: true }) + expect(ctx.get('addonVersion')).toBe(1) +}) + +it('keeps the no-HMR restart when publishing the resolution needs a restart, and fails on other publication errors', async () => { + const { ctx, dir, trace, manager } = await fixture({ live: false }) + const refresh = vi.spyOn(ctx.pluginPackages, 'refresh') + .mockRejectedValueOnce(new ResolutionRestartRequired('moved')) + .mockRejectedValueOnce(new Error('resolution unreadable')) + onTestFinished(() => { refresh.mockRestore() }) + mockPnpm(dir, trace, () => { installFiles(dir, 1) }) + expect(await manager.installBundle('addon')).toMatchObject({ application: 'restart-required', restartReason: 'no-hmr' }) + mockPnpm(dir, trace, () => { removeFiles(dir) }) + expect(await manager.removeBundle('addon')).toMatchObject({ application: 'failed', error: { diagnostic: 'resolution unreadable' } }) +}) + +it('requires a restart when a tarball whose package is known only after installation replaces an installed bundle without HMR', async () => { + const { dir, trace, manager } = await fixture({ live: false, installed: true }) + const tarball = join(dir, '..', 'addon-2.tgz') + writeFileSync(tarball, '') + mockPnpm(dir, trace, () => { installFiles(dir, 2) }) + expect(await manager.installBundle(tarball, { enabled: true })) + .toMatchObject({ application: 'restart-required', restartReason: 'no-hmr', bundle: 'addon', changed: true }) +}) + +it('installs over an installed bundle without HMR and keeps the running version until the restart', async () => { + const { ctx, dir, trace, published, manager } = await fixture({ live: false, installed: true }) + mockPnpm(dir, trace, () => { installFiles(dir, 2) }) + expect(await manager.installBundle('addon@2', { enabled: true })) + .toMatchObject({ application: 'restart-required', restartReason: 'no-hmr', changed: true }) expect(published).not.toHaveBeenCalled() - expect(trace).toEqual(['start:sibling', 'start:addon:1', 'pnpm:add']) - expect(entry()?.fiber).toBe(previous) - expect(previous?.state).toBe(FiberState.ACTIVE) expect(ctx.get('addonVersion')).toBe(1) - expect(readFileSync(evaluationPath, 'utf8')).toBe('addon:1\n') }) it('publishes nothing after installation fails', async () => { @@ -392,21 +566,26 @@ it('publishes nothing after installation fails', async () => { it.each(['install', 'remove'] as const)('keeps successful %s disk changes when resolution publication fails', async (operation) => { const { ctx, dir, trace, published, manager, parentURL, entry } = await fixture({ installed: operation === 'remove' }) - const previous = ctx.pluginPackages.packageOf('addon-plugin', parentURL) mockPnpm(dir, trace, () => { if (operation === 'install') installFiles(dir, 1); else removeFiles(dir) }) - published.mockImplementation(() => { throw new Error('publication rejected') }) + const publish = published.getMockImplementation() + // Removal publishes once its plugins stopped and again after pnpm removed the package; the second publication fails. + published.mockImplementation((successor) => { + if (operation === 'install' || trace.includes('pnpm:remove')) throw new Error('publication rejected') + publish?.(successor) + }) const result = operation === 'install' ? await manager.installBundle('addon') : await manager.removeBundle('addon') expect(result).toMatchObject({ application: 'failed', changed: true, error: { diagnostic: 'publication rejected' }, packageResult: { exitCode: 0 }, }) - expect(published).toHaveBeenCalledOnce() + expect(published).toHaveBeenCalledTimes(operation === 'install' ? 1 : 2) const manifest = readProfileManifest('test', dir) expect(manifest.dependencies).toEqual(operation === 'install' ? { addon: '1.0.0' } : {}) expect(manifest.dsh?.profile?.bundles).toEqual(operation === 'install' ? ['core', 'addon'] : ['core']) expect(existsSync(join(dir, 'node_modules', 'addon'))).toBe(operation === 'install') - expect(ctx.pluginPackages.packageOf('addon-plugin', parentURL)).toBe(previous) + // A removal publishes the bundle's private mappings away once its plugins stopped. + expect(ctx.pluginPackages.packageOf('addon-plugin', parentURL)).toBeUndefined() expect(entry()).toBeUndefined() }) @@ -455,3 +634,19 @@ it('publishes a newly installed bundle without HMR and leaves its activation to expect(ctx.get('addonVersion')).toBeUndefined() expect(existsSync(evaluationPath)).toBe(false) }) + + +it('reports restart-required when a reinstallation cannot compare the loaded module files', async () => { + const { ctx, dir, trace, manager } = await fixture({ installed: true }) + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) + onTestFinished(() => { warn.mockRestore() }) + await removeAddon(manager, dir, trace) + mockPnpm(dir, trace, () => { + installFiles(dir, 2) + writeFileSync(join(dir, 'node_modules', '@broken'), '') + }) + const result = await manager.installBundle('addon@2', { enabled: true }) + expect(result).toMatchObject({ application: 'restart-required', restartReason: 'module-comparison' }) + expect(ctx.get('addonVersion')).toBe(2) + expect(warn).toHaveBeenCalledWith(expect.objectContaining({ code: 'ENOTDIR' })) +}) diff --git a/packages/boot/plugin-manager/tests/tools.spec.ts b/packages/boot/plugin-manager/tests/tools.spec.ts index dab620bff1..c7981e2296 100644 --- a/packages/boot/plugin-manager/tests/tools.spec.ts +++ b/packages/boot/plugin-manager/tests/tools.spec.ts @@ -282,3 +282,25 @@ it('presents reads and changes distinctly and disposes its registration', async await fiber.dispose() expect(ctx.tools.get('plugin_manager')).toBeUndefined() }) + +it('tells the agent the next step for each change outcome it can act on', async () => { + const { manager, call } = await fixture() + const outcome = async (result: object): Promise => { + manager.setBundleEnabled.mockResolvedValueOnce(result as never) + return (JSON.parse(resultText(await call({ action: 'set_bundle', target: 'bundle', enabled: true }))) as { nextStep?: string }).nextStep + } + expect(await outcome({ changed: true, application: 'applied' })).toBeUndefined() + expect(await outcome({ changed: false, application: 'cancelled' })).toContain('nothing changed') + expect(await outcome({ changed: true, application: 'overridden' })).toContain('higher-priority layer') + expect(await outcome({ changed: true, application: 'restart-required', restartReason: 'host-module' })) + .toBe('Tell the user to restart DSH to apply this change: the change reaches modules DSH itself loaded.') + expect(await outcome({ changed: true, application: 'restart-required' })).toBe('Tell the user to restart DSH to apply this change.') + expect(await outcome({ changed: false, application: 'failed', packageResult: { kind: 'build-blocked' } })).toContain('approvedBuilds') + // A refusal code is more specific than the package manager's failure kind. + expect(await outcome({ changed: false, application: 'failed', packageResult: { kind: 'network' }, error: { code: 'stale-approval' } })) + .toContain('without approvedBuilds') + // A failure kind without its own step falls back to the refusal code. + expect(await outcome({ changed: false, application: 'failed', packageResult: { kind: 'unknown' }, error: { code: 'operation-error' } })) + .toContain('Report the diagnostic') + expect(await outcome({ changed: false, application: 'failed' })).toContain('Report the diagnostic') +}) diff --git a/packages/client/ui-plugin-manager/README.i18n.yaml b/packages/client/ui-plugin-manager/README.i18n.yaml index 646ea5b2d5..d210974ef2 100644 --- a/packages/client/ui-plugin-manager/README.i18n.yaml +++ b/packages/client/ui-plugin-manager/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 09388d293f9be9cb /deepseek-ai-dsh-client-ui-plugin-manager/use-this-package: - en: c0ec2417ce428285 - zh: 257122d7920d49c5 + en: 91f28554f9ccc921 + zh: 4b60a4bf90a73fcf /deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/installing-a-bundle: en: fcb2e69c8b028c0a zh: d505a075679974eb diff --git a/packages/client/ui-plugin-manager/README.md b/packages/client/ui-plugin-manager/README.md index 3d028ba6ce..77c54ad9b0 100644 --- a/packages/client/ui-plugin-manager/README.md +++ b/packages/client/ui-plugin-manager/README.md @@ -33,7 +33,7 @@ The heading moves its complete action group to another line when space is insuff Installed bundles and their plugin rows display their own title and description in the current UI language on cards and detail pages. Each field falls back from exported locale `meta` to the accessible `package.json` of a package-root plugin; subpath plugins never read one; the final title is the full package or module name, with no package description when both sources omit it. See [Plugin display metadata](../../../docs/cookbook/adding-a-package.md#plugin-display-metadata) for the author format. Bundle cards, details, and component rows display the package root's manifest `icon` or exported `./icon`, or a subpath plugin's exported `/icon`; absent or undecodable images retain the default artwork. Installation previews still use registry or manifest information. -On-demand Official cards are visible offline before installation. Turning one On opens the existing install progress dialog, installs the [target offered by the running Host](../../boot/plugin-manager/README.md#use-this-package), and selects its layer. The same registry fallback, cancellation, and script-approval controls apply. Turning Off keeps the dependency installed; Remove is separate. A different version or development link source offers **Update**; details show current and offered sources and versions. Updates preserve selection and report whether a restart is required. Missing or unreadable packages keep their recorded dependency source alongside the diagnostic. An enabled bundle can still be switched Off when its checkout target is unavailable. +On-demand Official cards are visible offline before installation. Turning one On opens the existing install progress dialog, installs the [target offered by the running Host](../../boot/plugin-manager/README.md#use-this-package), and selects its layer. The same registry fallback, cancellation, and script-approval controls apply. Turning Off keeps the dependency installed; Remove is separate. A different version or development link source offers **Update**; details show current and offered sources and versions. An update removes the installed package and installs the offered one with the same selection, reporting whether a restart is required; when the installation fails after the removal, the package stays uninstalled. Missing or unreadable packages keep their recorded dependency source alongside the diagnostic. An enabled bundle can still be switched Off when its checkout target is unavailable. The first list read shows a short group-heading placeholder and four skeleton cards, using the real cards' row, icon, and text-line spacing, with the action area left blank. They use the same opacity pulse as Session search and remain static with reduced motion. The accessible loading label and read timing are unchanged; refreshes retain the existing list, and installation and component-loading indicators keep their own states. diff --git a/packages/client/ui-plugin-manager/README.zh.md b/packages/client/ui-plugin-manager/README.zh.md index c35122327e..b228f3d99f 100644 --- a/packages/client/ui-plugin-manager/README.zh.md +++ b/packages/client/ui-plugin-manager/README.zh.md @@ -33,7 +33,7 @@ kind: "package-reference" 已安装的组合包及其插件行在卡片和详情页中,按当前界面语言显示各自的标题与描述。每个字段先读取导出的 locale `meta`,缺失时回退到包根插件可访问的 `package.json`,子路径插件从不读取;标题最终使用完整包名或模块名,两处都没有描述时不提供包描述。作者格式见[插件展示元信息](../../../docs/cookbook/adding-a-package.zh.md#plugin-display-metadata)。组合包卡片、详情和组件行显示包根的清单 `icon` 或导出的 `./icon`,或子路径插件导出的 `<子路径>/icon`;未声明或无法解码时保留默认插画。安装预览仍使用注册表或 manifest 信息。 -官方按需卡片在安装前即可离线显示。开启会打开已有的安装进度对话框,安装[当前 Host 提供的目标](../../boot/plugin-manager/README.zh.md#use-this-package)并选择配置层。安装源回退、取消和脚本审批继续使用同一组控件。关闭保留依赖;移除是独立操作。版本或开发链接来源不同会提供**更新**;详情显示当前及所提供的来源和版本。更新保留选择状态,并报告是否需要重启。文件缺失或不可读的包在诊断旁保留记录的依赖来源。已启用的 bundle 在 checkout 目标不可用时仍可关闭。 +官方按需卡片在安装前即可离线显示。开启会打开已有的安装进度对话框,安装[当前 Host 提供的目标](../../boot/plugin-manager/README.zh.md#use-this-package)并选择配置层。安装源回退、取消和脚本审批继续使用同一组控件。关闭保留依赖;移除是独立操作。版本或开发链接来源不同会提供**更新**;详情显示当前及所提供的来源和版本。更新先删除已安装的包,再以相同的选择状态安装所提供的包,并报告是否需要重启;删除之后安装失败时,该包保持未安装。文件缺失或不可读的包在诊断旁保留记录的依赖来源。已启用的 bundle 在 checkout 目标不可用时仍可关闭。 首次读取列表时,页面显示一条较短的分组标题占位和四行骨架卡片,沿用真实卡片的行、图标和文字间距,操作区域留白。骨架使用与 Session 搜索相同的透明度呼吸动效,在减少动态效果模式下保持静止。无障碍加载文案和读取时机不变;刷新时保留已有列表,安装及组件加载仍使用各自的状态提示。 diff --git a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx index 26fab6011b..bdaa5bae69 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx @@ -29,7 +29,7 @@ import { type InstallInputError, type InstallState, type InstallSubject, type PackageRow, type PackageView, type PluginManagerFace, type RegistryChoice, } from './manager-store.ts' -import { managementText, noticeText, packageText, registryText, rowText, type Translate } from './presentation.ts' +import { managementText, noticeText, packageText, registryText, restartText, rowText, type Translate } from './presentation.ts' import type { PluginPackageRef, PluginRowRef, PluginsSubject } from './slot-contract.ts' import type { ConfigPageForm } from './slot-contract.ts' import css from './PluginManagerPage.module.css' @@ -506,7 +506,8 @@ function DetailTop({ crumbLabel, crumbText, onBack, icon, actions }: { function updateAvailable(pkg: PackageView): boolean { const target = pkg.installTarget - if (!pkg.installed || pkg.availability === 'installation' || target === undefined) return false + // An update removes the installed package first, so a package that cannot be removed offers none. + if (!pkg.installed || !pkg.removable || pkg.availability === 'installation' || target === undefined) return false return pkg.version !== target.version || ((target.spec.startsWith('link:') || pkg.source?.startsWith('link:') === true) && pkg.source !== target.spec) } @@ -1309,7 +1310,7 @@ function InstallDialog({ ?

{t('installDoneNothing')}

: null} {phase === 'done' && install.restartRequired - ?

{t('installDoneRestart')}

+ ?

{restartText('installDoneRestart', install.restartReason, t)}

: null} {phase === 'done' && subject?.kind === 'registry' && subject.name !== undefined && subject.version !== undefined && install.installedVersion !== null && install.installedVersion !== subject.version diff --git a/packages/client/ui-plugin-manager/src/client/locales.ts b/packages/client/ui-plugin-manager/src/client/locales.ts index b4f3765a62..4b4f14ec22 100644 --- a/packages/client/ui-plugin-manager/src/client/locales.ts +++ b/packages/client/ui-plugin-manager/src/client/locales.ts @@ -23,7 +23,12 @@ export const zh = { chooseAddMethod: '选择添加插件方式', installExisting: '安装第三方插件', installExistingDescription: '支持 npm 包、Git 仓库和本地目录', - restartNotice: '更改将在下次启动生效', + restartNotice: '请重启 DSH 使更改生效。{reason}', + restartReasonNoHmr: '这个 profile 只在启动时应用更改。', + restartReasonPackageMoved: '已安装的插件包解析到了另一个目录,或者更改替换了 DSH 安装自带的包。从其他位置安装前请先卸载。', + restartReasonHostModule: '更改涉及 DSH 自身已加载的模块。', + restartReasonUnmanagedPlugin: '更改涉及一个不受插件列表管理的插件。', + restartReasonModuleComparison: 'DSH 无法将已加载的插件与磁盘上的文件进行比较。', overriddenNotice: '{name} 已保存,但被更高优先级的配置覆盖,当前未生效', bundlesTitle: '已安装插件', basicTitle: '内置插件配置', @@ -182,7 +187,7 @@ export const zh = { terminalSignal: '信号 {signal}', terminalNoExitCode: '未正常退出', installDoneNothing: '安装完成,没有新增依赖。', - installDoneRestart: '已安装,下次启动后加载。', + installDoneRestart: '已安装,请重启 DSH 加载它。{reason}', installDoneApproved: '已允许运行安装脚本:{names}', installDoneOtherVersion: '根据 pnpm 供应链安全策略(minimumReleaseAge),发布时间未满规定时长的版本不会被自动安装,因此本次安装的是 {installed},而非最新版本 {version}。新发布的版本尚未经过该观察期,可能存在未被发现的恶意代码或缺陷。如确需使用 {version},请先卸载当前版本,再安装 {exact} 以指定该版本。', installApprovalTitle: '需要允许安装脚本', @@ -205,6 +210,7 @@ export const zh = { reasonUnaddressable: '当前 profile 的 patch 无法唯一定位这一项', reasonUnknownPlugin: '找不到该插件', reasonInvalidSpec: '请输入有效的包名或地址', + reasonAlreadyInstalled: '这个包已经安装;要安装其他版本,请先卸载它', reasonAmbiguousInstall: '无法从依赖变更中确定安装了哪一个包', reasonNotBundle: '这个包没有声明组合包,不能作为插件管理', reasonNotRemovable: '这个包不属于当前 profile,或者是插件管理所需的组件', @@ -239,7 +245,12 @@ export const en = { chooseAddMethod: 'Choose how to add a plugin', installExisting: 'Install a third-party plugin', installExistingDescription: 'Supports npm packages, Git repositories, and local directories', - restartNotice: 'The change takes effect at the next start', + restartNotice: 'Restart DSH to apply the change. {reason}', + restartReasonNoHmr: 'This profile applies changes only at startup.', + restartReasonPackageMoved: 'An installed package now resolves to another directory, or the change replaces a package the DSH installation provides. Uninstall a package before installing it from another location.', + restartReasonHostModule: 'The change reaches modules DSH itself loaded.', + restartReasonUnmanagedPlugin: 'The change reaches a plugin running outside the plugin list.', + restartReasonModuleComparison: 'DSH could not compare the loaded plugins with their files.', overriddenNotice: '{name} was saved, but a higher-priority configuration overrides it, so it is not in effect', bundlesTitle: 'Installed plugins', basicTitle: 'Built-in plugin settings', @@ -398,7 +409,7 @@ export const en = { terminalSignal: 'signal {signal}', terminalNoExitCode: 'no exit code', installDoneNothing: 'Install finished with no new dependency.', - installDoneRestart: 'Installed; it loads at the next start.', + installDoneRestart: 'Installed; restart DSH to load it. {reason}', installDoneApproved: 'Install scripts allowed for {names}', installDoneOtherVersion: 'Under pnpm\'s supply-chain security policy (minimumReleaseAge), versions published more recently than the required age are not installed automatically, so this installation used {installed} instead of the latest version {version}. A newly published version has not completed this waiting period and may contain undetected malicious code or defects. If you need {version}, uninstall the current version and then install {exact} to request that version.', installApprovalTitle: 'Install scripts need permission', @@ -421,6 +432,7 @@ export const en = { reasonUnaddressable: 'The profile patch cannot address this one uniquely.', reasonUnknownPlugin: 'No such plugin.', reasonInvalidSpec: 'Enter a valid package name or address.', + reasonAlreadyInstalled: 'This package is already installed; uninstall it before installing another version.', reasonAmbiguousInstall: 'Which package was installed cannot be told from the dependency change.', reasonNotBundle: 'This package declares no bundle, so it cannot be managed as a plugin.', reasonNotRemovable: 'This package is not owned by the profile, or plugin management needs it.', diff --git a/packages/client/ui-plugin-manager/src/client/manager-store.ts b/packages/client/ui-plugin-manager/src/client/manager-store.ts index 9dedb29b36..2a997cf5af 100644 --- a/packages/client/ui-plugin-manager/src/client/manager-store.ts +++ b/packages/client/ui-plugin-manager/src/client/manager-store.ts @@ -25,6 +25,7 @@ import type { PluginSpecInspection, ReadOnlyReason, Registry, + RestartReason, } from '@deepseek-ai/dsh-api-remotes/client' import { normalizeRegistry, NPMMIRROR_REGISTRY, OFFICIAL_NPM_REGISTRY, REGISTRY_URL } from '@deepseek-ai/dsh-plugin-manager/registry' import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' @@ -39,7 +40,7 @@ export type FailedAction = 'enable' | 'disable' | 'uninstall' | 'rowEnable' | 'r /** What the last action left to say, shown as a toast; `seq` tells one showing from the next. */ export type ManagerNotice = - | { readonly kind: 'restart'; readonly packageName: string; readonly seq: number } + | { readonly kind: 'restart'; readonly packageName: string; readonly reason: RestartReason | undefined; readonly seq: number } | { readonly kind: 'overridden'; readonly packageName: string; readonly seq: number } | { readonly kind: 'cancelled'; readonly seq: number } | { readonly kind: 'refresh-failed'; readonly seq: number } @@ -224,6 +225,8 @@ export interface InstallState { readonly installedVersion: string | null /** Whether the finished run's bundle waits for the next start to load. */ readonly restartRequired: boolean + /** Why it waits, when the Host says. */ + readonly restartReason?: RestartReason /** * The run's failure, once one settled the dialog: the Host's refusal * `code` with its diagnostic as `reason`, or the transport's words alone; @@ -354,7 +357,7 @@ export interface PluginManagerFace { clearHighlight: () => void /** Put a bundle into, or take it out of, the profile's layer list. */ setEnabled: (packageName: string, enabled: boolean) => void - /** Install the host's catalog version while retaining the current selection. */ + /** Remove an installed package and install the host's catalog version, selected as the package was. */ update: (packageName: string) => void /** Ask before removing a package from the profile. */ uninstall: (packageName: string) => void @@ -661,7 +664,24 @@ export class PluginManagerController { }, update: (packageName) => { const pkg = this.getSnapshot().packages.find(pkg => pkg.name === packageName) - if (pkg?.installTarget !== undefined && pkg.availability !== 'installation') this.openCatalogInstall(pkg, pkg.installTarget, pkg.enabled) + const target = pkg?.installTarget + if (pkg === undefined || target === undefined || pkg.availability === 'installation') return + if (!pkg.installed) { + this.openCatalogInstall(pkg, target, pkg.enabled) + return + } + // An installed package is removed before another version is installed. Without HMR a running bundle stays + // installed, and the installation over it waits for the restart. + void this.run(packageName, { packageName, action: 'uninstall' }, async () => { + const removed = await this.ctx.remote.pluginManager.removeBundle(packageName) + if (removed.ok && removed.value.error?.code === 'stop-profile') { + this.openCatalogInstall(pkg, target, pkg.enabled) + return + } + // A failed removal throws; without HMR a removal waits for the restart, and the installation follows it. + this.applied(removed, packageName) + this.openCatalogInstall(pkg, target, pkg.enabled) + }) }, uninstall: (packageName) => { this.pendingConfirm = () => this.run(packageName, { packageName, action: 'uninstall' }, async () => { @@ -1026,6 +1046,7 @@ export class PluginManagerController { installed: result.bundle ?? null, installedVersion: result.version ?? null, restartRequired: result.application === 'restart-required', + ...result.restartReason === undefined ? {} : { restartReason: result.restartReason }, approvedBuilds: result.approvedBuilds ?? [], ...asked, }) @@ -1197,7 +1218,7 @@ export class PluginManagerController { this.patch({ notice: { kind: 'cancelled', seq: ++this.noticeSeq } }) return case 'restart-required': - this.patch({ notice: { kind: 'restart', packageName, seq: ++this.noticeSeq } }) + this.patch({ notice: { kind: 'restart', packageName, reason: result.restartReason, seq: ++this.noticeSeq } }) return case 'overridden': this.patch({ notice: { kind: 'overridden', packageName, seq: ++this.noticeSeq } }) diff --git a/packages/client/ui-plugin-manager/src/client/presentation.ts b/packages/client/ui-plugin-manager/src/client/presentation.ts index 33998a49ae..29b5d1c204 100644 --- a/packages/client/ui-plugin-manager/src/client/presentation.ts +++ b/packages/client/ui-plugin-manager/src/client/presentation.ts @@ -1,6 +1,6 @@ /** Display labels and toast sentences for global plugin management. */ -import type { IncompatiblePlugin, ManagementError, Registry } from '@deepseek-ai/dsh-api-remotes/client' +import type { IncompatiblePlugin, ManagementError, Registry, RestartReason } from '@deepseek-ai/dsh-api-remotes/client' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { PluginManagerLocaleKey } from './locales.ts' import type { FailedAction, ManagerNotice, PackageRow, PackageView, PluginManagerFace } from './manager-store.ts' @@ -50,6 +50,7 @@ const CODE_KEYS = { 'unaddressable': 'reasonUnaddressable', 'unknown-plugin': 'reasonUnknownPlugin', 'invalid-spec': 'reasonInvalidSpec', + 'already-installed': 'reasonAlreadyInstalled', 'ambiguous-install': 'reasonAmbiguousInstall', 'not-bundle': 'reasonNotBundle', 'not-removable': 'reasonNotRemovable', @@ -138,6 +139,27 @@ export function rowText( } } +/** The sentence naming why a change waits for a restart. */ +const RESTART_REASON_KEYS = { + 'no-hmr': 'restartReasonNoHmr', + 'package-moved': 'restartReasonPackageMoved', + 'host-module': 'restartReasonHostModule', + 'unmanaged-plugin': 'restartReasonUnmanagedPlugin', + 'module-comparison': 'restartReasonModuleComparison', +} satisfies Record + +/** + * A restart prompt with its reason. + * @param key - the prompt's sentence, which takes the reason as `{reason}`. + * @param reason - why the change waits for a restart. + * @param t - the manager's translate seat. + * @returns the prompt. + */ +export function restartText(key: 'restartNotice' | 'installDoneRestart', reason: RestartReason | undefined, t: Translate): string { + // A Host that names no reason still asks for the restart. + return t(key, { reason: reason === undefined ? '' : t(RESTART_REASON_KEYS[reason]) }).trimEnd() +} + /** * The sentence one notice shows. * @param notice - the last action's outcome. @@ -146,7 +168,7 @@ export function rowText( */ export function noticeText(notice: ManagerNotice, t: Translate): string { switch (notice.kind) { - case 'restart': return t('restartNotice') + case 'restart': return restartText('restartNotice', notice.reason, t) case 'overridden': return t('overriddenNotice', { name: notice.packageName }) case 'cancelled': return t('installCancelled') case 'refresh-failed': return t('refreshError') diff --git a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx index 98e7efa54e..fc2751b4bb 100644 --- a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx +++ b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx @@ -1492,9 +1492,9 @@ describe('PluginManagerPage', () => { expect(screen.queryByRole('button', { name: en.installEditAria })).toBeNull() fireEvent.click(screen.getByRole('button', { name: en.installEnableNow })) expect(actions.enableInstalled).toHaveBeenCalledTimes(1) - set({ install: { ...IDLE_INSTALL, open: true, spec: '/plugins/dsh-x', phase: 'done', subject, installed: 'dsh-x', restartRequired: true, enabling: true } }) + set({ install: { ...IDLE_INSTALL, open: true, spec: '/plugins/dsh-x', phase: 'done', subject, installed: 'dsh-x', restartRequired: true, restartReason: 'host-module', enabling: true } }) expect(screen.getByRole('button', { name: en.installEnableNow })).toHaveProperty('disabled', true) - expect(screen.getByText(en.installDoneRestart)).toBeTruthy() + expect(screen.getByText(en.installDoneRestart.replace('{reason}', en.restartReasonHostModule))).toBeTruthy() // A run that named no bundle leaves only Done. set({ install: { ...IDLE_INSTALL, open: true, spec: 'dsh-x', phase: 'done', subject: { spec: 'dsh-x', status: 'accepted', kind: 'registry', name: 'dsh-x', bundle: true, registry: null } } }) @@ -2020,8 +2020,11 @@ describe('PluginManagerPage', () => { it('words every notice as a toast that dismisses itself', () => { vi.useFakeTimers() try { - const { actions, set } = renderTab({ notice: { kind: 'restart', packageName: 'x', seq: 1 } }) - expect(screen.getByRole('alert').textContent).toContain(en.restartNotice) + const { actions, set } = renderTab({ notice: { kind: 'restart', packageName: 'x', reason: 'no-hmr', seq: 1 } }) + expect(screen.getByRole('alert').textContent).toContain(en.restartNotice.replace('{reason}', en.restartReasonNoHmr)) + // A Host that names no reason still asks for the restart. + set({ notice: { kind: 'restart', packageName: 'x', reason: undefined, seq: 4 } }) + expect(screen.getByRole('alert').textContent).toContain(en.restartNotice.replace(' {reason}', '')) set({ notice: { kind: 'overridden', packageName: 'pkg-1', seq: 2 } }) expect(screen.getByRole('alert').textContent).toContain(en.overriddenNotice.replace('{name}', 'pkg-1')) set({ notice: { kind: 'cancelled', seq: 3 } }) diff --git a/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts b/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts index 73b3c5b778..53e3b1d66c 100644 --- a/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts +++ b/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts @@ -750,9 +750,9 @@ describe('PluginManagerController', () => { controller.installProgress({ requestId, phase: 'applying' }) controller.appendLog({ requestId, jobId: 'old', argv: [], cwd: '/p', stream: 'stdout', text: 'late' }) expect(state().install).toMatchObject({ phase: 'starting', runs: [] }) - second.resolve(ok({ ...APPLIED, application: 'restart-required', bundle: 'slow' })) + second.resolve(ok({ ...APPLIED, application: 'restart-required', restartReason: 'no-hmr', bundle: 'slow' })) await vi.waitFor(() => { expect(state().install.phase).toBe('done') }) - expect(state().install).toMatchObject({ installed: 'slow', restartRequired: true }) + expect(state().install).toMatchObject({ installed: 'slow', restartRequired: true, restartReason: 'no-hmr' }) }) it.each(['too-late', 'not-running', 'offline'] as const)('keeps a stop the Host answered %s apart from a stopped run', async (status) => { @@ -1853,7 +1853,7 @@ it.each(['before', 'after'] as const)('reports one Official toggle when inventor expect(track.mock.calls.filter(([event]) => event === 'plugin_toggle')).toHaveLength(1) }) -it.each([true, false])('updates an Official entry with enabled=%s through the same exact installer', async (enabled) => { +it.each([true, false])('updates an Official entry with enabled=%s by removing it, then using the same exact installer', async (enabled) => { const installed: BundleInfo = { ...CATALOG, availability: 'profile', installed: true, version: '1.0.0', enabled, removable: true } const { controller, plugins, face, state } = bench({ listBundles: vi.fn().mockResolvedValue(ok([installed])), installBundle: vi.fn().mockResolvedValue(ok({ ...APPLIED, bundle: CATALOG.name, application: 'restart-required' })), @@ -1862,12 +1862,44 @@ it.each([true, false])('updates an Official entry with enabled=%s through the sa face.update(CATALOG.name) await vi.waitFor(() => { expect(state().install.phase).toBe('done') }) expect(plugins.inspect).not.toHaveBeenCalled() + expect(plugins.removeBundle).toHaveBeenCalledWith(CATALOG.name) + expect(plugins.removeBundle.mock.invocationCallOrder[0]).toBeLessThan(plugins.installBundle.mock.invocationCallOrder[0]!) expect(plugins.installBundle).toHaveBeenCalledWith(CATALOG.installTarget!.spec, { enabled, saveExact: true, requestId: state().install.requestId, registry: null, }) expect(state().install.restartRequired).toBe(true) }) +it('keeps an installed Official entry and installs nothing when its removal for an update fails', async () => { + const installed: BundleInfo = { ...CATALOG, availability: 'profile', installed: true, version: '1.0.0', enabled: true, removable: true } + const { controller, plugins, face, state } = bench({ listBundles: vi.fn().mockResolvedValue(ok([installed])), + removeBundle: vi.fn().mockResolvedValue(ok({ ...APPLIED, application: 'failed', error: { code: 'bundle-in-use' } })), + }) + await controller.load() + face.update(CATALOG.name) + await vi.waitFor(() => { expect(state().notice).toMatchObject({ kind: 'failed', action: 'uninstall' }) }) + expect(plugins.installBundle).not.toHaveBeenCalled() +}) + +it.each([ + ['an unreachable Host', () => refused('ERR_REMOTE', 'connection lost')], + ['a profile without HMR', () => ok({ ...APPLIED, application: 'failed', error: { code: 'stop-profile' } })], +] as const)('installs an Official update only after the removal, except over a running bundle of %s', async (kind, answer) => { + const installed: BundleInfo = { ...CATALOG, availability: 'profile', installed: true, version: '1.0.0', enabled: true, removable: true } + const { controller, plugins, face, state } = bench({ listBundles: vi.fn().mockResolvedValue(ok([installed])), + removeBundle: vi.fn().mockResolvedValue(answer()), + }) + await controller.load() + face.update(CATALOG.name) + if (kind === 'an unreachable Host') { + await vi.waitFor(() => { expect(state().notice).toMatchObject({ kind: 'failed', action: 'uninstall' }) }) + expect(plugins.installBundle).not.toHaveBeenCalled() + } else { + // Without HMR a running bundle stays installed, and the installation over it waits for the restart. + await vi.waitFor(() => { expect(plugins.installBundle).toHaveBeenCalled() }) + } +}) + it('preserves the Official exact target and selection when script approval retries an installation', async () => { const install = vi.fn().mockResolvedValueOnce(ok({ ...APPLIED, application: 'failed', stage: 'install', error: { code: 'operation-error' }, pendingBuilds: ['native-provider'], @@ -1973,6 +2005,7 @@ it.each([true, false])('replaces a same-version registry package with its source await controller.load() face.update(entry.name) await vi.waitFor(() => { expect(state().install.phase).toBe('done') }) + expect(plugins.removeBundle).toHaveBeenCalledWith(CATALOG.name) expect(plugins.installBundle).toHaveBeenCalledWith(spec, { enabled, requestId: state().install.requestId, registry: null }) expect(state().install.subject).toMatchObject({ kind: 'path', spec, selection: enabled }) expect(state().install.restartRequired).toBe(true) diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 7c990f00f9..ae9f632f95 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -1295,6 +1295,13 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [{ name: 'url', description: 'Module URL.' }], returns: 'Linked module URLs, or an empty list for an uncached module.', }, + { + signature: 'async reconcileProfile(requiredIds: readonly string[] = []): Promise', + description: 'Recompose the profile from its manifest and patch files, replacing running plugins whose loaded files below the profile directory or its linked package targets changed on disk, and publishing its runtime resolution. Changed modules are evicted and the Loader entries running them stop; app-boot\'s recomposeProfile then publishes the resolution, applies the generation, starts the stopped entries with the current files and configuration, imports entries whose import failed again, and judges activation. Afterwards the modules below the mappings the recomposition dropped are evicted, so a removed or deselected package is imported afresh when it returns. Callers run inside runExclusive.', + parameters: [{ name: 'requiredIds', description: 'Entry ids whose inactive state is reported as a failure even when it predates this call.' }], + returns: 'Inactive-entry diagnostics, the changed modules that load after a restart, and a profile-wide restart reason.', + throws: ['The patch read, publication, preparation, apply, or activation failure of the recomposition, including a replacement plugin that fails to load or start, whose previous code no longer runs.'], + }, ], }, { @@ -4882,7 +4889,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ChangeResult', - declaration: 'export interface ChangeResult {\n changed: boolean;\n application: \'applied\' | \'restart-required\' | \'overridden\' | \'failed\' | \'cancelled\';\n stage: \'install\' | \'enable\' | \'remove\';\n target: string;\n enabled?: boolean;\n error?: ManagementError;\n warnings?: string[];\n packageResult?: PackageResult;\n bundle?: string;\n version?: string;\n pendingBuilds?: string[];\n approvedBuilds?: string[];\n registries?: Registry[];\n failedAt?: \'registry\' | \'spec-host\';\n}', + declaration: 'export interface ChangeResult {\n changed: boolean;\n application: \'applied\' | \'restart-required\' | \'overridden\' | \'failed\' | \'cancelled\';\n restartReason?: RestartReason;\n stage: \'install\' | \'enable\' | \'remove\';\n target: string;\n enabled?: boolean;\n error?: ManagementError;\n warnings?: string[];\n packageResult?: PackageResult;\n bundle?: string;\n version?: string;\n pendingBuilds?: string[];\n approvedBuilds?: string[];\n registries?: Registry[];\n failedAt?: \'registry\' | \'spec-host\';\n}', }, { name: 'ClientArtifactBaseline', @@ -5914,7 +5921,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ManagementError', - declaration: 'export interface ManagementError {\n code: ReadOnlyReason | \'unknown-plugin\' | \'invalid-spec\' | \'ambiguous-install\' | \'not-bundle\' | \'not-removable\' | \'stop-profile\' | \'bundle-in-use\' | \'stale-approval\' | \'incompatible-version\' | \'operation-error\';\n diagnostic?: string;\n incompatible?: IncompatiblePlugin[];\n}', + declaration: 'export interface ManagementError {\n code: ReadOnlyReason | \'unknown-plugin\' | \'invalid-spec\' | \'already-installed\' | \'ambiguous-install\' | \'not-bundle\' | \'not-removable\' | \'stop-profile\' | \'bundle-in-use\' | \'stale-approval\' | \'incompatible-version\' | \'operation-error\';\n diagnostic?: string;\n incompatible?: IncompatiblePlugin[];\n}', }, { name: 'ManualCompactAgentContext', @@ -6180,6 +6187,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'PeerScope', declaration: 'export interface PeerScope {\n readonly id: PeerId;\n readonly ctx: Context;\n dispose(): Promise;\n}', }, + { + name: 'PendingModule', + declaration: 'export interface PendingModule {\n url: string;\n reason: \'host-module\' | \'unmanaged-plugin\' | \'module-comparison\';\n}', + }, { name: 'PermissionCatalog', declaration: 'export interface PermissionCatalog {\n options: PresetOption[];\n defaultOptions: PresetOption[];\n defaultPreset: string;\n}', @@ -6316,6 +6327,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ProfilePnpmInvocation', declaration: 'export interface ProfilePnpmInvocation {\n readonly command: string;\n readonly args: readonly string[];\n readonly env: Readonly>;\n}', }, + { + name: 'ProfileReconciliation', + declaration: 'export interface ProfileReconciliation {\n warnings: string[];\n pending: PendingModule[];\n restartReason?: \'module-comparison\' | \'package-moved\';\n}', + }, { name: 'ProjectionChangeListener', declaration: 'export type ProjectionChangeListener = (session: Session, key: Extract, value: unknown, seq: SessionSeq) => void;', @@ -6552,6 +6567,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ResolvedSubagentStartRequest', declaration: 'export interface ResolvedSubagentStartRequest extends SubagentStartRequest {\n readonly cwd: string;\n}', }, + { + name: 'RestartReason', + declaration: 'export type RestartReason = \'no-hmr\' | \'package-moved\' | \'host-module\' | \'unmanaged-plugin\' | \'module-comparison\';', + }, { name: 'RestoredSessionOptions', declaration: 'export interface RestoredSessionOptions {\n readonly seed: SessionEvent[];\n readonly meta: SessionHeader;\n readonly inheritedEventCount: SessionLogOffset;\n readonly eventState: SessionSeedEventState;\n}', diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index e3a87091e2..191478c77a 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -294,6 +294,7 @@ export const LINK_MAP: Readonly> = { WorkspaceChangesSummary: 'deliverables.md', WorkspaceFileDiff: 'deliverables.md', Reload: 'boot.md', + ProfileReconciliation: 'boot.md', PluginInfo: 'boot.md', BundleInfo: 'boot.md', ChangeResult: 'boot.md', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 15b1fe7a05..2eae1cf2fa 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -2431,6 +2431,16 @@ "doc": "docs/subsystems/worktrees.md", "symbol": "CreatedWorktree", "source": "packages/experimental/worktree/src/types.ts" + }, + { + "doc": "docs/subsystems/boot.md", + "symbol": "ProfileReconciliation", + "source": "packages/boot/hmr/src/index.ts" + }, + { + "doc": "docs/subsystems/boot.md", + "symbol": "PendingModule", + "source": "packages/boot/hmr/src/index.ts" } ] } diff --git a/snapshots/session/plugin-manager-mcp/tool-schemas.expected.json b/snapshots/session/plugin-manager-mcp/tool-schemas.expected.json index f44f52640a..dab0d64e35 100644 --- a/snapshots/session/plugin-manager-mcp/tool-schemas.expected.json +++ b/snapshots/session/plugin-manager-mcp/tool-schemas.expected.json @@ -262,7 +262,7 @@ }, { "name": "plugin_manager", - "description": "List plugins or bundles in the current profile, enable or disable them, install a bundle, or remove an installed bundle. Every action requires danger-full-access permission or approval for this call. Approval does not change the session permission mode. Changes affect every session in this profile. List first to obtain exact identifiers. Package installation can execute allowed build scripts. Live profiles apply changes immediately; startup profiles require restart. Incompatible DSH peer dependencies block installation and activation. Version exemptions risk crashes and data loss: warn the user and obtain explicit permission for the exact plugin and runtime versions before granting one.", + "description": "List plugins or bundles in the current profile, enable or disable them, install a bundle, or remove an installed bundle. Every action requires danger-full-access permission or approval for this call. Approval does not change the session permission mode. Changes affect every session in this profile. List first to obtain exact identifiers. Package installation can execute allowed build scripts. Live profiles apply changes immediately, including upgrades of installed packages; a change result that is not applied carries nextStep, which says what to do or tell the user. Incompatible DSH peer dependencies block installation and activation. Version exemptions risk crashes and data loss: warn the user and obtain explicit permission for the exact plugin and runtime versions before granting one.", "parameters": { "type": "object", "properties": { @@ -1005,7 +1005,7 @@ }, { "name": "plugin_manager", - "description": "List plugins or bundles in the current profile, enable or disable them, install a bundle, or remove an installed bundle. Every action requires danger-full-access permission or approval for this call. Approval does not change the session permission mode. Changes affect every session in this profile. List first to obtain exact identifiers. Package installation can execute allowed build scripts. Live profiles apply changes immediately; startup profiles require restart. Incompatible DSH peer dependencies block installation and activation. Version exemptions risk crashes and data loss: warn the user and obtain explicit permission for the exact plugin and runtime versions before granting one.", + "description": "List plugins or bundles in the current profile, enable or disable them, install a bundle, or remove an installed bundle. Every action requires danger-full-access permission or approval for this call. Approval does not change the session permission mode. Changes affect every session in this profile. List first to obtain exact identifiers. Package installation can execute allowed build scripts. Live profiles apply changes immediately, including upgrades of installed packages; a change result that is not applied carries nextStep, which says what to do or tell the user. Incompatible DSH peer dependencies block installation and activation. Version exemptions risk crashes and data loss: warn the user and obtain explicit permission for the exact plugin and runtime versions before granting one.", "parameters": { "type": "object", "properties": { diff --git a/snapshots/session/plugin-manager/session.v4.jsonl b/snapshots/session/plugin-manager/session.v4.jsonl index ba95460a09..bc2cd7791a 100644 --- a/snapshots/session/plugin-manager/session.v4.jsonl +++ b/snapshots/session/plugin-manager/session.v4.jsonl @@ -14,12 +14,12 @@ {"type":"session/title","data":{"title":"Use plugin_manager with action set_plugi","messageSeqs":[8],"source":{"kind":"fallback"}}} {"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to call plugin_manager twice with the given parameters, then reply \"PLUGIN_MANAGER_DONE\". Let me make the calls. The first call with enabled false, then the second with enabled true. These are dependent in sequence (toggle off, then on), so I should do them sequentially — actually, the instruction says call it again after. Let me do them one at a time to be safe, since the second depends on the first having applied.\n\nActually the user says \"Do not use other tools\" — plugin_manager is fine. Let me make the first call."},{"type":"tool-call","id":"call_00_XgwocGXnE4z5t7I5ENoS0769","name":"plugin_manager","arguments":"{\"action\": \"set_plugin\", \"target\": \"include:tool-todo\", \"enabled\": false}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash","replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"deepseek-v4-flash"},"blocks":[{"type":"reasoning","signature":"14ac483b-5df3-4d50-9aac-43c9caa07684"},{"type":"tool-call"}]}},"id":"{{message:4}}"},"usage":{"inputTokens":7164,"outputTokens":199,"cacheReadTokens":0,"cacheWriteTokens":0,"totalTokens":7363},"stream":[{"type":"chunk","time":1789475683170,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1789475683170,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," user"," wants"," me"," to"," call"," plugin","_manager"," twice"," with"," the"," given"," parameters",","," then"," reply"," \"","PL","UG","IN","_M","AN","AG","ER","_D","ONE","\"."," Let"," me"," make"," the"," calls","."," The"," first"," call"," with"," enabled"," false",","," then"," the"," second"," with"," enabled"," true","."," These"," are"," dependent"," in"," sequence"," (","toggle"," off",","," then"," on","),"," so"," I"," should"," do"," them"," sequentially"," —"," actually",","," the"," instruction"," says"," call"," it"," again"," after","."," Let"," me"," do"," them"," one"," at"," a"," time"," to"," be"," safe",","," since"," the"," second"," depends"," on"," the"," first"," having"," applied",".\n\n","Actually"," the"," user"," says"," \"","Do"," not"," use"," other"," tools","\""," —"," plugin","_manager"," is"," fine","."," Let"," me"," make"," the"," first"," call","."]},{"type":"chunk","time":1789475683171,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to call plugin_manager twice with the given parameters, then reply \"PLUGIN_MANAGER_DONE\". Let me make the calls. The first call with enabled false, then the second with enabled true. These are dependent in sequence (toggle off, then on), so I should do them sequentially — actually, the instruction says call it again after. Let me do them one at a time to be safe, since the second depends on the first having applied.\n\nActually the user says \"Do not use other tools\" — plugin_manager is fine. Let me make the first call."}}},{"type":"chunk","time":1789475683171,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1789475683171,"index":1,"dt":[],"id":"call_00_XgwocGXnE4z5t7I5ENoS0769","name":"plugin_manager","args":[""]},{"type":"tool-call-chunks","time0":1789475683171,"index":1,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"id":"call_00_XgwocGXnE4z5t7I5ENoS0769","args":["{","\"","action","\"",": ","\"","set","_","plugin","\"",", ","\"","target","\"",": ","\"","include",":","tool","-t","odo","\"",", ","\"","en","abled","\"",": ","false","}"]},{"type":"chunk","time":1789475683171,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_XgwocGXnE4z5t7I5ENoS0769","name":"plugin_manager","arguments":"{\"action\": \"set_plugin\", \"target\": \"include:tool-todo\", \"enabled\": false}"}}},{"type":"chunk","time":1789475683171,"chunk":{"type":"usage","usage":{"inputTokens":7164,"outputTokens":199,"cacheReadTokens":0,"cacheWriteTokens":0,"totalTokens":7363}}},{"type":"chunk","time":1789475683171,"chunk":{"type":"finish","reason":{"kind":"tool-calls"},"replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"deepseek-v4-flash"},"blocks":[{"type":"reasoning","signature":"14ac483b-5df3-4d50-9aac-43c9caa07684"},{"type":"tool-call"}]}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":1,"callId":"call_00_XgwocGXnE4z5t7I5ENoS0769","name":"plugin_manager","arguments":"{\"action\": \"set_plugin\", \"target\": \"include:tool-todo\", \"enabled\": false}"}} -{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"call_00_XgwocGXnE4z5t7I5ENoS0769"},"toolCallId":"call_00_XgwocGXnE4z5t7I5ENoS0769","content":[{"type":"text","text":"{\"stage\":\"enable\",\"target\":\"include:tool-todo\",\"enabled\":false,\"changed\":true,\"application\":\"restart-required\",\"warnings\":[]}"}],"isError":false,"id":"{{message:5}}"}},"sourceEventSeqs":[14],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"call_00_XgwocGXnE4z5t7I5ENoS0769"},"toolCallId":"call_00_XgwocGXnE4z5t7I5ENoS0769","content":[{"type":"text","text":"{\"stage\":\"enable\",\"target\":\"include:tool-todo\",\"enabled\":false,\"changed\":true,\"application\":\"restart-required\",\"restartReason\":\"no-hmr\",\"warnings\":[],\"nextStep\":\"Tell the user to restart DSH to apply this change: this profile applies changes only at startup.\"}"}],"isError":false,"id":"{{message:5}}"}},"sourceEventSeqs":[14],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"step/start","data":{"turn":1,"step":2}} {"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"Now the second call with enabled true."},{"type":"tool-call","id":"call_00_8FDEYUdbLsc4vBZYHKUs0380","name":"plugin_manager","arguments":"{\"action\": \"set_plugin\", \"enabled\": true, \"target\": \"include:tool-todo\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash","replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"deepseek-v4-flash"},"blocks":[{"type":"reasoning","signature":"4f20c714-9549-4b29-b0de-3e3bfb2d70e8"},{"type":"tool-call"}]}},"id":"{{message:6}}"},"usage":{"inputTokens":151,"outputTokens":85,"cacheReadTokens":7296,"cacheWriteTokens":0,"totalTokens":7532},"stream":[{"type":"chunk","time":1789475683202,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1789475683202,"index":0,"dt":[0,0,0,0,0,0,0],"texts":["Now"," the"," second"," call"," with"," enabled"," true","."]},{"type":"chunk","time":1789475683202,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Now the second call with enabled true."}}},{"type":"chunk","time":1789475683202,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1789475683202,"index":1,"dt":[],"id":"call_00_8FDEYUdbLsc4vBZYHKUs0380","name":"plugin_manager","args":[""]},{"type":"tool-call-chunks","time0":1789475683202,"index":1,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"id":"call_00_8FDEYUdbLsc4vBZYHKUs0380","args":["{","\"","action","\"",": ","\"","set","_","plugin","\"",", ","\"","en","abled","\"",": ","true",", ","\"","target","\"",": ","\"","include",":","tool","-t","odo","\"","}"]},{"type":"chunk","time":1789475683202,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_8FDEYUdbLsc4vBZYHKUs0380","name":"plugin_manager","arguments":"{\"action\": \"set_plugin\", \"enabled\": true, \"target\": \"include:tool-todo\"}"}}},{"type":"chunk","time":1789475683202,"chunk":{"type":"usage","usage":{"inputTokens":151,"outputTokens":85,"cacheReadTokens":7296,"cacheWriteTokens":0,"totalTokens":7532}}},{"type":"chunk","time":1789475683202,"chunk":{"type":"finish","reason":{"kind":"tool-calls"},"replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"deepseek-v4-flash"},"blocks":[{"type":"reasoning","signature":"4f20c714-9549-4b29-b0de-3e3bfb2d70e8"},{"type":"tool-call"}]}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":2,"callId":"call_00_8FDEYUdbLsc4vBZYHKUs0380","name":"plugin_manager","arguments":"{\"action\": \"set_plugin\", \"enabled\": true, \"target\": \"include:tool-todo\"}"}} -{"type":"tool/result","data":{"turn":1,"step":2,"message":{"role":"tool","source":{"kind":"tool","callId":"call_00_8FDEYUdbLsc4vBZYHKUs0380"},"toolCallId":"call_00_8FDEYUdbLsc4vBZYHKUs0380","content":[{"type":"text","text":"{\"stage\":\"enable\",\"target\":\"include:tool-todo\",\"enabled\":true,\"changed\":true,\"application\":\"restart-required\",\"warnings\":[]}"}],"isError":false,"id":"{{message:7}}"}},"sourceEventSeqs":[19],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":2,"message":{"role":"tool","source":{"kind":"tool","callId":"call_00_8FDEYUdbLsc4vBZYHKUs0380"},"toolCallId":"call_00_8FDEYUdbLsc4vBZYHKUs0380","content":[{"type":"text","text":"{\"stage\":\"enable\",\"target\":\"include:tool-todo\",\"enabled\":true,\"changed\":true,\"application\":\"restart-required\",\"restartReason\":\"no-hmr\",\"warnings\":[],\"nextStep\":\"Tell the user to restart DSH to apply this change: this profile applies changes only at startup.\"}"}],"isError":false,"id":"{{message:7}}"}},"sourceEventSeqs":[19],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":2}} {"type":"step/start","data":{"turn":1,"step":3}} {"type":"assistant/message","data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"reasoning","text":""},{"type":"text","text":"PLUGIN_MANAGER_DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash","replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"deepseek-v4-flash"},"blocks":[{"type":"reasoning","signature":"f082a27e-1e6a-4a30-b674-1a229370cd70"},{"type":"text"}]}},"id":"{{message:8}}"},"usage":{"inputTokens":192,"outputTokens":10,"cacheReadTokens":7424,"cacheWriteTokens":0,"totalTokens":7626},"stream":[{"type":"chunk","time":1789475683228,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"chunk","time":1789475683228,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":""}}},{"type":"chunk","time":1789475683228,"chunk":{"type":"block-start","index":1,"blockType":"text"}},{"type":"text-chunks","time0":1789475683228,"index":1,"dt":[0,0,0,0,0,0,0,0],"texts":["PL","UG","IN","_M","AN","AG","ER","_D","ONE"]},{"type":"chunk","time":1789475683228,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"PLUGIN_MANAGER_DONE"}}},{"type":"chunk","time":1789475683228,"chunk":{"type":"usage","usage":{"inputTokens":192,"outputTokens":10,"cacheReadTokens":7424,"cacheWriteTokens":0,"totalTokens":7626}}},{"type":"chunk","time":1789475683228,"chunk":{"type":"finish","reason":{"kind":"stop"},"replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"deepseek-v4-flash"},"blocks":[{"type":"reasoning","signature":"f082a27e-1e6a-4a30-b674-1a229370cd70"},{"type":"text"}]}}}]},"surfaceOp":"append"} diff --git a/snapshots/session/plugin-manager/tool-schemas.expected.json b/snapshots/session/plugin-manager/tool-schemas.expected.json index 4596dae7a1..3f145abc9c 100644 --- a/snapshots/session/plugin-manager/tool-schemas.expected.json +++ b/snapshots/session/plugin-manager/tool-schemas.expected.json @@ -262,7 +262,7 @@ }, { "name": "plugin_manager", - "description": "List plugins or bundles in the current profile, enable or disable them, install a bundle, or remove an installed bundle. Every action requires danger-full-access permission or approval for this call. Approval does not change the session permission mode. Changes affect every session in this profile. List first to obtain exact identifiers. Package installation can execute allowed build scripts. Live profiles apply changes immediately; startup profiles require restart. Incompatible DSH peer dependencies block installation and activation. Version exemptions risk crashes and data loss: warn the user and obtain explicit permission for the exact plugin and runtime versions before granting one.", + "description": "List plugins or bundles in the current profile, enable or disable them, install a bundle, or remove an installed bundle. Every action requires danger-full-access permission or approval for this call. Approval does not change the session permission mode. Changes affect every session in this profile. List first to obtain exact identifiers. Package installation can execute allowed build scripts. Live profiles apply changes immediately, including upgrades of installed packages; a change result that is not applied carries nextStep, which says what to do or tell the user. Incompatible DSH peer dependencies block installation and activation. Version exemptions risk crashes and data loss: warn the user and obtain explicit permission for the exact plugin and runtime versions before granting one.", "parameters": { "type": "object", "properties": { diff --git a/tsconfig.host.json b/tsconfig.host.json index 516bd6611e..93c52c2a4d 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -82,6 +82,7 @@ "apps/web/tests/inspector-source-build.e2e.ts", "apps/web/tests/plugin-install-cancel.e2e.ts", "apps/web/tests/plugin-install-approve.e2e.ts", + "apps/web/tests/plugin-hmr-lifecycle.e2e.ts", "apps/web/tests/plugin-install-registry.e2e.ts", "apps/web/tests/plugin-install-github.e2e.ts", "apps/web/tests/client-plugin-live.e2e.ts", From f6e4b5026cec08e6b20fad8130b825f6fd2015fb Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:44:13 +0800 Subject: [PATCH 14/35] docs(notes): record peer principals and rbac remote access --- .../2026-09-19-remote-duplex-stream.i18n.yaml | 28 +- .../2026-09-19-remote-duplex-stream.md | 42 +- .../2026-09-19-remote-duplex-stream.zh.md | 42 +- ...peer-principal-and-remote-access.i18n.yaml | 91 +++ ...-10-12-peer-principal-and-remote-access.md | 670 ++++++++++++++++++ ...-12-peer-principal-and-remote-access.zh.md | 670 ++++++++++++++++++ 6 files changed, 1491 insertions(+), 52 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md create mode 100644 .agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.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 index 0a502fbafe..5de15975be 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 @@ -30,8 +30,8 @@ en: 943208904785f7eb zh: bda1969132a16101 /agent-note-remote-duplex-stream-one-kind-of-stream-an-uplink-channel-and-an-invocation-context/decision/terms: - en: 401703adbd9cc1c2 - zh: 56d960c0b6fdff57 + en: a22cd84756773075 + zh: 49efa0c5a37d4b57 /agent-note-remote-duplex-stream-one-kind-of-stream-an-uplink-channel-and-an-invocation-context/decision/type-signatures: en: 5f9cd00890b6f4c3 zh: 600ecba7e60b63f6 @@ -42,8 +42,8 @@ en: a7180d3c194cf9d9 zh: 0dd7833e0e5d0a71 /agent-note-remote-duplex-stream-one-kind-of-stream-an-uplink-channel-and-an-invocation-context/decision/peerscope: - en: 202aa6e8e8b04bd6 - zh: b1fc897c09074a7c + en: a162af337571f56e + zh: 0ae0b9d62d2cb16e /agent-note-remote-duplex-stream-one-kind-of-stream-an-uplink-channel-and-an-invocation-context/decision/client-face: en: 9f10347a73402b8b zh: b939c7a254a8e579 @@ -75,8 +75,8 @@ en: 46a0c0b3ce1e5671 zh: 7743707de42b1d1d /agent-note-remote-duplex-stream-one-kind-of-stream-an-uplink-channel-and-an-invocation-context/decision/in-process-and-worker-carriers: - en: ec339e57dc2c0603 - zh: 3570be8c094ea24c + en: df4ae60d8403cdbb + zh: 70c6024a2a9a1e84 /agent-note-remote-duplex-stream-one-kind-of-stream-an-uplink-channel-and-an-invocation-context/decision/relation-to-events: en: a011793e5a4400ed zh: df8b20115f0348b8 @@ -102,8 +102,8 @@ en: 26dcd8bebdc8d974 zh: e74bcef7c549e70c /agent-note-remote-duplex-stream-one-kind-of-stream-an-uplink-channel-and-an-invocation-context/placement: - en: 41128746334320c7 - zh: 558a676340aae3a2 + en: ecfaff2b9b2ef021 + zh: 85b392d79364e0c3 /agent-note-remote-duplex-stream-one-kind-of-stream-an-uplink-channel-and-an-invocation-context/key-sequences: en: 130e4f9ec7296a10 zh: 2a5aa1302a8db61a @@ -126,14 +126,14 @@ en: b42ab0932f8b31b4 zh: 7fed8cb5b6e36788 /agent-note-remote-duplex-stream-one-kind-of-stream-an-uplink-channel-and-an-invocation-context/alternatives-considered: - en: 04e72789417d6e3a - zh: b57c5cc68dff45a5 + en: b0046c9f7c12a876 + zh: f3c37b466746b0fd /agent-note-remote-duplex-stream-one-kind-of-stream-an-uplink-channel-and-an-invocation-context/consequences: - en: 35326fed46e31d74 - zh: 6f47baa645a1e370 + en: dbc54ce699ef8e71 + zh: 83144f07ae8f8759 /agent-note-remote-duplex-stream-one-kind-of-stream-an-uplink-channel-and-an-invocation-context/testing: - en: 5c8188930b9d4fdb - zh: 1d94e6563cedde13 + en: 06fbeed7b82ded32 + zh: 66accb4bd2bf64a2 /agent-note-remote-duplex-stream-one-kind-of-stream-an-uplink-channel-and-an-invocation-context/deferred: en: 8368c592f30af97f zh: 2ff89acb7b3ed477 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 a8d89a9fe5..5555c46b04 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 @@ -88,7 +88,7 @@ Every Remote stream accepts a Client uplink. The method's return type `RemoteStr | 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 | +| Peer | The party one connection speaks for, one `PeerScope` carrying its principal; every browser connection shares `connection.browserPeer`, and in-process code creates its own through `connection.createPeer()` ([Peer principals](../../implemented/architecture/2026-10-12-peer-principal-and-remote-access.md)) | | Invocation context | `RemoteInvocation`: the request, receiving service, Peer, cancellation signal, and uplink entry of this call | ### Type signatures @@ -135,7 +135,7 @@ export interface RemoteInvocation { } /** Cordis service key of the receiving service. */ readonly service: string - /** The Peer that initiated the call. In-process carriers and unadmitted calls are the operator. */ + /** The Peer that initiated the call; every carrier names it explicitly. */ readonly peer: PeerScope /** Carrier cancellation: Client cancel, socket closure, uplink failure. */ readonly signal: AbortSignal @@ -180,24 +180,28 @@ The mechanism relies on two existing Cordis behaviors: /** The opaque identity of one Peer. */ export type PeerId = Branded<'PeerId'> -/** One Peer's session on this Host: opened and released by the admitting side; ctx owns connection-lifetime registrations. */ +/** One Peer's session on this Host: opened and released by its creator; ctx owns connection-lifetime registrations. */ export interface PeerScope { readonly id: PeerId readonly ctx: Context + /** Caller identity fixed at creation. */ + readonly principal: Principal + /** Aborted once the Peer is disposed. */ + readonly signal: AbortSignal 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`. +`@deepseek-ai/dsh-client-connection` implements the one Peer class, `Peer(owner, { principal })`, with `createScope(owner, peer)`, the same mechanism an Agent uses to create its own scope: the Peer object is the ScopeKey, `peer.ctx` carries connection-lifetime effects, `peer.signal` aborts when the scope ends, and `dispose()` quiets the fiber. Connection creates `connection.browserPeer` with the owner principal when the service applies; [Peer principals and method-level Remote access control](../../implemented/architecture/2026-10-12-peer-principal-and-remote-access.md) owns principals, `createPeer()`, and access checks. | 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 | +| `connection.browserPeer: PeerScope` | The Peer every authenticated browser connection shares; owner principal; lives and dies with the Connection | +| `connection.admit(request): PeerAdmission` | Runs the existing `requestRejection`: returns `{ rejection: 401 \| 403 }` when refused, otherwise `{ peer: browserPeer }`. 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. +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. `InvokeRemoteRequest.peer` and the `peer` parameter of `RemoteStreamOpener` and `wireStream.open` are required, so every in-process caller names its Peer, and the Gateway creates none. Looking up a Peer by id and Peer open and close events wait until the first consumer that needs them appears. -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`. +The Peer carries its principal; what that principal may call is decided by the roles, bindings, and policies that [Peer principals and method-level Remote access control](../../implemented/architecture/2026-10-12-peer-principal-and-remote-access.md) defines. ### Client face @@ -312,7 +316,7 @@ Credit frames wait until a consumer that needs sustained high-volume uplink appe | 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. +An in-process carrier passes its Peer explicitly; the in-page Host of the worker tunnel passes `connection.browserPeer`. ### Relation to `$events` @@ -326,7 +330,7 @@ An in-process carrier with no `peer` means the operator. // 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 PeerScope { readonly id: PeerId; readonly ctx: Context; readonly principal: Principal; readonly signal: AbortSignal; dispose(): Promise } export interface RemoteInvocation { … } // see Host face declare module '@deepseek-ai/cordis' { interface Context { readonly invocation: RemoteInvocation | undefined } } @@ -363,13 +367,13 @@ export interface InvokeRemoteRequest { readonly args: Readonly> /** Uplink items; absent is equivalent to an iterator that ends immediately. */ readonly uplink?: AsyncIterable - /** The Peer that initiated the call; absent means the operator. */ - readonly peer?: PeerScope + /** The Peer that initiated the call; required on every path. */ + readonly peer: PeerScope readonly signal?: AbortSignal } export interface TypertGatewayWireStream { - open: (endpoint: string, payload: unknown, uplink: AsyncIterable, peer: PeerScope | undefined, signal: AbortSignal) => Promise> + open: (endpoint: string, payload: unknown, uplink: AsyncIterable, peer: PeerScope, signal: AbortSignal) => Promise> failure: (error: unknown) => RemoteStreamFailure } @@ -396,7 +400,7 @@ 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(), + request.peer, signal, new UplinkDecoder(request.uplink ?? EMPTY_ASYNC_ITERABLE, descriptor.uplink?.codec, endpoint, control), ) @@ -446,8 +450,8 @@ cancel : missing → ignore; otherwise control.abort(new Error('Remote stream ca | `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-client-connection` (`peer.ts`, `rpc.ts`, `rpc-host.ts`, `index.ts`) | The `Peer` class; `connection.browserPeer` 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)` | | `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 | @@ -562,7 +566,7 @@ for await (const reply of stream) replies.push(reply) // ['> a', '> b'] **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. +**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. Method-level access control later moved to [Peer principals and method-level Remote access control](../../implemented/architecture/2026-10-12-peer-principal-and-remote-access.md), which supersedes this part. **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. @@ -578,7 +582,7 @@ for await (const reply of stream) replies.push(reply) // ['> a', '> b'] - **`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`. - **Web terminals use `attach`** for screen output and input commands, with provider acknowledgements and per-attachment writable state; their Client retains the current generation's handle and never replays input. - **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`. +- **Every browser connection shares `browserPeer`**, and `admit` is the only network admission point; in-process code creates further Peers with `connection.createPeer()`. - **No cross-direction ordering guarantee between uplink and downlink**; a protocol that needs request-response pairing carries its own sequence numbers. - **Upper-layer supervisors expose only the downlink**: `$stream`, snapshot and journal do not expose `send`; a domain that writes uplink retains its generated physical handle for that generation. - **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. @@ -591,7 +595,7 @@ Retention cases in `gateway-stream.host.spec.ts` and `gateway.client.spec.ts` co - 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. +- connection: the three outcomes of `admit`, 401, 403, and `browserPeer`; `browserPeer` 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. 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 37613ef7e5..4e5681b04d 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 @@ -88,7 +88,7 @@ Client 句柄的 `send()` 提供上行项,`end()` 半关闭上行,`dispose() | 半关闭 | 一个方向已结束而另一个方向仍开放 | | 项 | 一个方向上的一个业务值;每项独立经 codec 校验 | | inbox | Host 侧每逻辑流一个的有界上行队列 | -| Peer | 连接层被接纳的一方,一个 `PeerScope`;本 Host 只有操作者一个 Peer | +| Peer | 一条连接代表的一方,一个携带 principal 的 `PeerScope`;所有浏览器连接共享 `connection.browserPeer`,进程内代码经 `connection.createPeer()` 自建([Peer 身份属性](../../implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md)) | | 调用上下文 | `RemoteInvocation`:本次调用的请求、接收服务、Peer、取消信号与上行入口 | ### 类型签名 @@ -135,7 +135,7 @@ export interface RemoteInvocation { } /** Cordis service key of the receiving service. */ readonly service: string - /** The Peer that initiated the call. In-process carriers and unadmitted calls are the operator. */ + /** The Peer that initiated the call; every carrier names it explicitly. */ readonly peer: PeerScope /** Carrier cancellation: Client cancel, socket closure, uplink failure. */ readonly signal: AbortSignal @@ -180,24 +180,28 @@ declare module '@deepseek-ai/cordis' { /** The opaque identity of one Peer. */ export type PeerId = Branded<'PeerId'> -/** One Peer's session on this Host: opened and released by the admitting side; ctx owns connection-lifetime registrations. */ +/** One Peer's session on this Host: opened and released by its creator; ctx owns connection-lifetime registrations. */ export interface PeerScope { readonly id: PeerId readonly ctx: Context + /** Caller identity fixed at creation. */ + readonly principal: Principal + /** Aborted once the Peer is disposed. */ + readonly signal: AbortSignal dispose(): Promise } ``` -本 Host 只有一个 Peer:操作者。`@deepseek-ai/dsh-client-connection` 在服务 apply 时用 `createScope(connectionCtx, peer)` 建它,与 Agent 建自己的 scope 是同一机制:Peer 对象就是 ScopeKey,`peer.ctx` 承接连接期效果,`dispose()` 随 connection 释放让 fiber 静默。它以 `connection.operator` 暴露。 +`@deepseek-ai/dsh-client-connection` 实现唯一的 Peer 类 `Peer(owner, { principal })`,用 `createScope(owner, peer)` 建立,与 Agent 建自己的 scope 是同一机制:Peer 对象就是 ScopeKey,`peer.ctx` 承接连接期效果,scope 结束时 `peer.signal` 中止,`dispose()` 让 fiber 静默。Connection 在服务 apply 时以 owner principal 创建 `connection.browserPeer`;principal、`createPeer()` 与访问检查归 [Peer 身份属性与 Remote 方法级访问控制](../../implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md)。 | 成员 | 语义 | | --- | --- | -| `connection.operator: PeerScope` | 唯一的 Peer,随 connection 生灭 | -| `connection.admit(request): PeerAdmission` | 过既有的 `requestRejection`:被拒返回 `{ rejection: 401 \| 403 }`,否则返回 `{ peer: operator }`。将来的接纳方在这里接入 | +| `connection.browserPeer: PeerScope` | 所有通过认证的浏览器连接共享的 Peer;principal 为 owner;随 connection 生灭 | +| `connection.admit(request): PeerAdmission` | 过既有的 `requestRejection`:被拒返回 `{ rejection: 401 \| 403 }`,否则返回 `{ peer: browserPeer }`。将来的接纳方在这里接入 | -网关 `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 的消费者出现时再加。 +网关 `handleUpgrade(req, socket, head, peer)` 为该 socket 绑一个固定 Peer 的 opener,并在 `peer.ctx.effect` 里登记 socket 关闭;scope 已失效时改为直接以 1001 关闭 socket。`InvokeRemoteRequest.peer`、`RemoteStreamOpener` 与 `wireStream.open` 的 `peer` 形参均为 required,每个进程内调用方都显式给出 Peer,网关不自建 Peer。按 id 查 Peer、Peer 的开关事件,都等第一个需要它们的消费者出现时再加。 -Peer 不携带访问模型:Peer 是谁、能做什么,由业务插件通过 `peer.ctx` 自行附加。 +Peer 携带自己的 principal;该 principal 能调用什么,由 [Peer 身份属性与 Remote 方法级访问控制](../../implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md) 定义的 role、binding 与 policy 判定。 ### Client 面 @@ -312,7 +316,7 @@ credit 帧留待需要持续大流量上行的消费者出现时再加;inbox | 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` 缺席即操作者。 +进程内载体显式传入 Peer;worker 隧道的页内 Host 传 `connection.browserPeer`。 ### 与 `$events` 的关系 @@ -326,7 +330,7 @@ credit 帧留待需要持续大流量上行的消费者出现时再加;inbox // 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 PeerScope { readonly id: PeerId; readonly ctx: Context; readonly principal: Principal; readonly signal: AbortSignal; dispose(): Promise } export interface RemoteInvocation { … } // see Host face declare module '@deepseek-ai/cordis' { interface Context { readonly invocation: RemoteInvocation | undefined } } @@ -363,13 +367,13 @@ export interface InvokeRemoteRequest { readonly args: Readonly> /** Uplink items; absent is equivalent to an iterator that ends immediately. */ readonly uplink?: AsyncIterable - /** The Peer that initiated the call; absent means the operator. */ - readonly peer?: PeerScope + /** The Peer that initiated the call; required on every path. */ + readonly peer: PeerScope readonly signal?: AbortSignal } export interface TypertGatewayWireStream { - open: (endpoint: string, payload: unknown, uplink: AsyncIterable, peer: PeerScope | undefined, signal: AbortSignal) => Promise> + open: (endpoint: string, payload: unknown, uplink: AsyncIterable, peer: PeerScope, signal: AbortSignal) => Promise> failure: (error: unknown) => RemoteStreamFailure } @@ -396,7 +400,7 @@ 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(), + request.peer, signal, new UplinkDecoder(request.uplink ?? EMPTY_ASYNC_ITERABLE, descriptor.uplink?.codec, endpoint, control), ) @@ -446,8 +450,8 @@ cancel : missing → ignore; otherwise control.abort(new Error('Remote stream ca | `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-client-connection`(`peer.ts`、`rpc.ts`、`rpc-host.ts`、`index.ts`) | `Peer` 类;`connection.browserPeer` 与 `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)` | | `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 目录 | @@ -562,7 +566,7 @@ for await (const reply of stream) replies.push(reply) // ['> a', '> b'] **客户端 `send` 放在生成方法返回的 `AsyncIterable` 上。** `AsyncIterable` 上挂方法不常规;句柄类型 `RemoteStreamHandle` 显式扩展它。 -**照搬 Access 机制。** 访问控制是业务语义,`vouch` 与 `remote/invoke` waterfall 为它服务;本 Note 只需要"这条流是谁的",`PeerScope` 就够。 +**照搬 Access 机制。** 访问控制是业务语义,`vouch` 与 `remote/invoke` waterfall 为它服务;本 Note 只需要"这条流是谁的",`PeerScope` 就够。方法级访问控制后来归入 [Peer 身份属性与 Remote 方法级访问控制](../../implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md),该部分由其 supersede。 **继续用 unary RPC 做上行。** 每次上行一次 HTTP 往返、一次鉴权、一次查找;两条载体没有顺序关系,只能靠客户端串行化;没有 EOF;取消不覆盖两半;每个功能各造一套关联键。 @@ -578,7 +582,7 @@ for await (const reply of stream) replies.push(reply) // ['> a', '> b'] - **`this.ctx.invocation` 在非 Remote 调用下是 `undefined`**,方法读它要处理可选;一个只被进程内直接调用的服务方法读到 `undefined` 是正确行为。 - **Web 终端使用 `attach`** 传输屏幕输出和输入命令,并提供提供者确认和附件专属可写状态;Client 保留当前代次的句柄,绝不重放输入。 - **inbox 上限对所有流生效**:一个不读上行的方法收到大量上行帧会整流失败。这是刻意的显式失败,README 记明。 -- **Peer 只有操作者一个**,`admit` 是唯一的接纳点;第一个需要第二个 Peer 的消费者要在 `dsh-client-connection` 加上打开、关联与事件。 +- **所有浏览器连接共享 `browserPeer`**,`admit` 是唯一的网络接纳点;进程内代码经 `connection.createPeer()` 创建其他 Peer。 - **上行与下行之间没有跨方向顺序保证**;需要请求应答配对的协议自带序号。 - **上层监督器仅暴露下行**:`$stream`、snapshot 和 journal 不暴露 `send`;需要写入上行的领域保留该代次的生成物理句柄。 - **worker 隧道与 WebSocket 载体行为不同**:没有 inbox 上限,`end` 之后的项丢弃,因为页面到 worker 是同源可信边界。 @@ -591,7 +595,7 @@ for await (const reply of stream) replies.push(reply) // ['> a', '> b'] - 网关 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 释放。 +- connection:`admit` 的 401、403 与 `browserPeer` 三种结果;`browserPeer` 经桥与共享处理器到达;随 connection 释放。 - remote-mock、worker 隧道:上行透传;直接代理返回真句柄。 - 既有 stream 与 unary 方法的测试不改期望值即通过。 diff --git a/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.i18n.yaml new file mode 100644 index 0000000000..3bd834903b --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.i18n.yaml @@ -0,0 +1,91 @@ +# Bilingual-pair consistency record for 2026-10-12-peer-principal-and-remote-access.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md +/agent-note-peer-principals-and-method-level-remote-access-control: + en: 0c0fd0f567bd5c89 + zh: 464e34d05737a7bc +/agent-note-peer-principals-and-method-level-remote-access-control/problem: + en: f5b70ee2c0a3e32a + zh: 68776a916cb41f11 +/agent-note-peer-principals-and-method-level-remote-access-control/decision: + en: a15c7c13f167e5ac + zh: 63bcc2ef60c2f344 +/agent-note-peer-principals-and-method-level-remote-access-control/decision/scope: + en: 86061e5578d06871 + zh: 08eb605fcd38329d +/agent-note-peer-principals-and-method-level-remote-access-control/decision/two-layers-principal-and-peer: + en: 6663ea33561eee82 + zh: 722fd3b8fae0ef90 +/agent-note-peer-principals-and-method-level-remote-access-control/decision/peer-model: + en: db5b0cf33679e5ed + zh: 1b04328268a95abc +/agent-note-peer-principals-and-method-level-remote-access-control/decision/identity-types: + en: 31430c5b95295ead + zh: 8f4ba35b4406558d +/agent-note-peer-principals-and-method-level-remote-access-control/decision/peer-lifetime-and-revocation: + en: a01ac57fc71a84f0 + zh: 3e488ef3da5e4b8c +/agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control: + en: a55516339b0d49dd + zh: 3281df9ea7878fc0 +/agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/correspondence-with-rbac: + en: a78a2410eb52f777 + zh: a868bf1b0e690c9f +/agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/resources-and-resource-readers-deepseek-ai-dsh-typert-protocol-deepseek-ai-dsh-typert-registry: + en: 9680128eec80add7 + zh: 93cb356641781ad8 +/agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/endpoint-resources: + en: 471ae9be4041846a + zh: 84f2f6052ca8ec10 +/agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/roles-bindings-and-policies-deepseek-ai-dsh-api-remote-access: + en: e57f396239ea9f56 + zh: ddb26278b1521ceb +/agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/example-share-viewer: + en: 3c911f57f0f6ca4b + zh: 1509781eeda4c922 +/agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/host-access-check-order: + en: d22555466f55b134 + zh: bc8b1f54789dc4b0 +/agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/forwarded-events: + en: 5fe34ee5ec31942a + zh: ef39b020b0539b8f +/agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/list-and-aggregate-methods: + en: 745028bb7b8d4765 + zh: f5e83362c5919360 +/agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/access-snapshot-stream: + en: 41d7ac1faf2448d7 + zh: 6d523895a9abae68 +/agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/client-face: + en: 7be28cd7e19e5377 + zh: 30b5aee957271d6c +/agent-note-peer-principals-and-method-level-remote-access-control/decision/security-invariants: + en: 2094587c320b6ccc + zh: 3a1d2a6b343500f4 +/agent-note-peer-principals-and-method-level-remote-access-control/decision/recording-the-message-source: + en: 2ee3abed24d418f4 + zh: 25653dd24b95c220 +/agent-note-peer-principals-and-method-level-remote-access-control/decision/code-review-rule-and-documentation: + en: e9a2e0d1534b8fe7 + zh: 085813c0b2b3c0d8 +/agent-note-peer-principals-and-method-level-remote-access-control/decision/out-of-scope-and-future-work: + en: 4bb0314c20e4f25c + zh: 6ede1dd2193a01d7 +/agent-note-peer-principals-and-method-level-remote-access-control/decision/relation-to-existing-notes: + en: 305ec45a7cd85ed7 + zh: d2cd4209a56b1d0c +/agent-note-peer-principals-and-method-level-remote-access-control/alternatives-considered: + en: 130df2981ba550df + zh: e9a0a89af6fb50a3 +/agent-note-peer-principals-and-method-level-remote-access-control/consequences: + en: 89452c3c3f042e75 + zh: 9bcad00a60c65303 +/agent-note-peer-principals-and-method-level-remote-access-control/consequences/verification: + en: 859bdb170d0244b3 + zh: eb68208eaa4b310a +/agent-note-peer-principals-and-method-level-remote-access-control/consequences/costs-and-limits: + en: 1e254cec680b1a37 + zh: 06a31e057ba965aa +/agent-note-peer-principals-and-method-level-remote-access-control/consequences/migration-list-for-pr-5945: + en: f904c62b68d46bb5 + zh: 34eb45b97302badb diff --git a/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md b/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md new file mode 100644 index 0000000000..8270786b81 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md @@ -0,0 +1,670 @@ +# Agent Note: Peer principals and method-level Remote access control + +Status: implemented + +English | [中文](2026-10-12-peer-principal-and-remote-access.zh.md) + +## Problem + +A Remote method on the Host could not tell whom one call spoke for, and could not restrict callable methods by caller. The [`PeerScope`](../../../../packages/typert/protocol/src/types.ts) contract stated "Who the Peer is and what it may do are not recorded here"; [Connection](../../../../packages/client/connection/src/rpc-host.ts) had a single operator Peer, `admit()` returned it for every authenticated request, and the `/api` route and the interceptor's Fetch handler used it unconditionally instead of the admission result; [Gateway](../../../../packages/api/gateway/src/index.ts) silently fell back to `operatorPeer()` when a caller named no Peer. + +The first non-owner caller is a liveshare viewer. Viewer requests reach the owner's liveshare Host plugin through the Relay, and the plugin dispatched them in the owner's process as the operator, so the owner's Session treated a viewer's prompt as the owner's own input. PR #3854 pushed liveshare semantics into session-controller for this: the request body carries `origin`, every prompt writes a required `prompt/origin` event correlated by requestId, the controller folds origins, `serializeAdmission` extends to every prompt, and a new `api-session/external-admission` hook appears; liveshare maintains its own `READ_ENDPOINTS` allowlist and trims list/follow results; the frontend `clientWritePolicy` lives in ui-conversation, with three ways of reading it across five packages and seven places that hide UI by whether a callback was injected. + +One general mechanism is needed: + +- In-process Host code assigns the caller identity; no wire field can set or override it. +- Every call path names its Peer explicitly; there is no implicit fallback. +- Method-level permission is computed and enforced in one place on the Host; the frontend reads the same result to show or hide entries and follows rule changes automatically. +- A prompt records its caller with the user message, so a later check can tell who initiated each turn. + +## Decision + +### Scope + +| Part | Content | +|---|---| +| Peer identity | `PeerScope.principal` and `PeerScope.signal`; one Peer implementation `Peer` (renamed from `OperatorPeer`) with an options constructor; removal of the operator concept and every implicit Peer fallback; `connection.browserPeer`, `connection.createPeer()`, and `fetch(request, peer)`; a disposed Peer is always refused with `gateway/peer-disposed`; the `user-rpc` source records `principal?` | +| Method-level access control | `resources` readers on `@Remote`, `TypertResourceMap` resource kinds, and the `typert.resources` registry (typert-protocol, typert-registry); Gateway resolution of calls and forwarded events to resources; new package `@deepseek-ai/dsh-api-remote-access` (Host and Client faces): RBAC decisions over roles, bindings, and policies through `decide(request)`, list filtering through `allows()`, the first snapshot in the index and the access snapshot stream, and access hooks | +| Documentation | One code-review rule and terminology | + +### Two layers: principal and peer + +| Layer | Concept | Example | Cardinality | +|---|---|---|---| +| Authorization | principal: the caller identity that permission decisions read | `share-viewer{shareId, actor}` proven by a share token; the local user `{ kind: 'owner' }` | one principal has N peers | +| Connection | peer: the lifetime scope of one connection | a browser connection, one connection of a liveshare viewer | one peer per connection | + +| Concern | Layer | +|---|---| +| Method-level decisions, access hooks, access snapshots | principal | +| Recording `source.principal` | principal | +| Subscriptions, streams, connection-lifetime registrations (`peer.ctx`) | peer | +| Revocation | both: after revocation the policy gives that principal no binding, so its calls are all refused; the creator disposes every peer under it | + +The creator owns the principal→peers table; liveshare, for example, keeps one object per share grant that records every peer opened under it. + +### Peer model + +There is one Peer concept and one implementation; the operator concept is removed. + +```text +// @deepseek-ai/dsh-typert-protocol +export interface PeerScope { + readonly id: PeerId + /** Connection-lifetime registrations; disposed with the Peer. */ + readonly ctx: Context + /** Caller identity fixed at creation by in-process Host code; no wire field can set it. */ + readonly principal: Principal + /** Aborted once the Peer is disposed; Remote streams that speak for the Peer end with it. */ + readonly signal: AbortSignal + dispose(): Promise +} + +// @deepseek-ai/dsh-client-connection +export class Peer implements PeerScope { + constructor(owner: Context, options: { readonly principal: Principal }) +} + +interface HostConnectionHandle { + /** The Peer every authenticated browser connection shares; principal { kind: 'owner' }. */ + readonly browserPeer: PeerScope + /** Network admission: the admitted Peer or the refusal status. */ + admit(request: ConnectionTrustRequest): PeerAdmission + /** In-process only; disposed with the Context that read `ctx.connection`, or earlier by dispose(). */ + createPeer(options: { readonly principal: Principal }): PeerScope +} + +interface ConnectionFetchHandler { + fetch(request: Request, peer: PeerScope): Promise +} +bridge(req, res, apiHandler, peer, maxRequestBodyBytes?) + +// @deepseek-ai/dsh-api-gateway +interface InvokeRemoteRequest { …; readonly peer: PeerScope } // was `peer?` +wireStream.open(endpoint, payload, uplink, peer: PeerScope, signal) // was `PeerScope | undefined` +``` + +| Rule | Detail | +|---|---| +| Every path names its Peer | Network paths take it from `admit()`; in-process paths receive it from the caller. Gateway deletes `operatorPeer()` and `request.peer ?? …` and never creates a Peer | +| Browser connections share `browserPeer` | Every browser connection shares one Peer, and every use site names `connection.browserPeer` explicitly; there is no fallback variable. The in-page Host (webworker-runtime) also passes `browserPeer` explicitly | +| In-process Host automation | Code that calls Remote methods creates its own Peer with `createPeer({ principal })`. No production caller exists yet, so no fixed Host Peer is pre-provisioned | +| Multiple Peers | Each `createPeer()` returns an independent Peer; liveshare is to create one per viewer connection (wired on the #3854 side) | +| Exact Fetch routes | Exact routes such as file-upload, present-open, media-references, session-log-export, and inspector bypass the Gateway; the shared Fetch handler answers 403 to every non-owner Peer | + +### Identity types + +```text +// @deepseek-ai/dsh-typert-protocol + +/** + * Merge-extensible caller identities. Each driver declares its own entry. + * Every entry is a JSON value with a unique literal `kind`, carries no + * credential, and is safe to persist in a Session log. + */ +export interface PrincipalMap { + owner: { readonly kind: 'owner' } +} +export type Principal = PrincipalMap[keyof PrincipalMap] + +/** + * @param invocation - `ctx.invocation`; `undefined` outside a Remote call. + * @returns the caller's principal when it is not the owner; `undefined` for the + * owner and for trusted in-process code outside a Remote call. + */ +export function principalOf(invocation: RemoteInvocation | undefined): Principal | undefined +``` + +The single read point is `this.ctx.invocation.peer.principal`; an owner test is written inline as `principal.kind === 'owner'`, and an unknown kind is never the owner. External drivers extend `PrincipalMap` by declaration merging in their own package; liveshare's `share-viewer{ shareId, actor }` is declared on the #3854 side, and liveshare itself is unchanged here. + +### Peer lifetime and revocation + +| Event | Behavior | +|---|---| +| Gateway dispatch | The first step checks `peer.signal.aborted` and refuses a disposed Peer with `gateway/peer-disposed`, on HTTP, WebSocket, and in-process paths alike; wire paths check before they read the payload, so a malformed payload from a disposed Peer gets the same code | +| Streams when a Peer is disposed | The signal a Remote stream method observes joins `peer.signal`, and so does the `$events` stream lifetime; a WebSocket still closes with 1001 | +| Unary calls when a Peer is disposed | A unary call already running is not cancelled; later calls are refused | +| Revocation | After a business package revokes a grant, its policy gives that principal no binding, and the business package calls `invalidate(principal)`; the creator disposes every peer in its principal→peers table | + +### Method-level access control + +Resource types and the `resources` readers of `@Remote` live in `@deepseek-ai/dsh-typert-protocol`; resource kinds live in typert beside lookups and Contexts: `TypertResourceMap` declares the kinds, `typert.resources` holds each kind's default wire field, and lookups and Host Context adapters declare the resource kind their wire identity names. The Gateway resolves one call or forwarded event to the resources it touches. Decisions, list filtering, and the access snapshot live in the new package `@deepseek-ai/dsh-api-remote-access` (`packages/api/remote-access`), which reads no argument field. The Gateway and domain packages depend on remote-access only for types and inject `remoteAccess`; their only value dependency is `dsh-typert-protocol`, so `package-dependency-policy` gains no remote-access entry. Business packages define and register roles, bindings, and policies; liveshare is the first user. Apart from the built-in `global`, no resource-kind name appears in remote-access or the Gateway, which follows the [`packages/api` carries generic orchestration only](../../../../docs/defensive-patterns/remote-and-api.md) review rule. + +#### Correspondence with RBAC + +| This design | RBAC / IAM term | Meaning | +|---|---|---| +| principal | Subject / Principal | The caller identity, one entry of `PrincipalMap` | +| Peer | Session (RBAC session) | One connection of a principal; one principal has N Peers | +| owner | Superuser | Skips every check | +| action | Action / Permission | A Remote endpoint `ns/method`, or a forwarded event `event:`; the Gateway still calls it an endpoint internally | +| resource | Resource / Object | `{ kind: 'global' }`, or `{ kind, id }` of a declared resource kind | +| role | Role | An action set over one resource kind; a plain value of the business package | +| binding | RoleBinding (role assignment) | The principal holds a role on a resource, optionally with a condition | +| condition | Condition (IAM Conditions) | Attached to a binding; allows by the request's action, resource, and arguments, or refuses with a reason | +| policy | Allow policy | The bindings and optional `explain` a business package gives for one non-owner principal kind | +| explain | Denial explanation | Names a refusal reason when no binding allows; never changes the decision | +| Gateway | PEP (Policy Enforcement Point) | Resolves resources and enforces the decision | +| remote-access | PDP (Policy Decision Point) | Computes decisions, list filtering, and access snapshots | + +| Rule | Content | +|---|---| +| Default deny | A non-owner is allowed only when the role of some binding explicitly lists the action; an action no role lists, a resource without a binding, and a principal kind without a policy are all refused. Every Remote method is under access control; there is no separate accessibility marker | +| No inheritance between resources | `global` is not a parent of other resources: a grant on `global` implies no grant on any Session or Workspace, and a grant on a Workspace implies no grant on its Sessions | +| A `global` grant is an explicit global grant | A call that resolves to no resource touches `global`. Putting an action in a `global` role lets that principal call it without resource restriction, which is a high-risk grant; once the method is allowed, it can still decide each item inside with `allows(resource)` | + +#### Resources and resource readers (`@deepseek-ai/dsh-typert-protocol`, `@deepseek-ai/dsh-typert-registry`) + +```text +/** Merge-extensible resource kinds: the key is the kind, the value declares its identity type. */ +export interface TypertResourceMap {} +// @deepseek-ai/dsh-session/types: session: TypertResource +// @deepseek-ai/dsh-workspace/types: workspace: TypertResource + +// Runtime registries in typert +ctx.typert.resources.register(kind, { field }): TypertDisposer +ctx.typert.lookups.register(key, { parameter, wire, resource?, … }) // resource: the kind its wire id names +ctx.typert.contexts.registerHost(key, { wire, resource?, … }) // a TypertHostContextAdapter + +export interface TypertHostContextAdapter { + readonly wire: string + readonly wireTypeSymbol: string + /** Resource kind the wire identity names; access checks read the identity from args before resolve(). */ + readonly resource?: TypertResourceKind + resolve(id: Wire): Context | undefined | Promise +} + +export type RemoteAccessResourceKind = 'global' | TypertResourceKind +export type RemoteAccessResource = + | { readonly kind: 'global' } + | { readonly kind: K; readonly id: TypertResourceId } // for each declared kind K +export type RemoteAccessResourceOf = Extract + +/** Per-kind readers, called with the method's positional wire arguments. */ +export type TypertResourceReaders = { + readonly [K in TypertResourceKind]?: (...args: WireArgs) => Id | readonly Id[] | undefined // Id = TypertResourceId +} + +export interface RemoteMethodOptions { + readonly name?: string + readonly mode?: 'stream' + readonly resources?: TypertResourceReaders +} + +export function Remote( + option: string | RemoteMethodOptions>>, +): (method: (this: This, ...args: Args) => Result, + context: ClassMethodDecoratorContext Result>) => void + +export interface RemoteScopeOptions { + readonly name?: string + readonly resources?: TypertResourceReaders +} + +export function RemoteScope( + key: Extract, + option?: string | RemoteScopeOptions>>, +): (method: (this: This, ...args: Args) => Result, + context: ClassMethodDecoratorContext Result>) => void +``` + +| Rule | Content | +|---|---| +| Syntax | `@Remote({ name: 'page', resources: { session: (request) => request.address.kind === 'session' ? request.address.sessionId : request.address.childSessionId } })`; a Context method writes `@RemoteScope('agent')` and gets its Session resource from the Agent Context's `resource: 'session'`, and writes `@RemoteScope('agent', { resources: { workspace: (request) => request.workspaceId } })` when it needs another resource. Most methods write no `resources` and get their resources from automatic resolution | +| Resource kinds | The lower-level packages that declare the identity types merge the resource kinds: `session: TypertResource` in `@deepseek-ai/dsh-session/types` and `workspace: TypertResource` in `@deepseek-ai/dsh-workspace/types`. session-controller references the commands package, so a merge in session-controller would make commands reference session-controller and form a project-reference cycle. remote-access builds in `global`, which has no id and stays out of `TypertResourceMap`; typert reserves the name, and `typert.resources.register('global', …)` throws | +| Default fields and declarations | The domain that owns a kind registers its default wire field in apply: session-controller calls `typert.resources.register('session', { field: 'sessionId' })` and workspace-controller registers `workspace` → `workspaceId`; registering a kind twice throws. The Agent and Session lookups declare `resource: 'session'`, and the agent package's Agent Context declares `resource: 'session'` | +| Resolution precedence | Each resource kind takes, in order: ① that kind's reader in the method's `resources`; ② every lookup parameter that declares that kind as its `resource` contributes its wire identity, and an omitted optional lookup parameter contributes none; when the Context of a Context method declares that kind as its `resource`, the Context's wire identity contributes too; ③ that kind's default field: a top-level named wire argument of that name when one exists, otherwise that field of the first object argument. A default field names one identity, so an array there is an invalid value | +| Resolution result | The resources of every kind together are the resources the call touches; with none, the call touches `[global]`; when resources resolve, only those are used and `global` is not added | +| Readers | Called with the method's positional arguments: the named wire arguments mapped by the descriptor back to the method's parameter order (undecoded JSON, the wire identity in a lookup position, and the call's signal in the cancellation position); return one id, an id array (every one must be allowed), or `undefined` (no resource of that kind) | +| Invalid values | A reader that throws or returns an empty array, or a read value (including a Context's wire identity) that is not a non-empty string, makes the resources invalid: the Gateway refuses the call with `remote-access/denied` (reason `resource-invalid`) without asking `decide()` | +| Completeness | `resources` and automatic resolution must cover every resource the method touches. A call that resolves no resource falls to `global`, which only a `global` role can allow, so it is refused by default; when only some resources resolve, the rest go unchecked and a principal granted on the resolved resources can reach them, which the method author must prevent | +| Type inference | The parameter types of `@Remote` and `@RemoteScope` readers are inferred from the decorated method: the factory parameter uses `NoInfer`, so `Args` is inferred only from the returned decorator; lookup parameters map to wire types through `TypertLookupMap` (for a method whose first parameter is `agent: Agent`, the reader's first parameter is `SessionId`). A missing property, more parameters than the method has, and a `global` key fail compilation | +| Storage | `resources` readers live on the class prototype with the other `@Remote` / `@RemoteScope` metadata, are recorded on instance construction, and are safe across duplicate installations; they do not enter Typert artifacts | +| Context methods | `@RemoteScope(key, option?)` matches `@Remote`: a string is the export name and an object is `{ name?, resources? }`. A Context's wire identity takes part in resolution like a lookup parameter that declares a `resource`, with the same precedence, and a `resources` reader for that kind overrides it; the Gateway reads that identity directly from args before `resolveReceiverContext`, so no Agent is activated. A Context that declares no `resource` contributes no resource from its identity, and the method resolves by the ordinary rules | + +#### Endpoint resources + +The table lists the endpoints #3854 viewers use and where their resources come from. Only `session/page` and `session/follow` need explicit `resources`; the rest rely on a lookup's `resource` or the default field: + +| Endpoint | Resource | Resolution source | Filtering inside the method | +|---|---|---|---| +| `workspace/follow` | `global` | no resource | yes: Workspaces and Sessions | +| `session/control` | `global` | no resource | yes: Sessions | +| `session/list` | `global` (`_request` has no `sessionId`) | no resource | yes: Session rows | +| `session/modelCatalog` | `global` | no resource | no | +| `session/canOpenWorkspacePath` | `global` | no resource | no | +| `llm/listConfigurableProviders` | `global` | no resource | no | +| `llm/listProviders` | `global` | no resource | no | +| `commands/list` | session: `args.agentId` (the Agent lookup's wire field) | Agent lookup `resource: 'session'` | no | +| `goals/get` | session: `args.agentId` | Agent lookup `resource: 'session'` | no | +| `session/attachment` | session: `args.request.sessionId` | default field | no | +| `skills/list` | session: `args.request.sessionId` | default field | no | +| `session/fork` | session: `args.request.sessionId` | default field | no | +| `session/prompt` | session: `args.request.sessionId` | default field | no | +| `session/cancel` | session: `args.request.sessionId` | default field | no | +| `session/projections` | session: `args.request.sessionId` | default field | no | +| `session/page` | session: `args.request.address.sessionId` for a Session address; `args.request.address.childSessionId` for a subagent address | `resources` | no | +| `session/follow` | same as above | `resources` | no | +| `remoteAccess/follow` | `global` | no resource; remote-access allows it for every principal | no | + +A subagent address reads the child transcript, so its resource is the child Session. A Client rereads the projections of known Sessions after a reconnect, including the parent of a subagent Session; a reader without a grant on that parent has its projection read refused. Whether a grant on a parent Session covers its subagent children is the policy's decision, that is, whether it gives a binding for the child Session; the generic layer derives no parent resource. + +Methods that need no non-owner access declare nothing, and a method no role lists is refused. The request fields of subagent `prompt` are `parentSessionId` / `childSessionId`, so automatic resolution finds no Session and resolves it to `global`; it must therefore stay out of every `global` role. No role lists `session/search`, so it is unreachable for non-owners and gains no filtering. `subagents/list` is not a Remote endpoint (`SubagentRuntime.list()` carries no `@Remote`). + +#### Roles, bindings, and policies (`@deepseek-ai/dsh-api-remote-access`) + +```text +/** Remote endpoint `ns/method`, or `event:` for one forwarded event; endpoint segments never contain `:`. */ +export type RemoteAccessAction = RemoteAccessEndpoint | `event:${string}` + +/** A named action set over one resource kind; a plain value owned by the business package. */ +export interface RemoteAccessRole { + readonly name: string + readonly resource: K + readonly actions: readonly RemoteAccessAction[] +} + +/** Answer of decide() and of a condition. */ +export type RemoteAccessDecision = + | { readonly kind: 'allow' } + | { readonly kind: 'deny'; readonly reason: string } + +/** Refines one binding for one action on its resource; `args` only for a concrete call. */ +export type RemoteAccessCondition = (request: { + readonly action: RemoteAccessAction + readonly resource: RemoteAccessResourceOf + readonly args?: Readonly> +}) => RemoteAccessDecision + +/** One role on one resource of the role's kind; the resource type follows from the role. */ +export type RemoteAccessBinding = { + [K in RemoteAccessResourceKind]: { + readonly role: RemoteAccessRole + readonly resource: RemoteAccessResourceOf + readonly condition?: RemoteAccessCondition + } +}[RemoteAccessResourceKind] + +/** One access question; the Gateway passes only valid resources, [global] when none. */ +export interface RemoteAccessRequest

{ + readonly principal: P + readonly action: RemoteAccessAction + /** Allow only when the action is allowed on every listed resource. */ + readonly resources: readonly RemoteAccessResource[] + /** Named wire arguments of a concrete call, undecoded; absent for events, snapshots, and list filtering. */ + readonly args?: Readonly> +} + +/** Business rules for one non-owner principal kind; synchronous over in-memory state. */ +export interface RemoteAccessPolicy

{ + bindings(principal: P): readonly RemoteAccessBinding[] + /** Reason for a request no binding allows; never changes the decision. */ + explain?(request: RemoteAccessRequest

): string | undefined +} + +/** details of `remote-access/denied` */ +export interface RemoteAccessDeniedDetails { + readonly action: string + readonly reason: string +} + +class RemoteAccessService { // ctx.remoteAccess; Remote namespace remoteAccess + registerPolicy>(kind: K, policy: RemoteAccessPolicy): () => void + invalidate(principal?: Principal): void + decide(request: RemoteAccessRequest): RemoteAccessDecision // Gateway-only + allows(resource: RemoteAccessResource): boolean // reads the accessing ctx's invocation + snapshotFor(principal: Principal): RemoteAccessBaseline // a page's opening baseline + follow(signal): AsyncIterable // remoteAccess/follow +} +``` + +| Rule | Content | +|---|---| +| Owner | The Gateway does not resolve resources or call `decide()` for the owner, and reads no policy | +| Registration | `registerPolicy(kind, policy)` returns a disposer, and a business package calls it inside `ctx.effect()`. Each non-owner principal kind has at most one policy, and a second registration for the same kind throws; `bindings()` and `explain()` receive principals typed by the kind | +| Built-in allow | `remoteAccess/follow` is allowed for every principal, so every Peer can read its own snapshot | +| Decision | A principal kind without a policy is refused with `no-allow`. Otherwise `bindings(principal)` is read, and a linear scan finds for each requested resource one binding whose resource is equal (the same `kind` and, for a non-`global` resource, the same `id`), whose role's `actions` contains the action, and that has no condition or whose condition allows; the request is allowed only when every resource has one (AND). A condition runs only for a binding whose resource is equal and whose role contains the action | +| Refusal reason | The first resource not allowed decides the reason, in this precedence: when matching bindings exist on that resource but all their conditions refuse, the first condition's reason; otherwise the return value of `explain(request)`; without `explain`, or when it returns `undefined`, `no-allow`. `explain` runs only after the decision is already a refusal and never changes it | +| Failure isolation | When `bindings()`, a condition, or `explain()` throws, the decision is refused with `policy-failed`; in a snapshot computation, a throwing `bindings()` leaves that principal no grant and a throwing condition excludes that action. A faulty rule therefore never breaks the shared `$events` source, a snapshot stream, or page rendering; after each `invalidate()` or policy change, the first failure of each policy is logged once | +| Synchronous | `bindings()`, conditions, and `explain()` return synchronously from state the business package preloads in memory, so list filtering can decide per item and snapshots can be computed | +| Evaluation timing | Each `decide()`, each `allows()`, and each snapshot computation calls `bindings()` once, with no caching; time-dependent state (such as expiry) applies immediately, and a long stream (such as `workspace/follow` or `session/control`) reflects added or removed bindings at its next per-item decision; snapshots are recomputed only after `invalidate()`, so a business package also calls `invalidate()` when a time event such as expiry happens | +| Notification | When a business state change (grant, revocation, mode switch) alters a binding or a condition's answer, the business package must call `invalidate(principal?)`; snapshots are recomputed only after `invalidate()` and policy registration or removal | +| `remote-access/denied` | Only a call refusal becomes an error, with details `{ action, reason }`, where reason is the reason a condition or `explain` gave, `no-allow`, `policy-failed`, or the `resource-invalid` the Gateway gives for invalid resources; a refused event is simply not pushed | +| Required service | The Gateway declares `static inject = ['typert', 'remoteAccess']`; a composition that mounts the Gateway mounts remote-access, which [`bundle/base`](../../../../packages/bundle/base/cordis.patch.yml) assembles right after `typert-gateway`; the plugin manager protects it like the Gateway, so it cannot be disabled from the Plugins page | + +#### Example: share viewer + +The `share-viewer` principal that liveshare declares shows that the derivation from business state to bindings stays in the business package; `shares` in the example stands for the share state the business package holds in memory: + +```text +const globalReader = { name: 'share:global-reader', resource: 'global', actions: ['session/list', 'session/control', 'workspace/follow', 'session/modelCatalog', 'llm/listProviders', 'commands/list'] } +const sessionReader = { name: 'share:session-reader', resource: 'session', actions: ['session/page', 'session/follow', 'session/list', 'session/control', 'workspace/follow', 'commands/list', 'event:api-session/status', 'event:api-session/added'] } +const workspaceReader = { name: 'share:workspace-reader', resource: 'workspace', actions: ['workspace/follow'] } +const forker = { name: 'share:forker', resource: 'session', actions: ['session/fork'] } +const writer = { name: 'share:writer', resource: 'session', actions: ['session/prompt', 'session/cancel'] } + +ctx.effect(() => ctx.remoteAccess.registerPolicy('share-viewer', { + bindings(viewer) { + const share = shares.serving(viewer) // undefined once stopped, expired, or no longer admitted + if (share === undefined) return [] + const source = { kind: 'session', id: share.sessionId } + return [ + { role: globalReader, resource: { kind: 'global' } }, + { role: sessionReader, resource: source }, + ...(share.allowsFork ? [{ role: forker, resource: source }] : []), + ...(share.mode === 'write' ? [{ role: writer, resource: source, condition: sourceWrite(share) }] : []), + ...shares.forksOf(viewer).flatMap(child => [ + { role: sessionReader, resource: { kind: 'session', id: child.id } }, + ...(child.revoked ? [] : [{ role: writer, resource: { kind: 'session', id: child.id }, condition: childPrompt(child) }]), + ]), + ...shares.workspacesShowing(viewer).map(id => ({ role: workspaceReader, resource: { kind: 'workspace', id } })), + ] + }, + explain({ principal, action }) { + return shares.refusal(principal) // 'share-expired', 'share-stopped', … + ?? (action === 'session/prompt' ? 'share-not-writable' : undefined) + }, +})) + +// Without args (snapshots, list filtering) a condition answers for the action alone. +const sourceWrite = share => () => share.confined ? allow : deny('source-write-unavailable') +const childPrompt = child => ({ action, args }) => + action === 'session/prompt' && args !== undefined && !child.allowsPrompt() ? deny('fork-prompt-refused') : allow +``` + +| Owned by | Content | +|---|---| +| Business package | The action lists of roles; the derivation from share state to bindings (stop, expiry, admission, mode, the viewer's own forks, revocation, Workspaces containing visible Sessions); per-request conditions; refusal reasons for the viewer page | +| Generic layer | Resource resolution, AND across resources, role and action matching, condition calls, reason precedence, list filtering, snapshot computation, and failure isolation | + +That viewer's snapshot contains grants on `global`, the source Session, each of its own forks, and each visible Workspace; `session/list` appears in both a `global` role and a session role, see [List and aggregate methods](#list-and-aggregate-methods). + +#### Host access-check order + +Every call path (HTTP `/api`, WebSocket streams, in-process `fetch(request, peer)` / `invoke` / `stream` / `wireStream.open`) passes through one Gateway sequence: + +| Step | Action | Failure | +|---|---|---| +| 1 | Admission yields the Peer: network paths from Connection `admit()` (`browserPeer`), in-process paths from the caller. The Gateway checks `peer.signal` before it reads the wire payload | Failed network admission answers HTTP 401 / 403 without entering the Gateway; a disposed Peer: `gateway/peer-disposed` | +| 2 | Resolve the endpoint and validate the argument field set (`resolveDescriptor`, `assertExactArguments`) | `gateway/invocation-unavailable`, `gateway/ambiguous-endpoint`, `gateway/definition-unavailable`; extra or missing fields: `gateway/arguments-invalid` | +| 3 | Owner pass-through: for `principal.kind === 'owner'` go straight to step 5 | — | +| 4 | Non-owner: the Gateway resolves resources (`resources` readers, then lookup parameters and the `resource` a Context declares, then default fields; a Context identity is read directly from args without resolving the Context), then asks `remoteAccess.decide({ principal, action: endpoint, resources, args })`; invalid resources are refused with `resource-invalid` without asking `decide()`; a `deny` decision becomes the thrown error | `remote-access/denied` with details `{ action, reason }` | +| 5 | Dispatch: `resolveReceiverContext` (which can activate an Agent), argument decoding, and the method call | Existing codes unchanged, such as `gateway/lookup-not-found`, `gateway/context-not-found`, `gateway/input-invalid`, `gateway/service-unavailable`, `gateway/method-unavailable`, and business errors | + +The access check (steps 3–4) precedes lookups and argument decoding, so a refused call activates no Session and causes no other side effect. The only new Gateway error code is `gateway/peer-disposed`; remote-access declares the refusal code `remote-access/denied`. + +Gateway-owned endpoints: a non-owner may subscribe to `$events` but never receives waterfall frames it could answer; `$events/result` from a non-owner is refused with the existing `gateway/invocation-unavailable`; both run the step-1 check. + +#### Forwarded events + +An `emit` entry of `API_REMOTE_FORWARDED_EVENTS` may declare `resources`: readers keyed by resource kind and typed by that event's own parameters through `TypertForwardableEventEntry`, for example `{ event: 'api-session/added', mode: 'emit', resources: { session: (summary) => summary.sessionId } }`. API Remotes attaches the readers to each forwarded frame; for every non-owner `$events` stream the Gateway resolves the resources once per frame and pushes the frame only when `remoteAccess.decide({ principal, action: 'event:', resources })` allows it. + +| Rule | Content | +|---|---| +| Declared events | `api-session/activity`, `api-session/added`, `api-session/error`, `api-session/removed`, and `api-session/status` declare their Session | +| Undeclared resources | An event without `resources`, or with an empty `resources: {}`, touches `global` and reaches a non-owner only when a `global` role lists `event:` | +| Missing resource | An event carries every resource it declares, so a reader returning `undefined` makes the event's resources invalid, and the Gateway delivers it to no non-owner without asking `decide()`; a call missing a resource touches `global` instead | +| Whole or nothing | An allowed event arrives with its payload unchanged; no payload is trimmed or projected per Peer | +| One resource per event | A notification carries at most one resource; collection changes travel on follow streams (`session/follow`, `session/control`, `workspace/follow`), whose methods decide each item inline through `allows()` | +| Waterfalls | Non-owners never receive waterfall frames, and no policy decides them | +| Non-owner stream facts | A non-owner `$events` stream opens with the same `host` as an owner stream, including `home`; `hasLiveClient()` counts only owner streams, the ones that can answer waterfalls | + + +#### List and aggregate methods + +Calls of list, paging, and aggregate methods resolve to `global` and are allowed by a `global` role; inside, the method calls `this.ctx.remoteAccess.allows(resource)` for every item to decide whether the current call's action is granted on that resource (without args). A list action therefore appears both in a `global` role (allowing the call) and in resource roles (allowing each item): `session/list` must be granted on `global` to be called, and each Session row also requires `session/list` granted on that Session. `allows()` reads the invocation on the Context that accessed the service; it is always true for the owner and for in-process code outside a Remote call, which are not filtered. + +| Method | Filtered items | +|---|---| +| `session/list` | Session rows (`session` resource) | +| `session/control` | baseline projections and projection increments (`session` resource) | +| `workspace/follow` | Workspaces in the baseline, `upsert` frames, and `order` ids (`workspace` resource), the Sessions inside Workspaces, and the archived / pinned Session sets (`session` resource). The stream tracks the Workspaces it delivered; when one is no longer allowed at its next change or in a new order, it sends a `replace` increment with the complete filtered state instead of a `remove`, because the Client never resurrects a removed id, and the Workspace reappears through an ordinary `upsert` once access returns. A `remove` frame reports a deletion and reaches only a caller that received that Workspace | + +#### Access snapshot stream + +```text +// Remote: remoteAccess/follow (stream; every Peer may read its own snapshot) +type RemoteAccessFrame = + | { readonly type: 'baseline'; readonly owner: true } + | { readonly type: 'baseline'; readonly owner: false; readonly grants: readonly RemoteAccessGrant[] } + | { readonly type: 'upsert'; readonly grant: RemoteAccessGrant } + | { readonly type: 'remove'; readonly resource: RemoteAccessResource } + +interface RemoteAccessGrant { + readonly resource: RemoteAccessResource // global, or one declared-kind resource + readonly actions: readonly string[] // endpoint actions allowed on this resource without args, sorted +} + +/** Window global carrying a page's opening baseline. */ +export const REMOTE_ACCESS_SNAPSHOT_KEY = '__DSH_REMOTE_ACCESS__' +``` + +| Behavior | Detail | +|---|---| +| First snapshot | `snapshotFor(principal)` computes a page's opening baseline. remote-access adds the global `REMOTE_ACCESS_SNAPSHOT_KEY`, set to the owner baseline `{ type: 'baseline', owner: true }`, to every index the webServer renders, through `webserver/index-inject`. A business package that renders a non-owner page (such as liveshare on the owner Host) places an assignment of that global to `snapshotFor(principal)` after every index injection row; the later assignment wins, and the Client reads the global when it mounts | +| Subscription | Each Peer subscribes to one access snapshot stream, whose baseline replaces the injected one | +| Computation | All bindings of the policy for the principal's kind are merged by equal resource; each resource's `actions` are the endpoint actions of those bindings' roles that the same decision as a call allows (one resource, without args, conditions called without args); only a resource with at least one allowed endpoint action gets a grant. Decisions and snapshots share one source: for the same principal, action, and resource (without arguments), the snapshot answer equals `decide()` | +| Endpoint actions only | `event:` actions do not enter the snapshot; the frontend decides entry visibility from endpoint actions only | +| Increments | `invalidate()` or a policy registration or removal recomputes and pushes `upsert` / `remove`, and a resource whose allowed set becomes empty pushes `remove`; unchanged resources push nothing | +| Lifetime | A generation ends when its signal aborts or when the remote-access service unloads, so a reloaded service answers every reader from a fresh baseline; the Client treats the end as a carrier loss and reconnects | +| Owner | Receives only `{ owner: true }`, so every frontend answer is true | +| Wire type | `actions` is `readonly string[]`: Typert cannot generate a codec for template-literal types | + +Access hooks judge only by action and resource and never look at call arguments; at call time a condition may also read args for a finer decision, and the Host result prevails. + +#### Client face + +The Client uses framework standard-hook injection and no React context. Components receive hooks only through props and import no remote-access value. + +```text +// @deepseek-ai/dsh-api-remote-access/client +interface ClientRemoteAccess { // Client service ctx.remoteAccess + source(kind: TypertResourceKind, id: string, action: string): HostObservable +} +GlobalStandardProps.useRemoteAccess: (action: RemoteEndpoint) => boolean // remote-access; the global resource + +// @deepseek-ai/dsh-client-ui-session (injects remoteAccess) +GlobalStandardProps.useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean +SessionStandardProps.useAccess: (action: RemoteEndpoint) => boolean +SessionMaybeStandardProps.useAccess: (action: RemoteEndpoint) => boolean | undefined + +// @deepseek-ai/dsh-client-ui-workspace (injects remoteAccess) +GlobalStandardProps.useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean + +// RemoteEndpoint = Extract +``` + +| Concern | Decision | +|---|---| +| Snapshot and mount | The remote-access Client plugin starts from the baseline the index injected (read synchronously at startup and validated), follows the snapshot stream, applies each baseline in one pass, provides the Client service `ctx.remoteAccess.source(kind, id, action)` (an identity-stable boolean source), and injects `useRemoteAccess` on the `global` resource. The entry module imports the generated `/remote` contribution and hands it with the injected baseline to `mountAccess(ctx, contribution, initial)`, which mounts it through `ctx.remote.$mount` (unit specs pass a fake contribution), following experimental client-ui-cot-translation, voice-input, and claude-code-mods; a central mount in api/remotes would form the project-reference cycle ui-session → api/remotes → remote-access → ui-session | +| Session resource | ui-session gives the bound Session's answer through `uiSession.provide({ keyedHooks: ['access'], resolve })`; the framework binds the Session as it does for `useProjection`. `useSessionAccess` is injected through `provideRoot` | +| Workspace resource | ui-workspace injects `useWorkspaceAccess` through `provideRoot` | +| Distinct names | The `global` answer is `useRemoteAccess` and the Session answer is `useAccess`, so neither shadows the other; in a session-maybe scope without a Session, `useAccess` is `undefined`, which callers treat as not allowed, and a `global` decision reads `useRemoteAccess` | +| Two-argument calls | Framework keyed hooks take a standard second argument: `KeyedStandardSource` is `(key, member?) => source`; a string second argument makes the hook read the resolver's `(key, member)` source without a selector, and the resolver returns the same source for the same arguments. Existing `(key, selector?, equal?)` and `(selector, equal?)` calls are unchanged. Curried forms are forbidden | +| Return value | Every answer on a resource is a boolean; action types come from the `ctx.remote` namespaces and methods | +| No fallback | Asking a Session resource about a non-session method (such as `useAccess('session/create')`) reads the decision on that Session resource and never falls back to the `global` answer | +| No snapshot | True for the owner, also after its stream fails terminally; a non-owner page without an injected baseline, or whose stream fails terminally, answers false, and the terminal failure is reported with its error and the advice to reload; a reconnect keeps the last snapshot; a malformed injected baseline fails the mount; the Host still checks every call. Hooks never issue requests | +| Example consumers | The ui-workspace Session row menu's fork entry reads `useSessionAccess(sessionId, 'session/fork')` and is not rendered when it is false; the ui-chat turn tail's branch button reads `useAccess('session/fork')` on the Session resource and is not rendered when it is false. Replacing #3854's `clientWritePolicy` stays with #3854 | +| Tests | `@deepseek-ai/dsh-client-test-runtime` exports `allowAllAccess` props and `allowAllAccessPlugin`; TestClient drops the remote-access row as it drops api-remotes and installs the allow-all service, and SlotTestRuntime installs it before ui-session | + +### Security invariants + +| Invariant | Basis and enforcement point | +|---|---| +| Only in-process Host code supplies a principal, when it creates a Peer | `PeerScope.principal` is read-only; `createPeer()` has no route | +| No wire field can set or override a principal | Principals never come from args, the envelope, headers, the query, or cookies. Extra top-level args fields fail `assertExactArguments`; the payload must be exactly `{ args }`; [`clientRequestSchema`](../../../../packages/client/connection/src/rpc-schema.ts) drops unknown envelope fields; a same-named field inside a request body is business data | +| Browser network admission yields the owner | Connections that pass [browser token authentication](../../implemented/architecture/2026-08-24-browser-token-authentication.md) receive `browserPeer` | +| No implicit Peer | Neither the Gateway nor Connection supplies a default Peer; required parameters expose a missing Peer at compile time | +| A disposed Peer cannot dispatch | `gateway/peer-disposed` | +| A non-owner cannot call without authorization | A call is allowed only when a binding's role explicitly lists the action and its resource matches; a kind without a policy is refused; the Gateway depends on `remoteAccess` and does not start without it | +| A `global` grant implies no resource grant | Resources do not inherit; `allows()` inside a method decides each item independently | +| An unknown principal kind is not the owner | Only `kind === 'owner'` is the owner | +| Non-owners answer no waterfall | `$events` gives non-owners no waterfall frame, and `$events/result` refuses them | +| A non-owner receives only authorized notifications | Each forwarded frame is decided by `event:` and its resources, and reaches a non-owner only when allowed | + +### Recording the message source + +The `MessageSourceMap` entry in the [session-controller types](../../../../packages/api/session-controller/src/types.ts) gains an optional field: + +```text +'user-rpc': { + kind: 'user' + rpcId: SessionRequestId + clientTimeZone?: string + /** Non-owner caller that submitted this prompt; absent means the owner. */ + principal?: Principal +} +``` + +Two producers build a `kind: 'user'` + `rpcId` user-message source inside a Remote call. Both call `principalOf(this.ctx.invocation)` in the Remote method and write `principal` only when it is defined: + +| Producer | Location | +|---|---| +| `SessionController.prompt()` builds the source and `SessionCommandController.prompt(request, source)` only delivers it | [`session-controller/src/index.ts`](../../../../packages/api/session-controller/src/index.ts), [`commands.ts`](../../../../packages/api/session-controller/src/commands.ts) | +| subagent `prompt()` (`BrowserPromptSource`); no role lists it, so a non-owner reaches it only through in-process Host code, not through the Gateway | [`subagent/src/index.ts`](../../../../packages/subagent/subagent/src/index.ts) | + +Business code does not pass a principal down as a function parameter: the identity is read once from the invocation in the Remote method, and the built source travels down. The Agent inbox has no interception point that could add the field on enqueue (`followup` / `steer` accept built messages, and in-process producers such as the SDK and scheduling run outside Remote calls), so each producer records it, and the code-review rule covers new producers. + +| Fact | Detail | +|---|---| +| `kind` is unchanged | Still `'user'`; existing `kind === 'user'` checks are unaffected | +| Owner messages are unchanged | An owner call writes no field, so its source is byte-identical to the source before principals existed; a message without the field (including every older Session) is the owner's | +| Not part of model requests | LLM adapters read only the model source ([`llm-deepseek/src/replay.ts`](../../../../packages/llm/llm-deepseek/src/replay.ts)) | +| Session format | `SESSION_FORMAT_VERSION` is not bumped; per the [persistence type-change cookbook](../../../../docs/cookbook/reviewing-persistence-type-changes.md) the change records an acknowledgement | + +Example consumer use, outside this Note's scope: tool guards and pre-step rechecks read `source.principal` of the user messages in a turn and judge by the non-owner caller when any message carries one; a turn mixing owner and viewer messages takes the strictest. + +### Code-review rule and documentation + +The review rule's home is the "`@Remote` marks capability methods" section of [Remote and API rules](../../../../docs/defensive-patterns/remote-and-api.md): `@Remote({ resources })` and automatic resolution must cover every resource a method touches; aggregate results filter every item through `allows()`; the caller identity is read only from `invocation.peer.principal`, never from arguments; every call path names its Peer explicitly; business code does not pass a principal as a function parameter. + +| Document | Content | +|---|---| +| [`docs/subsystems/typert.md`](../../../../docs/subsystems/typert.md), [`docs/api-gateway.md`](../../../../docs/api-gateway.md) | `PeerScope`, `RemoteInvocation`, `InvokeRemoteRequest`, explicit Peers, `resources` on `@Remote`, and the access-check order | +| READMEs of connection, gateway, typert-protocol, typert-registry, api-remotes, remote-access, session-controller, workspace-controller, ui-session, ui-workspace, ui-slots, and client-test-runtime | Peer / `browserPeer` in place of the operator; the remote-access role, binding, and policy contract; access hooks | + +### Out of scope and future work + +- The `/connect` handshake, API cookies, one Peer per API connection, and WebSocket lifetimes belong to pr-5945. +- Multiple browser Peers (such as one per tab): every browser connection shares `browserPeer`. +- Interaction participation (interactions) and approval isolation by principal come with later work; non-owners currently receive only authorized `$events` broadcast frames. +- `agent-preset/selected` and the other forwarded events declare no resources and are decided on `global`; when its viewers need a Session event, #3854 declares that event's resources and lists `event:` in a role. +- Liveshare integration (declaring `share-viewer`, one Peer per viewer connection, defining roles and registering a policy, removing `origin` / `prompt/origin` / `READ_ENDPOINTS`) happens on the #3854 side. +- `session/cancel` ownership checks, the fork `childSessionId`, and `ToolExecutionInput.turn` do not change. +- Precise resource recognition (for example, the Typert generator locating request fields by branded id type and writing them into descriptors): this change uses the `resource` that lookups and Contexts declare, default fields, and `resources` readers, and resource resolution can later be replaced without changing the policy interface. +- Answering 403 to every non-owner on exact Fetch routes is temporary; each route is to declare its own access rule later. +- The resource declaration of subagent `prompt` is open: its request fields are `parentSessionId` / `childSessionId`, so it currently resolves to `global`. The producer records `source.principal` for in-process callers; no role lists it, so non-owners cannot reach it through the Gateway. +- Predefined and aggregate roles: when a second non-owner principal kind appears, domain packages declare the generic roles a method belongs to (such as `session:viewer`) with `@Remote({ roles })`, and each policy binds those roles; roles are currently defined by the business package as explicit action lists. + +### Relation to existing Notes + +- [Remote duplex stream](../../implemented/architecture/2026-09-19-remote-duplex-stream.md): partially superseded. The replaced parts are "the Peer records no identity or permission", "this Host has exactly one Peer, the operator", "an in-process carrier without a Peer means the operator", and its alternative stating that "the Access mechanism is business semantics"; the rest (the duplex stream, uplink, and `ctx.invocation` binding) stands. Both Notes stay active and cross-linked, and that Note's current facts are updated. +- [Browser token authentication](../../implemented/architecture/2026-08-24-browser-token-authentication.md): unaffected. Its rejection of per-method privilege concerns owner authentication; an authenticated browser is still the owner and is not checked per method. +- [Typert Remote method calls](../../implemented/architecture/2026-08-02-typert-remote-method-calls.md): unaffected; `@Remote` and `@RemoteScope` gain optional `resources` readers (the second argument of `@RemoteScope` may be `{ name?, resources? }`), read only by the Gateway's access check, and change neither endpoint naming, call modes, nor generated artifacts. +- [Producer-owned message sources](../../implemented/architecture/2026-09-09-producer-owned-message-sources.md): unaffected; the producer that builds a source writes `principal`. + +## Alternatives considered + +**A separate `prompt/origin` event correlated by requestId (the #3854 approach).** Every prompt writes a required event, so even ordinary Sessions become unreadable by older versions; correlation serializes every prompt and maintains an origin fold, and messages outside the fold window lose their initiator. + +**Principal and peer as parallel invocation fields.** One caller's identity would have two sources; every admission point would have to produce and pass both, and a path that passes only one would mismatch them. + +**Keep "the Peer records no identity".** When business plugins attach identity through `peer.ctx`, every reader must know who attached it and how to read it, and the wire admission path cannot guarantee the attachment happened. + +**Keep a default-Peer fallback (`request.peer ?? …`).** An implicit default silently grants owner identity to a call that forgot its Peer; a required parameter turns the omission into a compile error. + +**A new source kind.** Every existing `kind === 'user'` check would need review, and one miss makes viewer messages disappear from titles, outlines, queues, or chat rendering. + +**Identity in a request-body field.** Any caller able to build a request body could forge or override it. + +**React context for frontend resources.** Business components read no React context; the framework binds standard hooks per resource, and components receive them only through props. + +**Driver-maintained endpoint allowlists that trim results (the #3854 approach).** The allowlist and the result trimming both live in the driver, the Gateway does not enforce them, and the snapshot and the frontend each derive their own; the generic layer enforces role action lists, and `allows()` inside the method performs list filtering. + +**Business packages implement `decide()` / `abstain` providers whose answers are merged.** Each business package hand-writes action × resource branches plus a separate resource list for snapshots, and the two stay consistent only by hand; a policy gives only bindings, decisions and snapshots are computed from the same bindings, and each principal kind has one policy, so no merge rule is needed. + +**A separate `@RemoteAccess` marker.** Role action lists already guarantee default deny, so a marker is only a second consent; a method that resolves no resource falls to `global`, and only an explicit global grant allows it. Resource readers describe the method, so they live on `@Remote`. + +**Calling resource-less calls `page` or `host`.** `page` describes a frontend location, not a resource; `host` reads easily as the parent of every resource on this Host. `global` means a grant tied to no resource. + +**Resource inheritance (GCP style: a grant on `global` or on a Workspace covers lower resources).** List methods allowed by a `global` grant must decide each item, and inheritance would make any `global` grant imply every Session; without inheritance, a binding grants each resource explicitly. + +**A role registry and predefined aggregate roles.** The only non-owner principal today is managed by one business package, so roles can be plain values of that package; aggregate roles would let methods a new package declares enter a share viewer's reach automatically, so they wait for a second non-owner principal kind. + +**Conditions attached to a role's actions.** A role stays reusable plain data; a refinement by business state belongs to the binding that grants the role to a principal. + +**Refusal reasons from conditions only.** Without a binding there is no condition to give a reason, so a viewer page could not tell "share expired" from "share not writable"; `explain` gives a reason only after the decision is already a refusal and never changes it. + +**A global resource-resolver registry (domain packages register recognizers by field name).** Field recognition separates from the method, nothing flags a method signature change, and same-named fields in other packages are misread; resource resolution uses a default convention on the method plus per-method `resources`, and a domain package registers only its own kind's default field. + +**Dot-path resource declarations (`'request.address.sessionId'`).** Types cannot constrain a path; the parameter types of `resources` readers are inferred from the decorated method, so a typo fails compilation. + +**Generator recognition of resource fields by branded id type.** Requires changing Typert artifacts and the descriptor format, and SRC descriptors carry no types; kept as future work. + +**A central mount of the remote-access generated contribution in api/remotes.** The remote-access Client depends on ui-session and ui-session depends on api/remotes, so a central mount forms a project-reference cycle; remote-access mounts its own contribution instead. + +**Decisions inside the Gateway (a `check()` with rules written in the Gateway).** The Gateway would carry access-control semantics and extra error codes; it resolves resources as it resolves lookups and calls `remoteAccess.decide()` before dispatch, and the rules and refusal code belong to remote-access. + +**Asynchronous policy decisions.** List filtering and snapshot computation need synchronous answers; business packages preload asynchronous state into memory. + +**On-demand permission queries from the frontend.** Request volume grows with the number of entries and cannot follow rule changes; the Host pushes a snapshot stream and the frontend reads it synchronously. + +**An access check after lookup resolution.** A lookup can activate an Agent, so an unauthorized call would cause side effects first. + +**Resource fields registered in remote-access (`registerScope`).** The access package would recognize argument fields although lookups already know which wire field names a Session; typert holds resource kinds beside lookups and Contexts, so the Gateway resolves calls and events in one place and remote-access reads no argument field. + +**Trimming forwarded event payloads per Peer.** A per-event projection would repeat list filtering inside every notification; an event carries at most one resource and is delivered whole or not at all, while collection changes travel on follow streams that filter inline. + +**Answering true until the first snapshot arrives.** A non-owner page would briefly show entries it cannot use; the index carries the first snapshot, so a page without one has no access. + +## Consequences + +### Verification + +| Case | Behavior | Test location | +|---|---|---| +| The wire tries to set a principal (top-level args, a payload sibling, the envelope, inside the request body) | The first two fail argument or payload validation; the rest are dropped or treated as business data; `invocation.peer` remains the admitted Peer | Gateway host spec | +| Every call path names its Peer | `InvokeRemoteRequest.peer`, the `wireStream.open` peer, `fetch(request, peer)`, and the `bridge` peer are required; the Gateway has no fallback; the `/api` route passes `admission.peer` | typecheck, Connection host spec | +| Browser connections and `createPeer()` | `browserPeer` has the owner principal; each `createPeer()` returns an independent Peer released with the Context that read Connection; exact routes answer 403 to non-owners | Connection host spec | +| Disposed Peers | Later unary, stream, and `$events` dispatches fail with `gateway/peer-disposed`, also when the wire payload is malformed; its streams end; a running unary call completes; other Peers are unaffected | Gateway host spec | +| Check order | The owner calls any method normally without reading a policy; a method no role lists refuses non-owners with `remote-access/denied` (`no-allow`); the access check precedes lookups, so a refused call activates no Session | Gateway host spec | +| Policy registration | One policy per non-owner principal kind, and a second registration throws; once the disposer withdraws it, that kind is refused with `no-allow`; it is withdrawn with its fiber | remote-access host spec | +| Decisions | Every resource needs a matching binding (AND); a role without the action, an unequal resource, or a refusing condition does not allow; the refusal reason follows condition > `explain` > `no-allow`; `explain` never changes the decision; conditions receive args for calls but not for snapshots and list filtering; a throwing `bindings()`, condition, or `explain()` refuses with `policy-failed`, each policy is logged once between `invalidate()` calls, and on the event path the owner stream and the shared event source continue; `remoteAccess/follow` is allowed for every principal | remote-access host spec, Gateway host spec | +| Resource resolution | A default field (top-level or first object argument) yields its resource; a lookup parameter that declares that kind as its `resource` yields its wire identity; a Context method gets its Session resource from the Agent Context identity in args without activating the Agent, and may declare other resources through `resources`; a `resources` reader overrides its kind, an array yields several resources, and `undefined` none; with nothing the call is `[global]`; a throw, an empty array, or a value that is not a non-empty string (including a Context identity) is invalid, refused by the Gateway with `resource-invalid` without asking `decide()`; a Context that declares no `resource` resolves by the ordinary rules | Gateway host spec (gateway-access) | +| Resource registry | `typert.resources` registers one default field per kind, rejects a second registration and `global`, and withdraws the registration with its fiber; a configured lookup resolver keeps the provider's `resource`; a Host Context adapter keeps its `resource` | typert-registry spec | +| Forwarded events | A non-owner `$events` stream receives an event, whole, only when `event:` is granted on its resources; an event that declares no resources is decided on `global`; an event whose reader returns `undefined` does not reach non-owners, and `decide()` is not asked; the owner receives all | Gateway host spec (gateway-access) | +| First snapshot | `snapshotFor()` returns the owner baseline or the principal's grants; remote-access injects the owner baseline into every rendered index; the Client starts from the injected baseline and rejects a malformed one | remote-access host and client specs | +| Type inference | The parameter types of `resources` readers are inferred from the method; lookup positions have wire types; a missing property, more parameters than the method has, and a `global` key fail compilation; a binding whose resource kind differs from its role's fails compilation | typert-protocol spec, remote-access spec (type tests) | +| List filtering | `session/list`, `session/control`, and `workspace/follow` return non-owners only items for which `allows()` is true; `workspace/follow` sends a `replace` increment when a delivered Workspace loses visibility, and the Workspace reappears once access returns; owner results are unchanged | session-controller / workspace-controller host spec | +| Access snapshots | The allowed set in a snapshot equals the Host decision for the same principal, action, and resource without arguments; it contains endpoint actions only; a resource with no allowed action gets no grant, and one whose allowed set becomes empty is removed; `invalidate()` and policy registration or removal push increments; a wait that a change ends leaves no abort listener behind; unloading the service ends every open snapshot stream; the owner receives only `{ owner: true }` | remote-access host spec | +| Access hooks | `source()`, `useRemoteAccess`, `useAccess`, `useSessionAccess`, and `useWorkspaceAccess` follow the snapshot; `useAccess` on a Session resource reads that Session's answer; the owner answers true; a non-owner without a snapshot or after its snapshot stream fails answers false; hooks issue no requests | remote-access client spec, ui-session / ui-workspace client spec | +| Two-argument keyed hooks | A string second argument reads the `(key, member)` source; existing call forms are unchanged | ui-renderer client spec | +| A non-owner dispatches `session/prompt` | The recorded user message's `source.principal` equals that principal, and a cold restore reads the same value | session-controller host spec | +| An in-process non-owner invocation of subagent `prompt` | The child Session's recorded user message has `source.principal` equal to that principal | subagent host spec | +| Owner messages | The source is byte-identical to the source before this change; existing keyless recorded-session snapshots are unchanged | `pnpm run test:snapshot` needs no re-recording | +| Persistence record | `verify-persistence-changes` passes | doc-sync | + +The SDK projections are unaffected: the TypeScript SDK server builds its own `{ kind: 'user' }` messages ([`sdk/server/src/server.ts`](../../../../packages/sdk/server/src/server.ts)) outside the Gateway and does not project the source; the Python SDK does not read the user source. + +### Costs and limits + +- The change spans protocol, Connection, Gateway, two domain controllers, subagent, a new package, and the frontend, with breaking API renames; it necessarily conflicts with pr-5945 in Connection and the Gateway, handled by the migration list. +- The Gateway depends on `remoteAccess`: a custom composition that mounts the Gateway without remote-access leaves the Gateway waiting. +- Session logs store viewer identities and travel with exported or shared Sessions, so no principal kind may carry a credential. +- Every browser connection shares one Peer, so disposal cannot distinguish tabs until multiple browser Peers exist. +- The default field convention reads by field name: a method that gains a same-named field touches that resource, which review must watch; precise recognition is future work. +- A method that resolves only some resources lets a principal exceed its grant: unresolved resources go unchecked, and the method author ensures `resources` is complete. +- Role actions are strings and are not checked against loaded endpoints: a misspelled or renamed action never matches and shows up as a refusal. +- `api-session/added` carries the whole `SessionSummary`, so a non-owner allowed to receive it also learns the Session's `parentSessionId`. +- A granted Session's cwd and tool paths already contain the owner's absolute paths, so the Gateway does not hide `home` from non-owner `$events` streams separately. +- `workspace/follow` notices a Workspace whose visibility a policy revoked only at that Workspace's next change or the next order change, and then sends the non-owner a `replace` increment; an access change alone sends no frame. + +### Migration list for pr-5945 + +| File | Migration | +|---|---| +| `packages/client/connection/src/peer.ts` | Replaces `operator-peer.ts`; construct `OperatorPeer` as `new Peer(owner, { principal })` | +| `packages/client/connection/src/rpc-host.ts` | `this.operator` becomes `browserPeer` with principal `{ kind: 'owner' }` | +| `packages/client/connection/src/rpc.ts` | `HostConnectionHandle` merges `admitConnection` with `browserPeer` / `createPeer` | +| `packages/api/gateway/src/index.ts` | Replace `peer ?? this.operatorPeer()` where `$events` opens with an explicit Peer; reuse `gateway/peer-disposed` | +| The operator-connect Note, upgrade guide, and subsystem changes | Rewrite operator terms as Peer / `browserPeer`; an API connection's Peer has principal `{ kind: 'owner' }` | diff --git a/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md b/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md new file mode 100644 index 0000000000..714348f3b2 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md @@ -0,0 +1,670 @@ +# Agent Note: Peer 身份属性与 Remote 方法级访问控制 + +Status: implemented + +[English](2026-10-12-peer-principal-and-remote-access.md) | 中文 + +## 问题 + +Host 上的 Remote 方法此前无法得知本次调用代表谁,也无法按调用方限制可调用的方法。[`PeerScope`](../../../../packages/typert/protocol/src/types.ts) 的契约曾写明「Who the Peer is and what it may do are not recorded here」;[Connection](../../../../packages/client/connection/src/rpc-host.ts) 只有一个 operator Peer,`admit()` 对每个通过认证的请求都返回它,`/api` 路由与 interceptor 的 Fetch handler 固定使用它而不使用准入结果;[Gateway](../../../../packages/api/gateway/src/index.ts) 在调用方未给出 Peer 时隐式回退到 `operatorPeer()`。 + +首个非 owner 调用方是 liveshare 读者。读者请求经 Relay 到达 owner 的 liveshare Host 插件,插件此前在 owner 进程内以 operator 身份分派,owner 的 Session 因此把读者的 prompt 当作 owner 本人的输入。PR #3854 为此把 liveshare 语义塞进 session-controller:请求体携带 `origin`,每条 prompt 都写 required 的 `prompt/origin` 事件并按 requestId 关联,controller 内做来源 fold,`serializeAdmission` 扩到全部 prompt,新增 `api-session/external-admission` 钩子;liveshare 自己维护接口白名单 `READ_ENDPOINTS` 并裁剪 list/follow 结果;前端的 `clientWritePolicy` 定义在 ui-conversation,5 个包三种读法,7 处靠「是否注入某回调」隐藏界面。 + +需要一套通用机制: + +- 调用方身份由 Host 进程内代码赋予,wire 上任何字段都无法设置或覆盖。 +- 每条调用路径都显式给出 Peer,不存在隐式兜底。 +- 方法级权限在后端一处计算、一处把关;前端读同一份结果决定入口显隐,规则变化时自动同步。 +- prompt 投递时身份随用户消息落盘,供之后按 turn 判定发起者。 + +## 决策 + +### 范围 + +| 部分 | 内容 | +|---|---| +| Peer 身份 | `PeerScope.principal` 与 `PeerScope.signal`;唯一 Peer 实现 `Peer`(`OperatorPeer` 改名)与 options 构造;删除 operator 概念与所有隐式 Peer 兜底;`connection.browserPeer`、`connection.createPeer()` 与 `fetch(request, peer)`;已 dispose 的 Peer 一律以 `gateway/peer-disposed` 拒绝;`user-rpc` source 落盘 `principal?` | +| 方法级访问控制 | `@Remote` 的 `resources` 读取函数、`TypertResourceMap` 资源种类与 `typert.resources` 注册表(typert-protocol、typert-registry);Gateway 把调用与转发事件解析为资源;新包 `@deepseek-ai/dsh-api-remote-access`(Host 面 + Client 面):按 role、binding 与 policy 的 RBAC 判定 `decide(request)`、列表过滤 `allows()`、index 中的首份快照与访问快照流、access hooks | +| 文档 | 一条代码评审标准与术语 | + +### 两层概念:principal 与 peer + +| 层 | 概念 | 例子 | 数量关系 | +|---|---|---|---| +| 授权层 | principal:调用方身份,权限判定的依据 | 一份分享授权 token 证明的 `share-viewer{shareId, actor}`;本机用户 `{ kind: 'owner' }` | 一个 principal 对应 N 个 peer | +| 连接层 | peer:一条连接的生命周期作用域 | 浏览器连接、liveshare 读者的一条连接 | 每条连接一个 peer | + +| 事项 | 归属层 | +|---|---| +| 方法级权限判定、access hooks、访问快照 | principal | +| `source.principal` 落盘 | principal | +| 订阅、stream、连接期注册(`peer.ctx`) | peer | +| 撤销授权 | 两层:撤权后 policy 不为该 principal 给出 binding,其调用一律被拒;创建方 dispose 其名下全部 peer | + +principal→peers 的对应表由创建方持有,例如 liveshare 每份授权一个对象,记录该授权下建立的全部 peer。 + +### Peer 模型 + +只有 Peer 一个概念、一个实现,删除 operator 概念。 + +```text +// @deepseek-ai/dsh-typert-protocol +export interface PeerScope { + readonly id: PeerId + /** Connection-lifetime registrations; disposed with the Peer. */ + readonly ctx: Context + /** Caller identity fixed at creation by in-process Host code; no wire field can set it. */ + readonly principal: Principal + /** Aborted once the Peer is disposed; Remote streams that speak for the Peer end with it. */ + readonly signal: AbortSignal + dispose(): Promise +} + +// @deepseek-ai/dsh-client-connection +export class Peer implements PeerScope { + constructor(owner: Context, options: { readonly principal: Principal }) +} + +interface HostConnectionHandle { + /** The Peer every authenticated browser connection shares; principal { kind: 'owner' }. */ + readonly browserPeer: PeerScope + /** Network admission: the admitted Peer or the refusal status. */ + admit(request: ConnectionTrustRequest): PeerAdmission + /** In-process only; disposed with the Context that read `ctx.connection`, or earlier by dispose(). */ + createPeer(options: { readonly principal: Principal }): PeerScope +} + +interface ConnectionFetchHandler { + fetch(request: Request, peer: PeerScope): Promise +} +bridge(req, res, apiHandler, peer, maxRequestBodyBytes?) + +// @deepseek-ai/dsh-api-gateway +interface InvokeRemoteRequest { …; readonly peer: PeerScope } // was `peer?` +wireStream.open(endpoint, payload, uplink, peer: PeerScope, signal) // was `PeerScope | undefined` +``` + +| 规则 | 说明 | +|---|---| +| 每条路径显式给出 Peer | 网络路径由 `admit()` 的结果给出;进程内路径由调用方传入。Gateway 删除 `operatorPeer()` 与 `request.peer ?? …`,不再自建 Peer | +| 浏览器连接共享 `browserPeer` | 所有浏览器连接共享一个 Peer,每个使用处都显式引用 `connection.browserPeer`,不存在兜底变量;页内 Host(webworker-runtime)同样显式传 `browserPeer` | +| Host 进程内自动化 | 需要调用 Remote 方法时用 `createPeer({ principal })` 创建自己的 Peer。目前没有这类生产调用方,因此不预置固定的 Host Peer | +| 多 Peer | `createPeer()` 每次返回独立 Peer;liveshare 将用它为读者的每条连接建一个 Peer(在 #3854 侧接入) | +| exact Fetch route | file-upload、present-open、media-references、session-log-export、inspector 等 exact route 不经 Gateway;共享 Fetch handler 对非 owner Peer 一律返回 403 | + +### 身份类型 + +```text +// @deepseek-ai/dsh-typert-protocol + +/** + * Merge-extensible caller identities. Each driver declares its own entry. + * Every entry is a JSON value with a unique literal `kind`, carries no + * credential, and is safe to persist in a Session log. + */ +export interface PrincipalMap { + owner: { readonly kind: 'owner' } +} +export type Principal = PrincipalMap[keyof PrincipalMap] + +/** + * @param invocation - `ctx.invocation`; `undefined` outside a Remote call. + * @returns the caller's principal when it is not the owner; `undefined` for the + * owner and for trusted in-process code outside a Remote call. + */ +export function principalOf(invocation: RemoteInvocation | undefined): Principal | undefined +``` + +读取入口统一为 `this.ctx.invocation.peer.principal`;owner 判断就地写 `principal.kind === 'owner'`,未知 kind 永远不是 owner。外部 driver 在自己的包内用声明合并扩展 `PrincipalMap`;liveshare 的 `share-viewer{ shareId, actor }` 在 #3854 侧声明,此处不改 liveshare。 + +### Peer 生命周期与撤权 + +| 事件 | 行为 | +|---|---| +| Gateway 分派 | 第一步检查 `peer.signal.aborted`,已 dispose 即以 `gateway/peer-disposed` 拒绝。对 HTTP、WebSocket 与进程内路径都生效;wire 路径在读取 payload 之前检查,已 dispose 的 Peer 携带畸形 payload 也得到同一错误码 | +| Peer dispose 时的 stream | Remote stream 方法看到的 signal 并入 `peer.signal`,`$events` 流的生命周期同样并入;WebSocket 照旧以 1001 关闭 | +| Peer dispose 时的 unary | 已在执行的 unary 调用不被取消;之后的调用被拒绝 | +| 撤销授权 | 业务包撤权后 policy 不为该 principal 给出 binding,业务包调用 `invalidate(principal)`;创建方按 principal→peers 表 dispose 全部 peer | + +### 方法级访问控制 + +资源类型与 `@Remote` 的 `resources` 读取函数在 `@deepseek-ai/dsh-typert-protocol`;资源种类在 typert 中与 lookups、Contexts 并列:`TypertResourceMap` 声明种类,`typert.resources` 保存每个种类的默认 wire 字段,lookup 与 Host Context adapter 声明其 wire 身份所属的资源种类。Gateway 把一次调用或一个转发事件解析为它访问的资源。判定、列表过滤与访问快照在新包 `@deepseek-ai/dsh-api-remote-access`(`packages/api/remote-access`),该包不读取任何参数字段。Gateway 与领域包对 remote-access 只做 type-only 依赖并 inject `remoteAccess`,值依赖只有 `dsh-typert-protocol`,不为 remote-access 新增 `package-dependency-policy` 条目。role、binding 与 policy 由业务包定义和注册,liveshare 为首个使用方。除内置的 `global` 外,remote-access 与 Gateway 中不出现任何资源种类名,符合 [`packages/api` 只承载通用编排](../../../../docs/defensive-patterns/remote-and-api.zh.md) 的评审规则。 + +#### 与 RBAC 的对应 + +| 本设计 | RBAC / IAM 术语 | 含义 | +|---|---|---| +| principal | Subject / Principal | 调用方身份,`PrincipalMap` 的一个条目 | +| Peer | Session(RBAC 会话) | principal 的一条连接;一个 principal 对应 N 个 Peer | +| owner | 超级用户 | 跳过全部检查 | +| action | Action / Permission | Remote 端点 `ns/method`,或转发事件 `event:`;Gateway 内部仍称端点 | +| resource | Resource / Object | `{ kind: 'global' }`,或某个已声明资源种类的 `{ kind, id }` | +| role | Role | 限定一种资源种类的 action 集合,是业务包的普通值 | +| binding | RoleBinding(角色指派) | principal 在某个资源上拥有某个 role,可附 condition | +| condition | Condition(IAM Conditions) | 挂在 binding 上,按请求的 action、资源与参数放行或带原因拒绝 | +| policy | Allow policy | 业务包为一种非 owner principal kind 给出 binding 与可选 `explain` | +| explain | 拒绝说明 | 没有 binding 放行时给出拒绝原因,不改变结论 | +| Gateway | PEP(Policy Enforcement Point) | 解析资源、执行结论 | +| remote-access | PDP(Policy Decision Point) | 计算结论、列表过滤与访问快照 | + +| 规则 | 内容 | +|---|---| +| 默认拒绝 | 非 owner 只有在某个 binding 的 role 显式列出该 action 时才被放行;role 未列出的 action、没有 binding 的资源、没有 policy 的 principal kind 一律被拒。所有 Remote 方法都受访问控制,没有单独的可访问标记 | +| 资源之间无继承 | `global` 不是其他资源的上级:`global` 上的授权不蕴含任何会话或工作区上的授权,工作区上的授权也不蕴含其中会话的授权 | +| `global` 授权即显式全局授权 | 调用解析不出任何资源即访问 `global`。把某个 action 放进 `global` 级 role,等于允许该 principal 不受资源限制地调用它,属高危授权;方法被放行后仍可在内部用 `allows(resource)` 逐项细判 | + +#### 资源与资源读取(`@deepseek-ai/dsh-typert-protocol`、`@deepseek-ai/dsh-typert-registry`) + +```text +/** Merge-extensible resource kinds: the key is the kind, the value declares its identity type. */ +export interface TypertResourceMap {} +// @deepseek-ai/dsh-session/types: session: TypertResource +// @deepseek-ai/dsh-workspace/types: workspace: TypertResource + +// Runtime registries in typert +ctx.typert.resources.register(kind, { field }): TypertDisposer +ctx.typert.lookups.register(key, { parameter, wire, resource?, … }) // resource: the kind its wire id names +ctx.typert.contexts.registerHost(key, { wire, resource?, … }) // a TypertHostContextAdapter + +export interface TypertHostContextAdapter { + readonly wire: string + readonly wireTypeSymbol: string + /** Resource kind the wire identity names; access checks read the identity from args before resolve(). */ + readonly resource?: TypertResourceKind + resolve(id: Wire): Context | undefined | Promise +} + +export type RemoteAccessResourceKind = 'global' | TypertResourceKind +export type RemoteAccessResource = + | { readonly kind: 'global' } + | { readonly kind: K; readonly id: TypertResourceId } // for each declared kind K +export type RemoteAccessResourceOf = Extract + +/** Per-kind readers, called with the method's positional wire arguments. */ +export type TypertResourceReaders = { + readonly [K in TypertResourceKind]?: (...args: WireArgs) => Id | readonly Id[] | undefined // Id = TypertResourceId +} + +export interface RemoteMethodOptions { + readonly name?: string + readonly mode?: 'stream' + readonly resources?: TypertResourceReaders +} + +export function Remote( + option: string | RemoteMethodOptions>>, +): (method: (this: This, ...args: Args) => Result, + context: ClassMethodDecoratorContext Result>) => void + +export interface RemoteScopeOptions { + readonly name?: string + readonly resources?: TypertResourceReaders +} + +export function RemoteScope( + key: Extract, + option?: string | RemoteScopeOptions>>, +): (method: (this: This, ...args: Args) => Result, + context: ClassMethodDecoratorContext Result>) => void +``` + +| 规则 | 内容 | +|---|---| +| 写法 | `@Remote({ name: 'page', resources: { session: (request) => request.address.kind === 'session' ? request.address.sessionId : request.address.childSessionId } })`;Context 方法写 `@RemoteScope('agent')`,由 Agent Context 的 `resource: 'session'` 得到会话资源,需要其他资源时写 `@RemoteScope('agent', { resources: { workspace: (request) => request.workspaceId } })`。多数方法不写 `resources`,由自动解析得到资源 | +| 资源种类 | 资源种类由声明 id 类型的底层包合并:`session: TypertResource` 在 `@deepseek-ai/dsh-session/types`,`workspace: TypertResource` 在 `@deepseek-ai/dsh-workspace/types`。session-controller 引用 commands 包,若在 session-controller 合并,commands 要引用 session-controller,形成 project reference 环。`global` 由 remote-access 内置,没有 id,不进 `TypertResourceMap`;typert 保留该名,`typert.resources.register('global', …)` 抛错 | +| 默认字段与声明 | 拥有该种类的领域在 apply 中登记默认 wire 字段:session-controller 调用 `typert.resources.register('session', { field: 'sessionId' })`,workspace-controller 登记 `workspace` → `workspaceId`;同一种类重复登记即抛错。Agent 与 Session lookup 声明 `resource: 'session'`,agent 包的 Agent Context 声明 `resource: 'session'` | +| 解析优先级 | 对每个资源种类依次取:① 该方法 `resources` 中该种类的读取函数;② 声明了该种类 `resource` 的每个 lookup 参数贡献其 wire 身份,省略的可选 lookup 参数不贡献;Context 方法的 Context 声明了该种类 `resource` 时,其 wire 身份同样贡献;③ 该种类的默认字段:命名 wire 参数中有同名顶层参数就读它,否则读第一个对象参数的同名字段。默认字段只表示一个身份,读到数组即为非法值 | +| 解析结果 | 各种类的资源合在一起即本次调用访问的资源;一个都没有即 `[global]`;解析出资源时只用这些资源,不附加 `global` | +| 读取函数 | 以方法的位置参数调用:按描述符把命名 wire 参数映射回方法参数顺序(未解码 JSON,lookup 位为 wire 身份,取消参数位为调用的 signal);返回一个 id、id 数组(每个都须被允许)或 `undefined`(该种类无资源) | +| 非法值 | 读取函数抛错或返回空数组,或读到的值(含 Context 的 wire 身份)不是非空字符串即资源非法:Gateway 不询问 `decide()`,直接以 `remote-access/denied`(reason `resource-invalid`)拒绝调用 | +| 完整性 | `resources` 与自动解析必须覆盖方法访问的全部资源。一个资源都没解析出的调用落到 `global`,只有 `global` 级 role 能放行,因而默认被拒;只解析出部分资源时,其余资源不受检查,持有已解析资源授权的 principal 可越权访问它们,由方法作者负责避免 | +| 类型推断 | `@Remote` 与 `@RemoteScope` 读取函数的参数类型从被装饰方法自动推断:工厂参数用 `NoInfer`,`Args` 只从返回的装饰器推断;lookup 参数按 `TypertLookupMap` 映射为 wire 类型(首个参数为 `agent: Agent` 的方法,其读取函数的第一个参数为 `SessionId`)。访问不存在的属性、参数多于方法参数与 `global` 键均为编译错误 | +| 存放 | `resources` 读取函数与 `@Remote` / `@RemoteScope` 的其他元数据一同存在类原型上,随实例构造登记,跨重复安装安全;不进入 Typert 生成物 | +| Context 方法 | `@RemoteScope(key, option?)` 与 `@Remote` 一致:字符串为导出名,对象为 `{ name?, resources? }`。Context 的 wire 身份按声明了 `resource` 的 lookup 参数参与解析,优先级与 lookup 相同,该种类有 `resources` 读取函数时被覆盖;Gateway 在 `resolveReceiverContext` 之前直接从 args 读取该身份,不激活 Agent。Context 未声明 `resource` 时其身份不贡献资源,方法按普通规则解析 | + +#### 端点的资源 + +下表列出 #3854 读者用到的端点及其资源来源。只有 `session/page` 与 `session/follow` 需要显式 `resources`,其余靠 lookup 的 `resource` 或默认字段: + +| 端点 | 资源 | 解析来源 | 方法内过滤 | +|---|---|---|---| +| `workspace/follow` | `global` | 无资源 | 是:工作区与会话 | +| `session/control` | `global` | 无资源 | 是:会话 | +| `session/list` | `global`(`_request` 无 `sessionId`) | 无资源 | 是:会话行 | +| `session/modelCatalog` | `global` | 无资源 | 否 | +| `session/canOpenWorkspacePath` | `global` | 无资源 | 否 | +| `llm/listConfigurableProviders` | `global` | 无资源 | 否 | +| `llm/listProviders` | `global` | 无资源 | 否 | +| `commands/list` | session:`args.agentId`(Agent lookup 的 wire 字段) | Agent lookup `resource: 'session'` | 否 | +| `goals/get` | session:`args.agentId` | Agent lookup `resource: 'session'` | 否 | +| `session/attachment` | session:`args.request.sessionId` | 默认字段 | 否 | +| `skills/list` | session:`args.request.sessionId` | 默认字段 | 否 | +| `session/fork` | session:`args.request.sessionId` | 默认字段 | 否 | +| `session/prompt` | session:`args.request.sessionId` | 默认字段 | 否 | +| `session/cancel` | session:`args.request.sessionId` | 默认字段 | 否 | +| `session/projections` | session:`args.request.sessionId` | 默认字段 | 否 | +| `session/page` | session:Session 地址读 `args.request.address.sessionId`;subagent 地址读 `args.request.address.childSessionId` | `resources` | 否 | +| `session/follow` | 同上 | `resources` | 否 | +| `remoteAccess/follow` | `global` | 无资源;remote-access 对任何 principal 放行 | 否 | + +subagent 地址读取的是子会话转录,因此其资源是子会话。Client 在重连后会重新读取已知会话的 projections,其中包括 subagent 会话的父会话;没有该父会话授权的读者,其 projection 读取被拒。父会话的授权是否覆盖其 subagent 子会话由 policy 决定,即是否为子会话给出 binding;通用层不推导父资源。 + +不需要非 owner 访问的方法不做任何声明,没有 role 列出它即被拒。subagent `prompt` 的请求字段为 `parentSessionId` / `childSessionId`,自动解析不到会话,解析为 `global`,因此不得放进任何 `global` 级 role;`session/search` 没有 role 列出,非 owner 不可达,因此不加过滤。`subagents/list` 不是 Remote 端点(`SubagentRuntime.list()` 未标 `@Remote`)。 + +#### role、binding 与 policy(`@deepseek-ai/dsh-api-remote-access`) + +```text +/** Remote endpoint `ns/method`, or `event:` for one forwarded event; endpoint segments never contain `:`. */ +export type RemoteAccessAction = RemoteAccessEndpoint | `event:${string}` + +/** A named action set over one resource kind; a plain value owned by the business package. */ +export interface RemoteAccessRole { + readonly name: string + readonly resource: K + readonly actions: readonly RemoteAccessAction[] +} + +/** Answer of decide() and of a condition. */ +export type RemoteAccessDecision = + | { readonly kind: 'allow' } + | { readonly kind: 'deny'; readonly reason: string } + +/** Refines one binding for one action on its resource; `args` only for a concrete call. */ +export type RemoteAccessCondition = (request: { + readonly action: RemoteAccessAction + readonly resource: RemoteAccessResourceOf + readonly args?: Readonly> +}) => RemoteAccessDecision + +/** One role on one resource of the role's kind; the resource type follows from the role. */ +export type RemoteAccessBinding = { + [K in RemoteAccessResourceKind]: { + readonly role: RemoteAccessRole + readonly resource: RemoteAccessResourceOf + readonly condition?: RemoteAccessCondition + } +}[RemoteAccessResourceKind] + +/** One access question; the Gateway passes only valid resources, [global] when none. */ +export interface RemoteAccessRequest

{ + readonly principal: P + readonly action: RemoteAccessAction + /** Allow only when the action is allowed on every listed resource. */ + readonly resources: readonly RemoteAccessResource[] + /** Named wire arguments of a concrete call, undecoded; absent for events, snapshots, and list filtering. */ + readonly args?: Readonly> +} + +/** Business rules for one non-owner principal kind; synchronous over in-memory state. */ +export interface RemoteAccessPolicy

{ + bindings(principal: P): readonly RemoteAccessBinding[] + /** Reason for a request no binding allows; never changes the decision. */ + explain?(request: RemoteAccessRequest

): string | undefined +} + +/** details of `remote-access/denied` */ +export interface RemoteAccessDeniedDetails { + readonly action: string + readonly reason: string +} + +class RemoteAccessService { // ctx.remoteAccess; Remote namespace remoteAccess + registerPolicy>(kind: K, policy: RemoteAccessPolicy): () => void + invalidate(principal?: Principal): void + decide(request: RemoteAccessRequest): RemoteAccessDecision // Gateway-only + allows(resource: RemoteAccessResource): boolean // reads the accessing ctx's invocation + snapshotFor(principal: Principal): RemoteAccessBaseline // a page's opening baseline + follow(signal): AsyncIterable // remoteAccess/follow +} +``` + +| 规则 | 内容 | +|---|---| +| owner | Gateway 对 owner 不解析资源、不调用 `decide()`,也不读取任何 policy | +| 注册 | `registerPolicy(kind, policy)` 返回 disposer,业务包在 `ctx.effect()` 中调用。每个非 owner principal kind 至多一个 policy,同一 kind 重复注册抛错;`bindings()` 与 `explain()` 收到的 principal 按 kind 定型 | +| 内置放行 | `remoteAccess/follow` 对任何 principal 放行,每个 Peer 都能读自己的快照 | +| 判定 | 没有该 principal kind 的 policy 以 `no-allow` 拒绝。否则取 `bindings(principal)`,对请求的每个资源线性扫描找一个 binding:资源相等(`kind` 相同,且非 `global` 时 `id` 相同)、role 的 `actions` 含该 action、没有 condition 或 condition 放行;每个资源都找到才放行(AND)。condition 只对资源相等且 role 含该 action 的 binding 调用 | +| 拒绝原因 | 第一个未被放行的资源决定原因,优先级为:该资源上存在匹配 binding 但其 condition 全部拒绝时,取第一个 condition 的原因;否则取 `explain(request)` 的返回值;没有 `explain` 或其返回 `undefined` 时为 `no-allow`。`explain` 只在结论已为拒绝后调用,不改变结论 | +| 失败隔离 | `bindings()`、condition 或 `explain()` 抛错时,本次判定以 `policy-failed` 拒绝;计算快照时 `bindings()` 抛错即该 principal 没有 grant,condition 抛错即该 action 不放行。有问题的规则因此不会破坏共享的 `$events` 事件源、快照流或页面渲染;每次 `invalidate()` 或 policy 变更之后,每个 policy 的第一次失败只记录一次日志 | +| 同步 | `bindings()`、condition 与 `explain()` 同步返回,状态由业务包预先载入内存,以便列表逐项过滤与快照计算 | +| 求值时机 | 每次 `decide()`、`allows()` 与快照计算都调用一次 `bindings()`,不缓存;依赖时间的状态(如过期)即时生效,长流(如 `workspace/follow`、`session/control`)在 binding 增删后的下一次逐项判定即反映;快照只在 `invalidate()` 后重算,业务包在过期等时间事件发生时也须调用 `invalidate()` | +| 通知 | 业务状态变化(授权、撤权、模式切换)改变 binding 或 condition 的结论时,业务包必须调用 `invalidate(principal?)`;快照只在 `invalidate()` 与 policy 注册、撤回后重算 | +| `remote-access/denied` | 只有调用的拒绝变成错误,details `{ action, reason }`,reason 为 condition 或 `explain` 给出的原因、`no-allow`、`policy-failed`,或 Gateway 对非法资源给出的 `resource-invalid`;被拒的事件只是不推送 | +| 必需服务 | Gateway `static inject = ['typert', 'remoteAccess']`;挂载 Gateway 的组合必须挂载 remote-access,[`bundle/base`](../../../../packages/bundle/base/cordis.patch.yml) 紧随 `typert-gateway` 装配;plugin manager 像保护 Gateway 一样保护它,插件页面无法禁用 | + +#### 示例:分享读者 + +以 liveshare 声明的 `share-viewer` principal 为例,说明业务状态到 binding 的推导留在业务包;示例中的 `shares` 代表业务包在内存中持有的分享状态: + +```text +const globalReader = { name: 'share:global-reader', resource: 'global', actions: ['session/list', 'session/control', 'workspace/follow', 'session/modelCatalog', 'llm/listProviders', 'commands/list'] } +const sessionReader = { name: 'share:session-reader', resource: 'session', actions: ['session/page', 'session/follow', 'session/list', 'session/control', 'workspace/follow', 'commands/list', 'event:api-session/status', 'event:api-session/added'] } +const workspaceReader = { name: 'share:workspace-reader', resource: 'workspace', actions: ['workspace/follow'] } +const forker = { name: 'share:forker', resource: 'session', actions: ['session/fork'] } +const writer = { name: 'share:writer', resource: 'session', actions: ['session/prompt', 'session/cancel'] } + +ctx.effect(() => ctx.remoteAccess.registerPolicy('share-viewer', { + bindings(viewer) { + const share = shares.serving(viewer) // undefined once stopped, expired, or no longer admitted + if (share === undefined) return [] + const source = { kind: 'session', id: share.sessionId } + return [ + { role: globalReader, resource: { kind: 'global' } }, + { role: sessionReader, resource: source }, + ...(share.allowsFork ? [{ role: forker, resource: source }] : []), + ...(share.mode === 'write' ? [{ role: writer, resource: source, condition: sourceWrite(share) }] : []), + ...shares.forksOf(viewer).flatMap(child => [ + { role: sessionReader, resource: { kind: 'session', id: child.id } }, + ...(child.revoked ? [] : [{ role: writer, resource: { kind: 'session', id: child.id }, condition: childPrompt(child) }]), + ]), + ...shares.workspacesShowing(viewer).map(id => ({ role: workspaceReader, resource: { kind: 'workspace', id } })), + ] + }, + explain({ principal, action }) { + return shares.refusal(principal) // 'share-expired', 'share-stopped', … + ?? (action === 'session/prompt' ? 'share-not-writable' : undefined) + }, +})) + +// Without args (snapshots, list filtering) a condition answers for the action alone. +const sourceWrite = share => () => share.confined ? allow : deny('source-write-unavailable') +const childPrompt = child => ({ action, args }) => + action === 'session/prompt' && args !== undefined && !child.allowsPrompt() ? deny('fork-prompt-refused') : allow +``` + +| 归属 | 内容 | +|---|---| +| 业务包 | role 的 action 清单;分享状态到 binding 的推导(停止、过期、准入、模式、读者自己的分支、撤销、包含可见会话的工作区);按请求细化的 condition;面向读者页面的拒绝原因 | +| 通用层 | 资源解析、按资源 AND、role 与 action 匹配、condition 调用、原因优先级、列表过滤、快照计算与失败隔离 | + +该读者的快照包含 `global`、源会话、自己的每个分支与每个可见工作区上的 grant;`session/list` 同时出现在 `global` 级与 session 级 role 中,见[列表与聚合方法](#list-and-aggregate-methods)。 + +#### Host 侧鉴权调用顺序 + +所有调用路径(HTTP `/api`、WebSocket stream、进程内 `fetch(request, peer)` / `invoke` / `stream` / `wireStream.open`)都经过同一段 Gateway 逻辑: + +| 步 | 动作 | 失败结果 | +|---|---|---| +| 1 | 准入得到 Peer:网络路径由 Connection `admit()` 给出(`browserPeer`);进程内路径由调用方传入。Gateway 在读取 wire payload 之前检查 `peer.signal` | 网络准入失败返回 HTTP 401 / 403(不进入 Gateway);Peer 已 dispose:`gateway/peer-disposed` | +| 2 | 解析端点并做参数字段集校验(`resolveDescriptor`、`assertExactArguments`) | `gateway/invocation-unavailable`、`gateway/ambiguous-endpoint`、`gateway/definition-unavailable`;字段多余或缺失:`gateway/arguments-invalid` | +| 3 | owner 直通:`principal.kind === 'owner'` 时直接进入第 5 步 | — | +| 4 | 非 owner:Gateway 解析资源(`resources` 读取函数、其次 lookup 参数与 Context 声明的 `resource`、再次默认字段;Context 身份直接从 args 读取,不解析 Context),再询问 `remoteAccess.decide({ principal, action: endpoint, resources, args })`;资源非法时不询问 `decide()`,直接以 `resource-invalid` 拒绝;`deny` 结论变为抛出的错误 | `remote-access/denied`,details `{ action, reason }` | +| 5 | 分派:`resolveReceiverContext`(可能激活 Agent)、参数解码、调用方法 | 既有错误码不变,如 `gateway/lookup-not-found`、`gateway/context-not-found`、`gateway/input-invalid`、`gateway/service-unavailable`、`gateway/method-unavailable` 与业务错误 | + +鉴权(第 3–4 步)位于 lookup 与参数解码之前,未授权调用不会激活会话或产生其他副作用。Gateway 新增的错误码只有 `gateway/peer-disposed`;拒绝码 `remote-access/denied` 由 remote-access 声明。 + +Gateway 自有端点:非 owner 可订阅 `$events`,但从不收到需要回答的 waterfall 帧;`$events/result` 对非 owner 以既有的 `gateway/invocation-unavailable` 拒绝;两者都做第 1 步的检查。 + +#### 转发事件 + +`API_REMOTE_FORWARDED_EVENTS` 的 `emit` 条目可以声明 `resources`:以资源种类为键、经 `TypertForwardableEventEntry` 按该事件自身参数定型的读取函数,例如 `{ event: 'api-session/added', mode: 'emit', resources: { session: (summary) => summary.sessionId } }`。API Remotes 把读取函数附在每个转发帧上;对每条非 owner 的 `$events` 流,Gateway 每帧解析一次资源,只有 `remoteAccess.decide({ principal, action: 'event:', resources })` 允许时才推送。 + +| 规则 | 内容 | +|---|---| +| 已声明事件 | `api-session/activity`、`api-session/added`、`api-session/error`、`api-session/removed` 与 `api-session/status` 声明其 Session | +| 未声明资源 | 未声明 `resources` 或声明为空 `resources: {}` 的事件访问 `global`,只有 `global` 级 role 列出 `event:` 时才送达非 owner | +| 缺失资源 | 事件携带其声明的每个资源,因此读取函数返回 `undefined` 时该事件的资源非法,Gateway 不询问 `decide()`,不送达任何非 owner;调用缺少资源时则为 `global` | +| 整条放行或拒绝 | 允许的事件 payload 原样送达;不按 Peer 裁剪或投影 payload | +| 每个事件一个资源 | 通知至多携带一个资源;集合类变化走 follow 类流(`session/follow`、`session/control`、`workspace/follow`),由这些方法内联经 `allows()` 逐项判定 | +| waterfall | 非 owner 从不收到 waterfall 帧,不经 policy 判定 | +| 非 owner 流的 Host 信息 | 非 owner 的 `$events` 流与 owner 流一样以完整 `host`(含 `home`)打开;`hasLiveClient()` 只统计能回答 waterfall 的 owner 流 | + + +#### 列表与聚合方法 + +列表、分页与聚合类方法的调用解析为 `global`,由 `global` 级 role 放行;方法内部对每一项调用 `this.ctx.remoteAccess.allows(resource)`,判定当前调用的 action 在该资源上是否被授权(不带 args)。因此列表类 action 同时出现在 `global` 级 role(放行调用)与资源级 role(放行逐项)中:`session/list` 在 `global` 上被授权才能调用,每个会话行还要求 `session/list` 在该会话上被授权。`allows()` 读访问该服务的 Context 上的 invocation,owner 与 Remote 调用之外的进程内代码恒为 true,不过滤。 + +| 方法 | 过滤对象 | +|---|---| +| `session/list` | 会话行(`session` 资源) | +| `session/control` | baseline 的 projections 与 projection 增量(`session` 资源) | +| `workspace/follow` | baseline、`upsert` 帧与 `order` id 中的工作区(`workspace` 资源)、工作区内会话、archived / pinned 会话集合(`session` 资源)。流记录已下发的工作区;某个工作区在下一次变化或新顺序中已不被允许时,发送一个携带完整过滤后状态的 `replace` 增量帧而不是 `remove`,因为 Client 不会复活已移除的 id,访问恢复后该工作区经普通 `upsert` 重新出现。`remove` 帧表示删除,只发给收到过该工作区的调用方 | + +#### 访问快照流 + +```text +// Remote: remoteAccess/follow (stream; every Peer may read its own snapshot) +type RemoteAccessFrame = + | { readonly type: 'baseline'; readonly owner: true } + | { readonly type: 'baseline'; readonly owner: false; readonly grants: readonly RemoteAccessGrant[] } + | { readonly type: 'upsert'; readonly grant: RemoteAccessGrant } + | { readonly type: 'remove'; readonly resource: RemoteAccessResource } + +interface RemoteAccessGrant { + readonly resource: RemoteAccessResource // global, or one declared-kind resource + readonly actions: readonly string[] // endpoint actions allowed on this resource without args, sorted +} + +/** Window global carrying a page's opening baseline. */ +export const REMOTE_ACCESS_SNAPSHOT_KEY = '__DSH_REMOTE_ACCESS__' +``` + +| 行为 | 说明 | +|---|---| +| 首份快照 | `snapshotFor(principal)` 计算页面打开时的 baseline。remote-access 经 `webserver/index-inject` 为 webServer 渲染的每个 index 加入全局变量 `REMOTE_ACCESS_SNAPSHOT_KEY`,值为 owner baseline `{ type: 'baseline', owner: true }`。渲染非 owner 页面的业务包(例如 owner Host 上的 liveshare)在所有 index 注入行之后放置一条把该全局变量赋值为 `snapshotFor(principal)` 的语句;后写的赋值生效,Client 在挂载时读取该全局变量 | +| 订阅 | 每个 Peer 订阅一条访问快照流,其 baseline 取代注入的快照 | +| 计算 | 取该 principal kind 的 policy 的全部 binding,按资源相等归并;每个资源的 `actions` 为这些 binding 的 role 中、经与调用相同的判定(单个资源、不带 args,condition 以无 args 调用)放行的端点 action;只为至少放行一个端点 action 的资源给出 grant。判定与快照同源:同一 principal、action 与资源(不带参数)上,快照答案与 `decide()` 一致 | +| 只含端点 action | `event:` action 不进入快照;前端只据端点 action 决定入口显隐 | +| 增量 | `invalidate()` 或 policy 注册、撤回时重算并推送 `upsert` / `remove`,放行集合变空的资源推送 `remove`;未变化的资源不推送 | +| 生命周期 | 一代快照流在其 signal abort 或 remote-access 服务卸载时结束,因此重载后的服务从新的 baseline 回答每个读者;Client 把结束视为载体中断并重连 | +| owner | 只推送 `{ owner: true }`,前端全部为 true | +| wire 类型 | `actions` 为 `readonly string[]`:Typert 无法为模板字面量类型生成 codec | + +access hooks 只按 action 与资源判断,不看调用参数;调用时 condition 可额外读取 args 做更细判断,以服务端结果为准。 + +#### Client 面 + +Client 面走框架标准 hook 注入,不引入 React context。组件只经 props 拿到 hook,不 import remote-access 的值。 + +```text +// @deepseek-ai/dsh-api-remote-access/client +interface ClientRemoteAccess { // Client service ctx.remoteAccess + source(kind: TypertResourceKind, id: string, action: string): HostObservable +} +GlobalStandardProps.useRemoteAccess: (action: RemoteEndpoint) => boolean // remote-access; the global resource + +// @deepseek-ai/dsh-client-ui-session (injects remoteAccess) +GlobalStandardProps.useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean +SessionStandardProps.useAccess: (action: RemoteEndpoint) => boolean +SessionMaybeStandardProps.useAccess: (action: RemoteEndpoint) => boolean | undefined + +// @deepseek-ai/dsh-client-ui-workspace (injects remoteAccess) +GlobalStandardProps.useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean + +// RemoteEndpoint = Extract +``` + +| 事项 | 定稿 | +|---|---| +| 快照与挂载 | remote-access Client 插件从 index 注入的 baseline 起步(启动时同步读取并校验),订阅快照流,对每个 baseline 一次构建完成,提供 Client 服务 `ctx.remoteAccess.source(kind, id, action)`(身份稳定的布尔 source)并注入 `global` 资源上的 `useRemoteAccess`。入口模块 import 生成的 `/remote` contribution,连同注入的 baseline 交给 `mountAccess(ctx, contribution, initial)` 经 `ctx.remote.$mount` 自行挂载(单测传入假 contribution),先例为 experimental client-ui-cot-translation、voice-input、claude-code-mods;若由 api/remotes 集中挂载,会形成 ui-session → api/remotes → remote-access → ui-session 的 project reference 环 | +| 会话资源 | ui-session 经 `uiSession.provide({ keyedHooks: ['access'], resolve })` 给出本会话答案,会话由框架绑定,与 `useProjection` 相同;`useSessionAccess` 经 `provideRoot` 注入 | +| 工作区资源 | ui-workspace 经 `provideRoot` 注入 `useWorkspaceAccess` | +| 名称区分 | `global` 答案是 `useRemoteAccess`,会话答案是 `useAccess`,两者互不遮蔽;session-maybe 无会话时 `useAccess` 为 `undefined`,调用方按不允许处理,需要 `global` 判断时读 `useRemoteAccess` | +| 双参数调用 | 框架 keyed hook 的标准双参数:`KeyedStandardSource` 为 `(key, member?) => source`;第二个参数为字符串时 hook 读取 resolver 对 `(key, member)` 返回的 source,不带 selector;resolver 对同一参数返回同一 source。既有 `(key, selector?, equal?)` 与 `(selector, equal?)` 调用不变。禁止柯里化形式 | +| 返回值 | 有资源的答案均为布尔值;action 类型来自 `ctx.remote` 的 namespace 与方法 | +| 不回退 | 会话资源上查询非会话方法(如 `useAccess('session/create')`)读该会话资源上的判定结果,不回退 `global` 答案 | +| 无快照 | owner 为 true,快照流终止失败后亦然;没有注入 baseline 或快照流终止失败的非 owner 页面为 false,终止失败会连同错误与“刷新页面”的提示一并报告;断线重连期间保留上次快照;注入的 baseline 格式错误时挂载失败;Host 仍对每次调用执行检查。hook 从不发请求 | +| 示例消费方 | ui-workspace 会话行菜单的 fork 入口读 `useSessionAccess(sessionId, 'session/fork')`,不允许时不渲染;ui-chat turn tail 的分叉按钮读会话资源上的 `useAccess('session/fork')`,不允许时不渲染。替换 #3854 的 `clientWritePolicy` 留给 #3854 | +| 测试 | `@deepseek-ai/dsh-client-test-runtime` 导出 `allowAllAccess` props 与 `allowAllAccessPlugin`;TestClient 像 api-remotes 一样去掉 remote-access 行并装入全允许服务,SlotTestRuntime 在 ui-session 之前装入 | + +### 安全不变量 + +| 不变量 | 依据与落点 | +|---|---| +| principal 只能由进程内 Host 代码在创建 Peer 时给出 | `PeerScope.principal` 只读;`createPeer()` 不对应任何路由 | +| wire 上任何字段都不能设置或覆盖 principal | principal 不来自 args、envelope、header、query 或 cookie。args 顶层多余字段被 `assertExactArguments` 拒绝;payload 必须恰为 `{ args }`;envelope 未知字段被 [`clientRequestSchema`](../../../../packages/client/connection/src/rpc-schema.ts) 丢弃;请求体内同名字段只是业务数据 | +| 浏览器网络准入为 owner | 通过[浏览器 token 认证](../../implemented/architecture/2026-08-24-browser-token-authentication.zh.md)的连接得到 `browserPeer` | +| 没有隐式 Peer | Gateway 与 Connection 都不补默认 Peer;required 参数让漏传在编译期暴露 | +| 已 dispose 的 Peer 不能再分派 | `gateway/peer-disposed` | +| 非 owner 未经授权不能调用 | 只有 binding 的 role 显式列出该 action 且资源匹配时才放行;没有该 kind 的 policy 即拒;Gateway 依赖 `remoteAccess`,缺失时 Gateway 不启动 | +| `global` 授权不蕴含资源授权 | 资源之间无继承;方法内 `allows()` 对每一项独立判定 | +| 未知 principal kind 不是 owner | 只有 `kind === 'owner'` 是 owner | +| 非 owner 不回答 waterfall | `$events` 不给非 owner waterfall 帧,`$events/result` 拒绝非 owner | +| 非 owner 只收到被授权的通知 | 每个转发帧按 `event:` 与其资源判定,被允许才转发给非 owner | + +### 消息来源落盘 + +[session-controller 类型](../../../../packages/api/session-controller/src/types.ts) 的 `MessageSourceMap` 条目增加可选字段: + +```text +'user-rpc': { + kind: 'user' + rpcId: SessionRequestId + clientTimeZone?: string + /** Non-owner caller that submitted this prompt; absent means the owner. */ + principal?: Principal +} +``` + +在 Remote 调用中构造 `kind: 'user'` + `rpcId` 用户消息 source 的生产者共两处,都在 Remote 方法内调用 `principalOf(this.ctx.invocation)`,返回值非 `undefined` 才写 `principal`: + +| 生产者 | 位置 | +|---|---| +| `SessionController.prompt()` 构造 source,`SessionCommandController.prompt(request, source)` 只负责投递 | [`session-controller/src/index.ts`](../../../../packages/api/session-controller/src/index.ts)、[`commands.ts`](../../../../packages/api/session-controller/src/commands.ts) | +| subagent `prompt()`(`BrowserPromptSource`);没有 role 列出它,非 owner 只能经进程内 Host 代码到达,不能经 Gateway 到达 | [`subagent/src/index.ts`](../../../../packages/subagent/subagent/src/index.ts) | + +业务代码不把 principal 当函数参数逐层传递:身份只在 Remote 方法内从 invocation 读一次,向下传的是已构造的 source。Agent inbox 没有可在消息入队时统一补字段的拦截点(`followup` / `steer` 接受已构造的消息,SDK、调度等进程内生产者不在 Remote 调用中),因此按生产者落实,并由代码评审标准约束新增的生产者。 + +| 事实 | 说明 | +|---|---| +| `kind` 不变 | 仍为 `'user'`;现有 `kind === 'user'` 判断不受影响 | +| owner 消息不变 | owner 调用时不写字段,source 与引入 principal 之前逐字节相同;没有该字段的消息(含全部旧会话)即为 owner | +| 不进入模型请求 | llm 适配器只读 model source([`llm-deepseek/src/replay.ts`](../../../../packages/llm/llm-deepseek/src/replay.ts)) | +| 会话格式 | 不 bump `SESSION_FORMAT_VERSION`;按 [persistence 类型变更 cookbook](../../../../docs/cookbook/reviewing-persistence-type-changes.zh.md) 为该持久化类型变更记录 acknowledgement | + +消费方用法示例(不在本 Note 范围内实现):工具守卫与 pre-step 复检按 turn 读取该 turn 内用户消息的 `source.principal`,任一条带非 owner principal 即按该调用方判定;同一 turn 混有 owner 与读者消息时取最严格者。 + +### 代码评审标准与文档 + +评审标准的正家为 [Remote and API rules](../../../../docs/defensive-patterns/remote-and-api.zh.md) 的「`@Remote` 标在能力方法上」一节:`@Remote({ resources })` 与自动解析必须覆盖方法访问的全部资源;聚合结果逐项经 `allows()` 过滤;调用方身份只读 `invocation.peer.principal`,不从参数读取;每条调用路径显式给出 Peer;业务代码不把 principal 当函数参数传递。 + +| 文档 | 内容 | +|---|---| +| [`docs/subsystems/typert.md`](../../../../docs/subsystems/typert.zh.md)、[`docs/api-gateway.md`](../../../../docs/api-gateway.zh.md) | `PeerScope`、`RemoteInvocation`、`InvokeRemoteRequest`、显式 Peer、`@Remote` 的 `resources`、访问检查顺序 | +| connection、gateway、typert-protocol、typert-registry、api-remotes、remote-access、session-controller、workspace-controller、ui-session、ui-workspace、ui-slots、client-test-runtime 的 README | 以 Peer / `browserPeer` 取代 operator;remote-access 的 role、binding、policy 契约;access hooks | + +### 不在范围与 Future work + +- `/connect` 握手、API Cookie、每条 API 连接一个 Peer、WebSocket 生命周期:归 pr-5945。 +- 浏览器多 Peer(如每标签页一个):所有浏览器连接共享 `browserPeer`。 +- 交互参与(interactions)与按 principal 隔离审批由后续工作引入;目前非 owner 只收被授权的 `$events` 广播帧。 +- `agent-preset/selected` 等其余转发事件未声明资源,按 `global` 判定;读者需要某个会话事件时,#3854 为其声明资源并在 role 中列出 `event:`。 +- liveshare 接入(声明 `share-viewer`、每条读者连接一个 Peer、定义 role 并注册 policy、删除 `origin` / `prompt/origin` / `READ_ENDPOINTS`):#3854 侧。 +- `session/cancel` 归属检查、fork 的 `childSessionId`、`ToolExecutionInput.turn`:不改。 +- 资源识别的精确方式(例如 Typert 生成器按 branded id 类型在请求对象中定位字段并写入描述符):目前用 lookup 与 Context 声明的 `resource`、默认字段加 `resources` 读取函数,以后可替换资源解析而不改 policy 接口。 +- exact Fetch route 对非 owner 一律 403 是临时处理;以后由各路由声明访问规则。 +- subagent `prompt` 的资源声明待定:其请求字段为 `parentSessionId` / `childSessionId`,目前解析为 `global`。生产者为进程内调用方记录 `source.principal`;没有 role 列出它,非 owner 不能经 Gateway 到达。 +- 预定义与聚合 role:出现第二种非 owner principal kind 时,由各领域包以 `@Remote({ roles })` 声明方法所属的通用 role(如 `session:viewer`),各 policy 绑定这些 role;目前 role 由业务包以显式 action 清单定义。 + +### 与现有 Note 的关系 + +- [Remote 双工流](../../implemented/architecture/2026-09-19-remote-duplex-stream.zh.md):部分 supersede。被替换的部分包括:「Peer 不记录身份与权限」「本 Host 只有一个 Peer:operator」「in-process carrier 不给 Peer 即 operator」,以及其替代方案中「Access 机制属于业务语义」的表述;其余(双工流、uplink、`ctx.invocation` 绑定)保持。两篇保持 active 并互链,该 Note 的现行事实已同步更新。 +- [浏览器 token 认证](../../implemented/architecture/2026-08-24-browser-token-authentication.zh.md):不受影响。该 Note 否决「按方法区分特权」针对的是 owner 的认证;通过认证的浏览器仍是 owner 且不受方法级检查。 +- [Typert Remote 方法调用](../../implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md):不受影响;`@Remote` 与 `@RemoteScope` 增加可选的 `resources` 读取函数(`@RemoteScope` 的第二个参数可为 `{ name?, resources? }`),只供 Gateway 的访问检查读取,不改变端点命名、调用模式与生成物。 +- [生产方拥有的消息来源](../../implemented/architecture/2026-09-09-producer-owned-message-sources.zh.md):不受影响;`principal` 由构造 source 的生产者写入。 + +## 考虑过的替代方案 + +**独立 `prompt/origin` 事件并按 requestId 关联(#3854 做法)。** 每条 prompt 都写 required 事件,普通会话也无法被旧版本读取;关联要求全部 prompt 串行并维护来源 fold,fold 窗口外的消息失去发起者。 + +**principal 与 peer 并列挂在 invocation 上。** 同一调用方的身份拆成两处来源,准入点要同时产出并传递两者,任一路径只传其一就会错配。 + +**保留「Peer 不记录身份」的原约定。** 身份由业务插件经 `peer.ctx` 附加时,每个读取方都要知道由谁附加、如何读取,wire 准入路径也无法保证附加发生。 + +**保留默认 Peer 兜底(`request.peer ?? …`)。** 隐式默认让漏传 Peer 的调用静默获得 owner 身份;required 参数把漏传变成编译错误。 + +**新开 source kind。** 现有 `kind === 'user'` 判断都要逐个审视,漏一处就让读者消息在标题、大纲、队列或聊天渲染中消失。 + +**请求体字段携带身份。** 任何能构造请求体的调用方都能伪造或覆盖它。 + +**前端 React context 提供资源。** 业务组件不得读 React context;标准 hook 由框架按资源绑定,组件只经 props 拿到。 + +**由驱动方自行维护端点白名单并裁剪结果(#3854 做法)。** 白名单与结果裁剪都在驱动方内,Gateway 不执行,快照与前端各自推导;role 的 action 清单由通用层执行,列表过滤由方法内的 `allows()` 完成。 + +**业务包实现 `decide()` / `abstain` 的 provider,并合并多个 provider 的答案。** 每个业务包手写 action × 资源的判别分支,另写一份资源列表供快照,两者靠人工保持一致;policy 只给出 binding,判定与快照由同一份 binding 算出,每种 principal kind 只有一个 policy,不需要合并规则。 + +**独立的 `@RemoteAccess` 标记。** 默认拒绝已由 role 的显式 action 清单保证,标记只是第二道同意;一个资源都没解析出的方法落到 `global`,只有显式的全局授权能放行。资源读取函数是方法描述信息,放在 `@Remote` 上。 + +**把无资源的调用称为 `page` 或 `host`。** `page` 描述前端位置而不是资源;`host` 容易被读作这台 Host 上全部资源的上级。`global` 表示不关联任何资源的授权。 + +**资源继承(GCP 式:`global` 或工作区上的授权覆盖下级资源)。** 列表方法被 `global` 授权放行后需要逐项细判,继承会让任何 `global` 授权蕴含全部会话;无继承时每个资源的授权都由 binding 显式给出。 + +**role 注册表与预定义聚合 role。** 目前唯一的非 owner principal 由一个业务包管理,role 作为该包的普通值即可;聚合 role 会让新包声明的方法自动进入分享读者的可访问范围,推迟到出现第二种非 owner principal。 + +**condition 挂在 role 的 action 上。** role 保持为可复用的纯数据;按业务状态细化的判断属于把 role 授予某个 principal 的 binding。 + +**拒绝原因只由 condition 给出。** 没有 binding 时没有 condition 可以给出原因,读者页面无法区分「分享已过期」「分享不允许写入」等情况;`explain` 只在结论已为拒绝后给出原因,不改变结论。 + +**全局资源 resolver 注册表(领域包按字段名注册识别函数)。** 字段识别与方法分离,方法改签名时无人提醒,跨包同名字段也会被误认;资源解析采用方法上的默认约定加 per-method `resources`,领域包只登记自己种类的默认字段。 + +**按点路径声明资源(`'request.address.sessionId'`)。** 路径无法由类型推导约束;`resources` 读取函数的参数类型从被装饰方法推断,拼错即编译失败。 + +**由生成器按 branded id 类型识别资源字段。** 需要改 Typert 生成物与描述符格式,SRC 描述符没有类型信息;留作 Future work。 + +**由 api/remotes 集中挂载 remote-access 的 generated contribution。** remote-access Client 依赖 ui-session,ui-session 依赖 api/remotes,集中挂载形成 project reference 环;改为自挂载。 + +**在 Gateway 内实现判定(带规则的 `check()` 写在 Gateway)。** Gateway 会承载访问控制语义并多出错误码;Gateway 像解析 lookup 一样解析资源,在分派前调用 `remoteAccess.decide()`,规则与拒绝码归 remote-access。 + +**policy 异步判定。** 列表逐项过滤与快照计算需要同步答案;异步状态由业务包预先载入内存。 + +**前端按需查询权限。** 请求量随入口数增长,且与规则变化无法同步;快照流由 Host 主动推送,前端同步读取。 + +**访问检查放在 lookup 解析之后。** lookup 可能激活 Agent,未授权调用会先产生副作用。 + +**在 remote-access 中登记资源字段(`registerScope`)。** 访问控制包要识别参数字段,而 lookup 本已知道哪个 wire 字段指向 Session;typert 把资源种类与 lookups、Contexts 并列保存,Gateway 在一处解析调用与事件,remote-access 不读取任何参数字段。 + +**按 Peer 裁剪转发事件的 payload。** 逐事件投影会在每个通知里重复一遍列表过滤;一个事件至多携带一个资源,整条放行或拒绝,集合类变化走内联过滤的 follow 类流。 + +**首份快照到达前一律回答 true。** 非 owner 页面会短暂显示无权使用的入口;index 携带首份快照,没有快照的页面即无权限。 + +## 后果 + +### 验证 + +| 用例 | 行为 | 测试落点 | +|---|---|---| +| wire 试图设置 principal(args 顶层、payload 并列、envelope、请求体内) | 前两种以参数或 payload 校验拒绝;其余被丢弃或当作业务数据;`invocation.peer` 仍为准入给出的 Peer | Gateway host spec | +| 所有调用路径显式给出 Peer | `InvokeRemoteRequest.peer`、`wireStream.open` 的 peer、`fetch(request, peer)`、`bridge` 的 peer 为 required;Gateway 中不存在兜底;`/api` 路由传递 `admission.peer` | typecheck、Connection host spec | +| 浏览器连接与 `createPeer()` | `browserPeer` 的 principal 为 owner;`createPeer()` 每次返回独立 Peer,随读取 Connection 的 Context 释放;exact route 对非 owner 返回 403 | Connection host spec | +| 已 dispose 的 Peer | 之后的 unary、stream 与 `$events` 分派以 `gateway/peer-disposed` 拒绝,wire payload 畸形时亦然;其名下 stream 结束;运行中的 unary 完成;不影响其他 Peer | Gateway host spec | +| 鉴权顺序 | owner 调用任何方法照常分派且不读取 policy;没有 role 列出的方法对非 owner 以 `remote-access/denied`(`no-allow`)拒绝;访问检查早于 lookup,被拒调用不激活会话 | Gateway host spec | +| policy 注册 | 每个非 owner principal kind 一个 policy,重复注册抛错;disposer 撤回后该 kind 以 `no-allow` 拒绝;随 fiber 撤回 | remote-access host spec | +| 判定 | 每个资源都需要匹配的 binding(AND);role 不含该 action、资源不相等或 condition 拒绝都不放行;拒绝原因按 condition > `explain` > `no-allow` 取;`explain` 不改变结论;condition 在调用时收到 args,在快照与列表过滤时不收;`bindings()`、condition 或 `explain()` 抛错以 `policy-failed` 拒绝,每个 policy 在两次 `invalidate()` 之间只记录一次日志,事件路径上 owner 流与共享事件源照常;`remoteAccess/follow` 对任何 principal 放行 | remote-access host spec、Gateway host spec | +| 资源解析 | 默认字段(顶层或首个对象参数)得到对应资源;声明了该种类 `resource` 的 lookup 参数得到其 wire 身份;Context 方法从 args 中的 Agent Context 身份得到其 session 资源且不激活 Agent,另可经 `resources` 声明其他资源;`resources` 读取函数覆盖该种类,数组为多个资源,`undefined` 为无;都没有为 `[global]`;抛错、空数组或非非空字符串(含 Context 身份)为非法,Gateway 以 `resource-invalid` 拒绝且不询问 `decide()`;未声明 `resource` 的 Context 按普通规则解析 | Gateway host spec(gateway-access) | +| 资源注册表 | `typert.resources` 每个种类登记一个默认字段,拒绝重复登记与 `global`,并随 fiber 撤回;配置的 lookup resolver 保留 provider 的 `resource`;Host Context adapter 保留其 `resource` | typert-registry spec | +| 转发事件 | 非 owner 的 `$events` 流只在 `event:` 于其资源上被授权时整条收到事件;未声明资源的事件按 `global` 判定;读取函数返回 `undefined` 的事件不送达非 owner,且不询问 `decide()`;owner 全部收到 | Gateway host spec(gateway-access) | +| 首份快照 | `snapshotFor()` 返回 owner baseline 或该 principal 的 grant;remote-access 为渲染的每个 index 注入 owner baseline;Client 从注入的 baseline 起步并拒绝格式错误的快照 | remote-access host 与 client spec | +| 类型推断 | `resources` 读取函数的参数类型从方法推断;lookup 位为 wire 类型;访问不存在的属性、参数多于方法参数与 `global` 键编译失败;binding 的资源种类与 role 不符编译失败 | typert-protocol spec、remote-access spec(类型测试) | +| 列表过滤 | `session/list`、`session/control`、`workspace/follow` 对非 owner 只返回 `allows()` 为 true 的项;已下发工作区失去可见性时 `workspace/follow` 发送 `replace` 增量帧,访问恢复后该工作区重新出现;owner 结果不变 | session-controller / workspace-controller host spec | +| 访问快照 | 快照中的允许集合与 Host 对同一 principal、action、资源(不带参数)的判定一致;只含端点 action;没有放行 action 的资源不出 grant,放行集合变空时推送 `remove`;`invalidate()` 与 policy 注册、撤回后推送增量;被变化结束的等待不残留 abort 监听器;卸载服务即结束所有打开的快照流;owner 只收 `{ owner: true }` | remote-access host spec | +| access hooks | `source()`、`useRemoteAccess`、`useAccess`、`useSessionAccess`、`useWorkspaceAccess` 与快照一致;会话资源上的 `useAccess` 读本会话答案;owner 为 true;没有快照或快照流失败的非 owner 为 false;hook 不发请求 | remote-access client spec、ui-session / ui-workspace client spec | +| keyed hook 双参数 | 第二个参数为字符串时读取 `(key, member)` source;既有调用形式不变 | ui-renderer client spec | +| 非 owner 分派 `session/prompt` | 落盘 user 消息 `source.principal` 等于该 principal;冷恢复后仍读到同一值 | session-controller host spec | +| 进程内以非 owner invocation 调用 subagent `prompt` | 子会话落盘的 user 消息 `source.principal` 等于该 principal | subagent host spec | +| owner 消息 | source 与本变更之前逐字节相同;现有 keyless recorded-session snapshot 不变 | `pnpm run test:snapshot` 不需要重录 | +| 持久化记录 | `verify-persistence-changes` 通过 | doc-sync | + +SDK 投影不受影响:TypeScript SDK server 自行构造 `{ kind: 'user' }` 消息([`sdk/server/src/server.ts`](../../../../packages/sdk/server/src/server.ts)),不经 Gateway,也不投影 source;Python SDK 不读取 user source。 + +### 代价与限制 + +- 改动面横跨 protocol、Connection、Gateway、两个领域控制器、subagent、新包与前端,并带破坏性 API 改名;与 pr-5945 在 Connection 与 Gateway 上必然冲突,按迁移清单处理。 +- Gateway 依赖 `remoteAccess`:自建组合若挂载 Gateway 而不挂载 remote-access,Gateway 停在等待状态。 +- Session 日志会保存读者身份;导出或分享会话时随之外传,每个 principal 种类不得携带凭据。 +- 浏览器所有连接共享一个 Peer:dispose 无法按标签页区分,直到浏览器多 Peer 落地。 +- 默认字段约定按字段名读取:方法新增同名字段即访问对应资源,评审需留意;精确识别列为 Future work。 +- 只解析出部分资源的方法会放行越权:未解析的资源不受检查,由方法作者保证 `resources` 完整。 +- role 的 action 是字符串,不与已加载端点核对:拼错或改名后的 action 永不匹配,表现为拒绝。 +- `api-session/added` 携带完整的 `SessionSummary`,因此被允许接收它的非 owner 也会得知该会话的 `parentSessionId`。 +- 被授权会话的 cwd 与工具路径本就包含 owner 的绝对路径,因此 Gateway 不对非 owner 的 `$events` 流单独隐藏 `home`。 +- policy 撤销某个工作区的可见性后,`workspace/follow` 只在该工作区下一次变化或顺序下一次变化时察觉并向非 owner 发送 `replace` 增量帧;仅访问变化不会发送帧。 + +### 留给 pr-5945 的迁移清单 + +| 文件 | 迁移 | +|---|---| +| `packages/client/connection/src/peer.ts` | 取代 `operator-peer.ts`;`OperatorPeer` 构造改为 `new Peer(owner, { principal })` | +| `packages/client/connection/src/rpc-host.ts` | `this.operator` 改为 `browserPeer`,principal 为 `{ kind: 'owner' }` | +| `packages/client/connection/src/rpc.ts` | `HostConnectionHandle` 合并 `admitConnection` 与 `browserPeer` / `createPeer` | +| `packages/api/gateway/src/index.ts` | `$events` 打开处的 `peer ?? this.operatorPeer()` 改为显式 Peer;复用 `gateway/peer-disposed` | +| operator-connect 的 Note、升级指南与 subsystems 改动 | operator 术语改写为 Peer / `browserPeer`;API 连接的 Peer 为 `{ kind: 'owner' }` | From f6316536ea262ece29d042923d9f2e1d1b2972d8 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:45:27 +0800 Subject: [PATCH 15/35] feat(client-connection): carry caller principals on explicit peers --- packages/api/gateway/src/index.ts | 67 +++++++------- .../api/gateway/src/remote-error-codes.ts | 1 + packages/api/gateway/src/types.ts | 9 +- packages/api/gateway/tests/peer.ts | 30 +++++++ .../gateway/tests/stream-server.host.spec.ts | 2 + .../tests/controller.spec.ts | 2 +- packages/client/connection/README.i18n.yaml | 4 +- packages/client/connection/README.md | 2 +- packages/client/connection/README.zh.md | 2 +- .../client/connection/src/client/index.ts | 2 +- packages/client/connection/src/http-bridge.ts | 5 +- packages/client/connection/src/index.ts | 7 +- .../client/connection/src/operator-peer.ts | 33 ------- packages/client/connection/src/peer.ts | 49 +++++++++++ packages/client/connection/src/rpc-host.ts | 41 +++++---- packages/client/connection/src/rpc.ts | 40 ++++++--- .../connection/tests/binary-rpc.host.spec.ts | 8 +- .../tests/fetch-routes.host.spec.ts | 88 ++++++++++++++++++- .../connection/tests/http-bridge.host.spec.ts | 16 ++-- .../connection/tests/node-half.host.spec.ts | 13 +-- packages/client/connection/tsconfig.host.json | 2 +- .../tests/changes-open.host.spec.ts | 19 ++-- .../tests/present-open.host.spec.ts | 28 +++--- .../tests/loader-composition.host.spec.ts | 5 +- .../inspector/tests/plugin.host.spec.ts | 6 +- .../webworker-runtime/src/worker-host.ts | 7 +- .../tests/archive.host.spec.ts | 4 +- .../tests/route.host.spec.ts | 12 +-- packages/typert/protocol/src/index.ts | 3 + packages/typert/protocol/src/principal.ts | 17 ++++ packages/typert/protocol/src/types.ts | 24 ++++- .../cordis-inspect-timeout/client-fixture.mjs | 10 ++- 32 files changed, 392 insertions(+), 166 deletions(-) create mode 100644 packages/api/gateway/tests/peer.ts delete mode 100644 packages/client/connection/src/operator-peer.ts create mode 100644 packages/client/connection/src/peer.ts create mode 100644 packages/typert/protocol/src/principal.ts diff --git a/packages/api/gateway/src/index.ts b/packages/api/gateway/src/index.ts index 877dd83194..42148cd2d3 100644 --- a/packages/api/gateway/src/index.ts +++ b/packages/api/gateway/src/index.ts @@ -7,10 +7,9 @@ import { randomUUID } from 'node:crypto' import { Context, Service, symbols } from '@deepseek-ai/cordis' -import { - OperatorPeer, - type ConnectionRpcAttachment, - type ConnectionRpcHandler, +import type { + ConnectionRpcAttachment, + ConnectionRpcHandler, } from '@deepseek-ai/dsh-client-connection' import { Deque } from '@deepseek-ai/dsh-deque' import type { WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver' @@ -213,7 +212,6 @@ export class TypertGatewayService extends Service implements TypertGateway { } private srcClaims: ReadonlySet | undefined - private inProcessOperator: PeerScope | undefined private remoteEvents: RegisteredRemoteEventSource | undefined private readonly remoteEventClients = new Map() private readonly pendingRemoteEvents = new Map() @@ -430,6 +428,7 @@ export class TypertGatewayService extends Service implements TypertGateway { ): Promise { if (endpoint === REMOTE_EVENT_RESULT_ENDPOINT) { try { + assertPeerLive(peer, endpoint) const result = parseRemoteEventResultPayload(payload) const client = this.remoteEventClients.get(result.clientId) if (client === undefined) { @@ -448,7 +447,7 @@ export class TypertGatewayService extends Service implements TypertGateway { endpoint: string, payload: unknown, uplink: AsyncIterable, - peer: PeerScope | undefined, + peer: PeerScope, signal: AbortSignal, control: AbortController, uplinkWindow?: RemoteInvocation['uplinkWindow'], @@ -456,27 +455,16 @@ export class TypertGatewayService extends Service implements TypertGateway { 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) + assertPeerLive(peer, endpoint) + return this.openRemoteEvents(payload, signal, peer) } return this.openStream({ ...remoteRequest(endpoint, payload, signal, peer), uplink, uplinkWindow }, control) } - /** - * The Peer an in-process carrier speaks for when it names none: the - * 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 ??= new OperatorPeer(this.ctx) - return this.inProcessOperator - } - private async *openRemoteEvents( payload: unknown, signal: AbortSignal, + peer: PeerScope, ): AsyncGenerator< RemoteEventEmitFrame | RemoteEventInvocationFrame | RemoteEventCancellationFrame | RemoteEventReadyFrame @@ -502,7 +490,7 @@ export class TypertGatewayService extends Service implements TypertGateway { 'forwarded Remote event source is unavailable', ) } - const lifetime = AbortSignal.any([signal, registration.lifetime.signal]) + const lifetime = AbortSignal.any([signal, registration.lifetime.signal, peer.signal]) let clientId = randomUUID() as RemoteEventClientId while (this.remoteEventClients.has(clientId)) clientId = randomUUID() as RemoteEventClientId const client: RemoteEventClient = { @@ -703,6 +691,7 @@ export class TypertGatewayService extends Service implements TypertGateway { control: AbortController, ): Promise { const endpoint = endpointOf(request.namespace, request.method) + assertPeerLive(request.peer, endpoint) const descriptor = this.resolveDescriptor(request.namespace, request.method, endpoint) assertExactArguments(request.args, descriptor, endpoint) const receiverContext = await this.resolveReceiverContext(descriptor, request.args, endpoint) @@ -717,11 +706,11 @@ 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))) - const signal = methodSignal(request, control) + const signal = methodSignal(request, control, descriptor.mode === 'stream') const invocation = new GatewayInvocation( { namespace: request.namespace, method: request.method, args: request.args }, descriptor.service, - request.peer ?? this.operatorPeer(), + request.peer, signal, { source: request.uplink ?? EMPTY_ASYNC_ITERABLE, @@ -1131,8 +1120,10 @@ function remoteRequest( endpoint: string, payload: unknown, signal: AbortSignal, - peer?: PeerScope, + peer: PeerScope, ): InvokeRemoteRequest { + // A disposed Peer is refused whatever it sent. + assertPeerLive(peer, endpoint) const segments = endpoint.split('/') if (segments.length !== 2 || segments[0] === '' || segments[1] === '') { throw new Error(`invalid Remote endpoint ${JSON.stringify(endpoint)}`) @@ -1146,20 +1137,34 @@ function remoteRequest( || !isPlainObject(payload.args)) { throw new Error('Remote payload must contain exactly one plain-object args field') } - return { namespace, method, args: payload.args, signal, ...(peer === undefined ? {} : { peer }) } + return { namespace, method, args: payload.args, signal, peer } } /** * The signal a method observes. A carrier that supplies an uplink fails the * stream through `control` when an item is rejected, so that invocation joins - * `control` with the carrier signal; every other invocation keeps the carrier - * signal's identity. + * `control` with the carrier signal; a stream also ends with its Peer. Every + * other invocation keeps the carrier signal's identity. */ -function methodSignal(request: InvokeRemoteRequest, control: AbortController): AbortSignal { +function methodSignal(request: InvokeRemoteRequest, control: AbortController, stream: boolean): AbortSignal { const carrier = request.signal - if (request.uplink === undefined) return carrier ?? NEVER_ABORTED_SIGNAL - if (carrier === undefined || carrier === control.signal) return control.signal - return AbortSignal.any([carrier, control.signal]) + if (request.uplink === undefined && !stream) return carrier ?? NEVER_ABORTED_SIGNAL + const joined = carrier === undefined || carrier === control.signal ? [control.signal] : [carrier, control.signal] + // `AbortSignal.any` leaves each source only a weak reference to the joined signal, + // so per-stream joins on a Host-lifetime Peer signal accumulate nothing. + return AbortSignal.any(stream ? [...joined, request.peer.signal] : joined) +} + +/** + * Refuse a call whose Peer has been disposed. + * @param peer - Peer the call speaks for. + * @param endpoint - endpoint named in the failure. + * @throws {@link TypertGatewayError} `gateway/peer-disposed` once the Peer is disposed. + */ +function assertPeerLive(peer: PeerScope, endpoint: string): void { + if (peer.signal.aborted) { + throw new TypertGatewayError('gateway/peer-disposed', endpoint, 'the calling Peer has been disposed') + } } function isIterable(value: unknown): value is Iterable | AsyncIterable { diff --git a/packages/api/gateway/src/remote-error-codes.ts b/packages/api/gateway/src/remote-error-codes.ts index 5e560557cc..e61432b8c5 100644 --- a/packages/api/gateway/src/remote-error-codes.ts +++ b/packages/api/gateway/src/remote-error-codes.ts @@ -27,6 +27,7 @@ declare module '@deepseek-ai/dsh-typert-protocol' { 'gateway/lookup-not-found': TypertGatewayFaultDetails 'gateway/lookup-unavailable': TypertGatewayFaultDetails 'gateway/method-unavailable': TypertGatewayFaultDetails + 'gateway/peer-disposed': TypertGatewayFaultDetails 'gateway/protocol': TypertGatewayFaultDetails 'gateway/provider-mismatch': TypertGatewayFaultDetails 'gateway/result-invalid': TypertGatewayFaultDetails diff --git a/packages/api/gateway/src/types.ts b/packages/api/gateway/src/types.ts index fd7c6a4a96..27a0989ea2 100644 --- a/packages/api/gateway/src/types.ts +++ b/packages/api/gateway/src/types.ts @@ -22,8 +22,8 @@ export interface InvokeRemoteRequest { readonly uplink?: AsyncIterable /** Carrier-provided frame capacity; absent for carriers without an encoded-frame inbox limit. */ readonly uplinkWindow?: RemoteUplinkWindow | undefined - /** Peer the call speaks for; absent means an in-process carrier, answered as the operator. */ - readonly peer?: PeerScope + /** Peer the call speaks for; every carrier and in-process caller names it explicitly. */ + readonly peer: PeerScope /** Carrier or direct-caller cancellation injected only into cancellation-aware methods. */ readonly signal?: AbortSignal } @@ -90,7 +90,7 @@ export interface TypertGatewayWireStream { * @param payload - decoded carrier payload. * @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 peer - Peer the stream speaks for; required, never defaulted. * @param signal - logical-stream cancellation. * @returns validated stream values. */ @@ -98,7 +98,7 @@ export interface TypertGatewayWireStream { endpoint: string, payload: unknown, uplink: AsyncIterable, - peer: PeerScope | undefined, + peer: PeerScope, signal: AbortSignal, ) => Promise> @@ -129,6 +129,7 @@ export type TypertGatewayErrorCode = | 'gateway/lookup-not-found' | 'gateway/lookup-unavailable' | 'gateway/method-unavailable' + | 'gateway/peer-disposed' | 'gateway/protocol' | 'gateway/provider-mismatch' | 'gateway/result-invalid' diff --git a/packages/api/gateway/tests/peer.ts b/packages/api/gateway/tests/peer.ts new file mode 100644 index 0000000000..2571e791d5 --- /dev/null +++ b/packages/api/gateway/tests/peer.ts @@ -0,0 +1,30 @@ +/** Owner Peers for Gateway specs: every in-process call names its Peer explicitly. */ +import type { Context } from '@deepseek-ai/cordis' +import { Peer } from '@deepseek-ai/dsh-client-connection' +import type { PeerScope, Principal } from '@deepseek-ai/dsh-typert-protocol' + +const owners = new WeakMap() + +/** + * The owner Peer of one test Context tree, created on first use. + * @param ctx - any Context of the tree; the Peer hangs under its root. + * @returns the tree's shared owner Peer. + */ +export function ownerPeer(ctx: Context): PeerScope { + let peer = owners.get(ctx.root) + if (peer === undefined) { + peer = new Peer(ctx.root, { principal: { kind: 'owner' } }) + owners.set(ctx.root, peer) + } + return peer +} + +/** + * A fresh Peer of one principal under the test Context tree. + * @param ctx - any Context of the tree; the Peer hangs under its root. + * @param principal - identity the Peer speaks for. + * @returns an independent Peer. + */ +export function peerFor(ctx: Context, principal: Principal): PeerScope { + return new Peer(ctx.root, { principal }) +} diff --git a/packages/api/gateway/tests/stream-server.host.spec.ts b/packages/api/gateway/tests/stream-server.host.spec.ts index 965cbb7989..87c0784d5d 100644 --- a/packages/api/gateway/tests/stream-server.host.spec.ts +++ b/packages/api/gateway/tests/stream-server.host.spec.ts @@ -575,6 +575,8 @@ async function fixturePeer(): Promise { return { id: 'fixture-peer' as PeerId, ctx: fiber.ctx, + principal: { kind: 'owner' }, + signal: new AbortController().signal, dispose: async () => { await fiber.dispose() }, } } diff --git a/packages/api/terminal-controller/tests/controller.spec.ts b/packages/api/terminal-controller/tests/controller.spec.ts index aa907a95b8..dd86c0be82 100644 --- a/packages/api/terminal-controller/tests/controller.spec.ts +++ b/packages/api/terminal-controller/tests/controller.spec.ts @@ -737,7 +737,7 @@ function attachController( ) { const invocation: RemoteInvocation = { request: { namespace: 'terminal', method: 'attach', args: {} }, service: 'terminalController', signal, - peer: { id: 'test-peer' as PeerId, ctx: agent.ctx, dispose: async () => {} }, + peer: { id: 'test-peer' as PeerId, ctx: agent.ctx, principal: { kind: 'owner' }, signal, dispose: async () => {} }, uplink: () => input as AsyncIterable, uplinkWindow, } diff --git a/packages/client/connection/README.i18n.yaml b/packages/client/connection/README.i18n.yaml index 9efedba955..851c94ecbc 100644 --- a/packages/client/connection/README.i18n.yaml +++ b/packages/client/connection/README.i18n.yaml @@ -18,8 +18,8 @@ en: 13b54969ff1d00a4 zh: 4d2ffff0445472ed /deepseek-ai-dsh-client-connection/browser-authentication-and-request-trust: - en: 44956be3cc11908a - zh: d518f765d58f3558 + en: dbd48251ac03d968 + zh: 6323c09d75e56ecc /deepseek-ai-dsh-client-connection/connection-generation: en: 116f94e44c0d87e7 zh: 59ef48984154d858 diff --git a/packages/client/connection/README.md b/packages/client/connection/README.md index d6cb58d42c..a5b4d4cc85 100644 --- a/packages/client/connection/README.md +++ b/packages/client/connection/README.md @@ -44,7 +44,7 @@ HTTPS cookie names and signed audiences include the `https:` scheme, so HTTP coo Before authentication, every request passes `src/api-request-trust.ts`. Its `Host` must be loopback, equal the listener's bind IP literal on any port, or match a `trustedHosts` entry: exact on `host:port`, any port on port-less entries. Host and configured entries use the listener's scheme for WHATWG default-port normalization, not a forwarded header or `Origin`. An attached HTTP(S) `Origin` must name the same authority, with Host parsed under that Origin's scheme to support HTTPS proxies over HTTP upstreams; `sec-fetch-site: cross-site` is refused. Malformed configured authorities fail plugin load. `allowsRemoteAuthorities` reports whether `trustedHosts` permits a non-loopback authority; accepting the bind IP does not change that list. 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` is 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. `OperatorPeer` is exported so a composition without Connection, such as the Gateway's in-process carrier, owns an operator scope with the same contract. +Every request speaks for one Peer, a `PeerScope` whose `principal` names the caller and whose `signal` aborts when its scope ends. `ctx.connection.browserPeer` is the Peer every authenticated browser connection shares; its principal is `{ kind: 'owner' }` and its scope is disposed with the Connection. `ctx.connection.admit(request)` runs the trust and authentication checks and answers with the rejection status or `browserPeer`; the `/api` route and the Gateway's WebSocket upgrade admit through it, and every RPC handler receives the Peer of its call. `ctx.connection.createPeer({ principal })` creates an independent in-process Peer, disposed with the Context that read `ctx.connection` or earlier by its own `dispose()`; no route, header, query, or cookie can create one or set its principal. The exported `Peer` class implements both, and a composition without Connection constructs it directly as `new Peer(owner, { principal })`. `fetch(request, peer)` and the HTTP bridge take the Peer explicitly; an exact route answers 403 to a Peer whose principal is not the owner. Method-level access for other principals belongs to [`dsh-api-remote-access`](../../api/remote-access/README.md). 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. Client disconnection aborts the handler signal; the bridge stops socket writes and drains any remaining response chunks. WebSocket stream ownership remains with API Gateway. diff --git a/packages/client/connection/README.zh.md b/packages/client/connection/README.zh.md index 6ec954f84d..cb23e015bd 100644 --- a/packages/client/connection/README.zh.md +++ b/packages/client/connection/README.zh.md @@ -44,7 +44,7 @@ HTTPS cookie 名称与签名 audience 包含 `https:` scheme,因此即使 host 认证之前,每个请求都经过 `src/api-request-trust.ts`。其 `Host` 必须是 loopback、等于监听器的绑定 IP 字面量(端口不限),或与 `trustedHosts` 条目匹配:带端口的 `host:port` 精确匹配,不带端口的条目匹配任意端口。Host 与配置条目按监听器的 scheme 做 WHATWG 默认端口归一化,不采信转发 header 或 `Origin`。附带的 HTTP(S) `Origin` 必须指向同一 authority;此时按 Origin 的 scheme 解析 Host,以支持 HTTPS 代理转发到 HTTP 上游;`sec-fetch-site: cross-site` 一律拒绝。畸形配置 authority 会让插件加载失败。`allowsRemoteAuthorities` 报告 `trustedHosts` 是否允许非 loopback authority;接受绑定 IP 不会改变该列表。这些检查防御 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。`OperatorPeer` 对外导出,供没有 Connection 的组合(例如 Gateway 的进程内载体)以同一约定拥有一个操作者 scope。 +每个请求都代表一个 Peer:一个 `PeerScope`,其 `principal` 指明调用方,其 `signal` 在 scope 结束时中止。`ctx.connection.browserPeer` 是所有通过认证的浏览器连接共享的 Peer;其 principal 为 `{ kind: 'owner' }`,scope 随 Connection 一起释放。`ctx.connection.admit(request)` 执行信任与认证检查,以拒绝状态或 `browserPeer` 作答;`/api` 路由与 Gateway 的 WebSocket 升级都经它接纳,每个 RPC 处理器都收到本次调用的 Peer。`ctx.connection.createPeer({ principal })` 创建一个独立的进程内 Peer,随读取 `ctx.connection` 的 Context 释放,或更早经其自身的 `dispose()` 释放;任何路由、header、query 或 cookie 都无法创建它或设置其 principal。导出的 `Peer` 类实现这两者,没有 Connection 的组合直接以 `new Peer(owner, { principal })` 构造。`fetch(request, peer)` 与 HTTP bridge 显式接收 Peer;对 principal 不是 owner 的 Peer,exact route 返回 403。其他 principal 的方法级访问归 [`dsh-api-remote-access`](../../api/remote-access/README.zh.md)。 通过认证的共享 HTTP 请求在传输请求体之前经过 `connection/request` waterfall。监听器可以拒绝新请求,或等待 `next()` 直到响应完成;释放所属 fiber 会移除准入行为。Desktop 使用此扩展点,在已批准的安装期间锁住新的 API 工作,而不取消已接纳的工作。客户端断开会中止处理函数的信号;桥接器停止写入 socket,并排空剩余响应块。WebSocket 流仍由 API Gateway 负责。 diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index e9d80a64f4..910cea58c5 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -96,7 +96,7 @@ export interface ClientTransportHooks { /** * The transport owner declares the page owns the Host outright: the Host * runs inside a worker this page spawned, so no other party can reach it and - * the loopback stand-in for "the operator's own machine" is vacuous. + * the loopback stand-in for "the owner's own machine" is vacuous. * `ctx.connection.isLoopback` then reports the privileged surface reachable * regardless of the page authority. Only a shell that assembles its own * transport can set this; served pages never carry the global at all. diff --git a/packages/client/connection/src/http-bridge.ts b/packages/client/connection/src/http-bridge.ts index 713f53dcea..2343b1c38b 100644 --- a/packages/client/connection/src/http-bridge.ts +++ b/packages/client/connection/src/http-bridge.ts @@ -5,6 +5,7 @@ import type { IncomingMessage } from 'node:http' import { Readable } from 'node:stream' +import type { PeerScope } from '@deepseek-ai/dsh-typert-protocol' import type { ConnectionFetchHandler } from './rpc.ts' /** Default carrier cap for all HTTP RPC bodies: sized for the default @@ -30,12 +31,14 @@ interface BridgeServerResponse { * @param req - incoming node:http request. * @param res - node:http response the bridge writes and owns to completion. * @param apiHandler - fetch-shaped API carrier the request is dispatched to. + * @param peer - Peer the carrier admitted the request as. * @param maxRequestBodyBytes - maximum bytes buffered for a buffered route. */ export async function bridge( req: IncomingMessage, res: BridgeServerResponse, apiHandler: ConnectionFetchHandler, + peer: PeerScope, maxRequestBodyBytes = DEFAULT_MAX_REQUEST_BODY_BYTES, ): Promise { const abort = new AbortController() @@ -91,7 +94,7 @@ export async function bridge( duplex: 'half', } as RequestInit & { duplex: 'half' }) } - const response = await apiHandler.fetch(request) + const response = await apiHandler.fetch(request, peer) const requestUnread = bodyMode === 'streaming' && !req.readableEnded const responseHeaders = Object.fromEntries(response.headers.entries()) res.writeHead(response.status, requestUnread ? { ...responseHeaders, connection: 'close' } : responseHeaders) diff --git a/packages/client/connection/src/index.ts b/packages/client/connection/src/index.ts index 34e15f90a6..ce58da1606 100644 --- a/packages/client/connection/src/index.ts +++ b/packages/client/connection/src/index.ts @@ -36,9 +36,10 @@ export type { RpcMessage, ServerResponse, } from './rpc.ts' -export type { PeerId, PeerScope, RemoteInvocation } from '@deepseek-ai/dsh-typert-protocol' +export type { PeerId, PeerScope, Principal, RemoteInvocation } from '@deepseek-ai/dsh-typert-protocol' export { RpcId, transportError } from './rpc.ts' -export { OperatorPeer } from './operator-peer.ts' +export { Peer } from './peer.ts' +export type { PeerOptions } from './peer.ts' export { clientRequestSchema, rpcErrorSchema, @@ -150,7 +151,7 @@ export async function apply(ctx: Context, config?: ConnectionConfig): Promise bridge(req, res, fetchHandler, maxRequestBodyBytes)) + await webCtx.waterfall('connection/request', req, res, () => bridge(req, res, fetchHandler, admission.peer, 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 deleted file mode 100644 index 83325f78e7..0000000000 --- a/packages/client/connection/src/operator-peer.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * 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.ts b/packages/client/connection/src/peer.ts new file mode 100644 index 0000000000..991ac521ba --- /dev/null +++ b/packages/client/connection/src/peer.ts @@ -0,0 +1,49 @@ +/** + * The Peer: one party admitted to this Host. Its creator fixes the principal + * it speaks for and owns its lifetime; Gateway hands it to each Remote call as + * `invocation.peer`. + * @module @deepseek-ai/dsh-client-connection/src/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, Principal } from '@deepseek-ai/dsh-typert-protocol' + +/** Construction options for one {@link Peer}. */ +export interface PeerOptions { + /** Caller identity every call through this Peer speaks for. */ + readonly principal: Principal +} + +/** + * One Peer'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 Peer implements PeerScope { + readonly id: PeerId = randomUUID() as PeerId + readonly ctx: Context + readonly principal: Principal + readonly signal: AbortSignal + private readonly scope: Scope + + /** + * @param owner - Context the scope fiber hangs under; disposing it disposes the Peer. + * @param options - the principal fixed for this Peer's lifetime. + */ + constructor(owner: Context, options: PeerOptions) { + this.principal = options.principal + this.scope = createScope(owner, this) + this.ctx = this.scope.ctx + const lifetime = new AbortController() + this.signal = lifetime.signal + // Runs however the scope ends: dispose() or the owner Context unloading. + this.ctx.effect(() => () => { lifetime.abort(new Error(`Peer ${this.id} was disposed`)) }, 'client-connection: Peer lifetime') + } + + /** Tear down every connection-lifetime registration; racing calls share one completion. */ + dispose(): Promise { + return this.scope.dispose() + } +} diff --git a/packages/client/connection/src/rpc-host.ts b/packages/client/connection/src/rpc-host.ts index 44862c770a..33562d790f 100644 --- a/packages/client/connection/src/rpc-host.ts +++ b/packages/client/connection/src/rpc-host.ts @@ -2,7 +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 type { PeerScope, Principal } from '@deepseek-ai/dsh-typert-protocol' import { RpcId, type ClientRequest, @@ -13,7 +13,7 @@ import { bridge } from './http-bridge.ts' import { isRemoteAuthority, isTrustedApiRequest } from './api-request-trust.ts' import { API_PATH } from './api-path.ts' import type { BrowserAuth } from './browser-auth.ts' -import { OperatorPeer } from './operator-peer.ts' +import { Peer } from './peer.ts' import type { PeerAdmission, ConnectionIndexRequest, @@ -61,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 { - /** The operator Peer every admitted request speaks for. */ - readonly operator: PeerScope + /** The Peer every authenticated browser connection shares. */ + readonly browserPeer: PeerScope private readonly interceptors = new Map() private readonly fetchRoutes = new Map() @@ -80,8 +80,8 @@ export class HostConnectionService extends Service implements HostConnectionHand private readonly browserAuth: BrowserAuth, ) { super(ctx, 'connection') - this.operator = new OperatorPeer(ctx) - ctx.effect(() => () => this.operator.dispose(), 'client-connection: operator Peer') + this.browserPeer = new Peer(ctx, { principal: { kind: 'owner' } }) + ctx.effect(() => () => this.browserPeer.dispose(), 'client-connection: browser Peer') this.allowsRemoteAuthorities = trustedHosts.some(isRemoteAuthority) } @@ -110,10 +110,15 @@ export class HostConnectionService extends Service implements HostConnectionHand return this.browserAuth.isAuthenticated(request, carrier?.protocol === 'https:') ? undefined : 401 } - /** A request that passes the fence and authentication speaks for the operator. */ + /** A request that passes the fence and authentication speaks for the shared browser Peer. */ admit(request: ConnectionTrustRequest): PeerAdmission { const rejection = this.requestRejection(request) - return rejection === undefined ? { peer: this.operator } : { rejection } + return rejection === undefined ? { peer: this.browserPeer } : { rejection } + } + + /** Create one in-process Peer owned by the Context that read this service. */ + createPeer(options: { readonly principal: Principal }): PeerScope { + return new Peer(this.ctx, options) } /** @@ -141,16 +146,21 @@ export class HostConnectionService extends Service implements HostConnectionHand const route = this.fetchRoutes.get(url.pathname) return route?.methods.has(method) === true ? route.requestBody : 'buffered' }, - fetch: (request) => { + fetch: (request, peer) => { const pathname = new URL(request.url).pathname const route = this.fetchRoutes.get(pathname) - if (route?.methods.has(request.method) === true) return route.fetch(request) + if (route?.methods.has(request.method) === true) { + // Exact routes bypass Gateway access checks, so only the owner reaches them. + // oxlint-disable-next-line typescript/no-unnecessary-condition -- other packages merge non-owner kinds into PrincipalMap. + if (peer.principal.kind !== 'owner') return Promise.resolve(new Response('forbidden', { status: 403 })) + return route.fetch(request) + } const endpoint = endpointFromPath(channel, pathname) const interceptor = this.interceptors.get(channel) if (endpoint === undefined || interceptor === undefined || !interceptor.matches(endpoint)) { return Promise.resolve(new Response('not found', { status: 404 })) } - return interceptor.fetchHandler.fetch(request) + return interceptor.fetchHandler.fetch(request, peer) }, } } @@ -180,7 +190,7 @@ export class HostConnectionService extends Service implements HostConnectionHand handler: ConnectionRpcHandler, ): () => Promise { assertChannel(channel) - const fetchHandler = rpcFetchHandler(channel, handler, this.operator) + const fetchHandler = rpcFetchHandler(channel, handler) const route: WebRoute = { kind: 'prefix', path: channel, @@ -191,7 +201,7 @@ export class HostConnectionService extends Service implements HostConnectionHand res.end(admission.rejection === 401 ? 'unauthorized' : 'forbidden') return } - await bridge(req, res, fetchHandler) + await bridge(req, res, fetchHandler, admission.peer) }, } return owner.effect( @@ -211,7 +221,7 @@ export class HostConnectionService extends Service implements HostConnectionHand } const interceptor: ConnectionRpcInterceptor = { matches, - fetchHandler: rpcFetchHandler(channel, handler, this.operator), + fetchHandler: rpcFetchHandler(channel, handler), } return owner.effect(() => { if (this.interceptors.has(channel)) { @@ -228,11 +238,10 @@ export class HostConnectionService extends Service implements HostConnectionHand function rpcFetchHandler( channel: string, handler: ConnectionRpcHandler, - peer: PeerScope, ): ConnectionFetchHandler { return { requestBodyMode: () => 'buffered', - async fetch(request: Request): Promise { + async fetch(request: Request, peer: PeerScope): Promise { const endpoint = endpointFromPath(channel, new URL(request.url).pathname) if (request.method !== 'POST' || endpoint === undefined) { return new Response('not found', { status: 404 }) diff --git a/packages/client/connection/src/rpc.ts b/packages/client/connection/src/rpc.ts index cf30df6204..2d1cfda387 100644 --- a/packages/client/connection/src/rpc.ts +++ b/packages/client/connection/src/rpc.ts @@ -1,7 +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' +import type { PeerScope, Principal } from '@deepseek-ai/dsh-typert-protocol' /** Correlation id minted by a caller and echoed by the Connection response. */ export type RpcId = Branded<'rpc-id'> @@ -115,14 +115,14 @@ export interface ConnectionIndexResponse { end(body?: string): unknown } -/** Outcome of admitting one request: the operator Peer it speaks for, or the status refusing it. */ +/** 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 } /** * Handler invoked after Connection has decoded the transport envelope. - * `peer` is the Peer the request was admitted as: the operator. + * `peer` is the Peer the carrier admitted or the in-process caller supplied. */ export type ConnectionRpcHandler = ( endpoint: string, @@ -148,7 +148,11 @@ export interface ConnectionFetchRoute { readonly methods: readonly ConnectionFetchMethod[] /** Buffered requests obey the configured JSON cap; streaming requests arrive with backpressure and no aggregate cap. */ readonly requestBody: ConnectionRequestBodyMode - /** Handle one request after the physical carrier has applied its trust and authentication policy. */ + /** + * Handle one request after the physical carrier has applied its trust and + * authentication policy. Only owner Peers reach an exact route; the shared + * handler answers 403 for every other principal. + */ readonly fetch: (request: Request) => Promise } @@ -195,8 +199,11 @@ export interface HostConnectionHandle { readonly rpc: HostConnectionRpc /** Exact Fetch routes for streaming or browser-native responses. */ readonly fetch: HostConnectionFetch - /** The operator Peer every admitted request speaks for; its scope lives as long as Connection. */ - readonly operator: PeerScope + /** + * The Peer every authenticated browser connection shares; its principal is + * the owner and its scope lives as long as Connection. + */ + readonly browserPeer: PeerScope /** * Whether this deployment accepts authorities beyond loopback: true only @@ -220,13 +227,23 @@ export interface HostConnectionHandle { requestRejection(request: ConnectionTrustRequest): ConnectionRequestRejection /** - * Admit one request: it passes {@link requestRejection} and speaks for the - * operator, or it is refused with that status. + * Admit one network request: it passes {@link requestRejection} and speaks + * for {@link browserPeer}, or it is refused with that status. * @param request - request headers from the HTTP or upgrade request. - * @returns the operator Peer, or the rejection status. + * @returns the admitted Peer, or the rejection status. */ admit(request: ConnectionTrustRequest): PeerAdmission + /** + * Create an independent Peer. In-process only: no route, header, query, or + * cookie reaches it. Disposed with the Context that read `ctx.connection`, + * or earlier by its own `dispose()`. + * @param options - caller identity the Peer speaks for. + * @param options.principal - principal fixed for the Peer's lifetime. + * @returns a new Peer distinct from every other Peer. + */ + createPeer(options: { readonly principal: Principal }): PeerScope + /** * Authenticate one frontend index request, owning a token redirect or 401. * The cookie this mints is `Secure` when the mounted Web carrier serves TLS, @@ -257,9 +274,10 @@ export interface ConnectionFetchHandler { /** * Dispatch one already-authenticated request. * @param request - Fetch request below the shared channel. - * @returns the registered response or a 404 response. + * @param peer - Peer to dispatch as; required, never defaulted. + * @returns the registered response, a 403 response for an exact route and a non-owner Peer, or a 404 response. */ - fetch(request: Request): Promise + fetch(request: Request, peer: PeerScope): Promise } /** Client caller for logical RPC channels carried by the current transport. */ diff --git a/packages/client/connection/tests/binary-rpc.host.spec.ts b/packages/client/connection/tests/binary-rpc.host.spec.ts index 05ec270545..4431199ec6 100644 --- a/packages/client/connection/tests/binary-rpc.host.spec.ts +++ b/packages/client/connection/tests/binary-rpc.host.spec.ts @@ -35,7 +35,7 @@ describe('Connection binary RPC', () => { attachments: [{ path: ['data'], bytes: data }], })) const handler = connection.createSharedFetchHandler('/api') - ctx.webServer.register({ kind: 'prefix', path: '/api', handler: (req, res) => bridge(req, res, handler) }) + ctx.webServer.register({ kind: 'prefix', path: '/api', handler: (req, res) => bridge(req, res, handler, connection.browserPeer) }) let transferred = 0 let encoding: string | undefined const rpc = createWebConnectionRpc((path, init) => new Promise((resolve, reject) => { @@ -111,7 +111,7 @@ describe('Connection binary RPC', () => { const shared = connection.createSharedFetchHandler('/api') const mediaTypes: (string | null)[] = [] const rpc = createWebConnectionRpc(async (url, init) => { - const response = await shared.fetch(new Request(new URL(url, 'http://host'), init)) + const response = await shared.fetch(new Request(new URL(url, 'http://host'), init), connection.browserPeer) mediaTypes.push(response.headers.get('content-type')) return response }) @@ -144,7 +144,7 @@ describe('Connection binary RPC', () => { attachments: [{ path: ['content'], bytes: content }], })) const handler = connection.createSharedFetchHandler('/api') - const rpc = createWebConnectionRpc((url, init) => handler.fetch(new Request(new URL(url, 'http://host'), init))) + const rpc = createWebConnectionRpc((url, init) => handler.fetch(new Request(new URL(url, 'http://host'), init), connection.browserPeer)) expect(await rpc.call('/api', 'fixture/read', {})).toEqual({ ok: true, value: { content: new Uint8Array([0, 128, 255]), metadata: { count: 1 } }, }) @@ -211,7 +211,7 @@ describe('Connection binary RPC', () => { const connection = ctx.get('connection') as HostConnectionService connection.rpc.intercept('/api', () => true, async () => next) const handler = connection.createSharedFetchHandler('/api') - const rpc = createWebConnectionRpc((url, init) => handler.fetch(new Request(new URL(url, 'http://host'), init))) + const rpc = createWebConnectionRpc((url, init) => handler.fetch(new Request(new URL(url, 'http://host'), init), connection.browserPeer)) const expected = { files: [{ name: 'a.png', content: data }, { name: 'b.png', content: new Uint8Array([2, 3]) }, { name: 'empty' }], 'dots./[]': [new Uint8Array(), null, [data]], diff --git a/packages/client/connection/tests/fetch-routes.host.spec.ts b/packages/client/connection/tests/fetch-routes.host.spec.ts index ddb9b3b264..a6b6d159c6 100644 --- a/packages/client/connection/tests/fetch-routes.host.spec.ts +++ b/packages/client/connection/tests/fetch-routes.host.spec.ts @@ -1,4 +1,5 @@ import { Context } from '@deepseek-ai/cordis' +import type { PeerScope, Principal } from '@deepseek-ai/dsh-typert-protocol' import { describe, expect, it, vi } from 'vitest' import type { BrowserAuth } from '../src/browser-auth.ts' import { HostConnectionService } from '../src/rpc-host.ts' @@ -18,6 +19,15 @@ async function mounted(): Promise<{ } } +const GUEST: Principal = { kind: 'test-guest', guest: 'connection' } + +declare module '@deepseek-ai/dsh-typert-protocol' { + interface PrincipalMap { + /** Test-only non-owner caller shared by the access specs. */ + 'test-guest': { readonly kind: 'test-guest'; readonly guest: string } + } +} + describe('Connection exact Fetch routes', () => { it('dispatches owned methods and returns 404 for unclaimed requests', async () => { const { connection, dispose: disposeFiber } = await mounted() @@ -33,7 +43,7 @@ describe('Connection exact Fetch routes', () => { const response = await shared.fetch(new Request( 'http://host/api/session.export?sessionId=session-1', - )) + ), connection.browserPeer) expect(shared.requestBodyMode({ method: 'POST', url: new URL('http://host/api/session.export'), })).toBe('streaming') @@ -43,12 +53,12 @@ describe('Connection exact Fetch routes', () => { expect(response.status).toBe(200) expect(await response.json()).toEqual({ query: 'session-1' }) expect(route).toHaveBeenCalledOnce() - const post = await shared.fetch(new Request('http://host/api/session.export', { method: 'POST' })) + const post = await shared.fetch(new Request('http://host/api/session.export', { method: 'POST' }), connection.browserPeer) expect(post.status).toBe(200) expect(route).toHaveBeenCalledTimes(2) await dispose() - const withdrawn = await shared.fetch(new Request('http://host/api/session.export')) + const withdrawn = await shared.fetch(new Request('http://host/api/session.export'), connection.browserPeer) expect(withdrawn.status).toBe(404) await disposeFiber() }) @@ -80,4 +90,76 @@ describe('Connection exact Fetch routes', () => { })).not.toThrow() await disposeFiber() }) + + it('refuses exact routes to non-owner Peers while the RPC interceptor still receives them', async () => { + const { connection, dispose: disposeFiber } = await mounted() + const route = vi.fn(async () => new Response('route')) + const disposeRoute = connection.fetch.register({ + path: '/api/session.export', methods: ['GET'], requestBody: 'buffered', fetch: route, + }) + const peers: PeerScope[] = [] + const disposeInterceptor = connection.rpc.intercept('/api', () => true, async (_endpoint, _payload, _signal, peer) => { + peers.push(peer) + return { ok: true, value: null } + }) + const guest = connection.createPeer({ principal: GUEST }) + const shared = connection.createSharedFetchHandler('/api') + + const refused = await shared.fetch(new Request('http://host/api/session.export'), guest) + expect(refused.status).toBe(403) + expect(route).not.toHaveBeenCalled() + expect((await shared.fetch(new Request('http://host/api/session.export'), connection.browserPeer)).status).toBe(200) + + const rpc = await shared.fetch(new Request('http://host/api/goals/get', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ type: 'client-request', rpcId: 'rpc-1', method: 'goals/get', payload: { args: {} } }), + }), guest) + expect(rpc.status).toBe(200) + expect(peers).toEqual([guest]) + await disposeInterceptor() + await disposeRoute() + await disposeFiber() + }) +}) + +describe('Connection in-process Peers', () => { + it('creates independent Peers that keep their principal and leave with the reading Context', async () => { + const { connection, dispose } = await mounted() + const first = connection.createPeer({ principal: GUEST }) + const second = connection.createPeer({ principal: GUEST }) + const owner = connection.createPeer({ principal: { kind: 'owner' } }) + expect(new Set([first.id, second.id, owner.id, connection.browserPeer.id]).size).toBe(4) + expect(first.principal).toBe(GUEST) + expect(owner.principal).toEqual({ kind: 'owner' }) + expect(first.ctx).not.toBe(second.ctx) + + const released = vi.fn() + first.ctx.effect(() => released, 'first Peer registration') + await first.dispose() + expect(released).toHaveBeenCalledOnce() + expect(() => first.ctx.effect(() => () => {})).toThrow() + // Disposing one Peer leaves the others usable. + expect(() => second.ctx.effect(() => () => {})()).not.toThrow() + + await dispose() + }) + + it('disposes a created Peer with the plugin that read the Connection', async () => { + const ctx = new Context() + const provider = ctx.plugin((pluginCtx) => { new HostConnectionService(pluginCtx, [], {} as BrowserAuth) }) + await provider.await() + let created: PeerScope | undefined + const consumer = ctx.plugin({ + inject: ['connection'], + apply: (consumerCtx: Context) => { created = consumerCtx.connection.createPeer({ principal: GUEST }) }, + }) + await consumer.await() + const lingering = vi.fn() + created!.ctx.effect(() => lingering, 'consumer Peer registration') + await consumer.dispose() + expect(lingering).toHaveBeenCalledOnce() + expect(() => created!.ctx.effect(() => () => {})).toThrow() + await provider.dispose() + }) }) diff --git a/packages/client/connection/tests/http-bridge.host.spec.ts b/packages/client/connection/tests/http-bridge.host.spec.ts index e9fa9df364..e612a0608d 100644 --- a/packages/client/connection/tests/http-bridge.host.spec.ts +++ b/packages/client/connection/tests/http-bridge.host.spec.ts @@ -1,8 +1,12 @@ import { EventEmitter } from 'node:events' import { Readable } from 'node:stream' import type { IncomingMessage, ServerResponse } from 'node:http' +import { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' import { bridge } from '../src/http-bridge.ts' +import { Peer } from '../src/peer.ts' + +const PEER = new Peer(new Context(), { principal: { kind: 'owner' } }) describe('HTTP bridge abort', () => { it('destroys a declared-oversize request instead of draining it', async () => { @@ -26,7 +30,7 @@ describe('HTTP bridge abort', () => { await bridge(request, response, { requestBodyMode: () => 'buffered', fetch: () => { throw new Error('a rejected request must never reach the handler') }, - }, 1000) + }, PEER, 1000) // The socket must not stay parked draining a body the client can trickle // at will after the rejection — same discipline as the chunked overrun. expect(status).toBe(413) @@ -68,7 +72,7 @@ describe('HTTP bridge abort', () => { } return Response.json({ aborted: fetchRequest.signal.aborted }) }, - }, Number.MAX_SAFE_INTEGER) + }, PEER, Number.MAX_SAFE_INTEGER) await started response.emit('close') await pending @@ -102,7 +106,7 @@ describe('HTTP bridge abort', () => { const pending = bridge(request, response, { requestBodyMode: () => 'buffered', fetch: async () => new Response(body), - }).then(() => { settled = true }) + }, PEER).then(() => { settled = true }) try { await expect.poll(() => settled).toBe(true) expect(writes).toBe(1) @@ -134,7 +138,7 @@ describe('HTTP bridge abort', () => { const pending = bridge(request, response, { requestBodyMode: () => 'buffered', fetch: async () => new Response(body), - }) + }, PEER) await started.promise response.emit('close') controller.enqueue(Uint8Array.of(1)) @@ -172,7 +176,7 @@ describe('HTTP bridge abort', () => { for await (const chunk of input.body) received.push(chunk) return new Response('stored') }, - }, 1) + }, PEER, 1) await started expect(received).toEqual([]) @@ -206,7 +210,7 @@ describe('HTTP bridge abort', () => { await bridge(request, response, { requestBodyMode: () => 'streaming', fetch: () => Promise.resolve(new Response(null, { status: 415 })), - }, 1) + }, PEER, 1) expect(status).toBe(415) expect(headers).toMatchObject({ connection: 'close' }) expect(destroyed).toEqual([true]) diff --git a/packages/client/connection/tests/node-half.host.spec.ts b/packages/client/connection/tests/node-half.host.spec.ts index 9a16fe0c43..ce37c41383 100644 --- a/packages/client/connection/tests/node-half.host.spec.ts +++ b/packages/client/connection/tests/node-half.host.spec.ts @@ -555,7 +555,7 @@ describe('connection node half', () => { await fiber.dispose() }) - it('admits every trusted, authenticated request as the operator Peer and hands each call that Peer', async () => { + it('admits every trusted, authenticated request as the owner browser 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( @@ -569,7 +569,7 @@ describe('connection node half', () => { 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 }) + expect(connection.admit(fakeRequest({ host: '127.0.0.1:3080', cookie }))).toEqual({ peer: connection.browserPeer }) const route = routes.find(candidate => candidate.path === API_PATH)! const request: ClientRequest = { @@ -581,17 +581,18 @@ describe('connection node half', () => { const answered = fakeResponse() 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 } }) - // A shell-owned carrier dispatches the shared handler without the bridge and speaks for the operator too. + // A shell-owned carrier dispatches the shared handler without the bridge and speaks for the browser Peer 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), - })) + }), connection.browserPeer) expect(direct.status).toBe(200) - expect(peers).toEqual([connection.operator, connection.operator]) + expect(peers).toEqual([connection.browserPeer, connection.browserPeer]) + expect(connection.browserPeer.principal).toEqual({ kind: 'owner' }) // Racing disposals share one completion, and the scope goes with the Connection. - await Promise.all([connection.operator.dispose(), connection.operator.dispose()]) + await Promise.all([connection.browserPeer.dispose(), connection.browserPeer.dispose()]) await remove() await dispose() }) diff --git a/packages/client/connection/tsconfig.host.json b/packages/client/connection/tsconfig.host.json index 66d9844f17..0a60bb86ea 100644 --- a/packages/client/connection/tsconfig.host.json +++ b/packages/client/connection/tsconfig.host.json @@ -14,7 +14,7 @@ "src/http-bridge.ts", "src/index.ts", "src/loopback-hostname.ts", - "src/operator-peer.ts", + "src/peer.ts", "src/recovery-config.ts", "src/rpc-host.ts", "src/rpc-schema.ts", diff --git a/packages/client/ui-deliverables/tests/changes-open.host.spec.ts b/packages/client/ui-deliverables/tests/changes-open.host.spec.ts index 6fda6fed4a..9921990174 100644 --- a/packages/client/ui-deliverables/tests/changes-open.host.spec.ts +++ b/packages/client/ui-deliverables/tests/changes-open.host.spec.ts @@ -69,10 +69,13 @@ async function fixture() { apply: registerPresentOpen, }) const handler = connection.createSharedFetchHandler('/api') - const open = (query = '?sessionId=owner&seq=9&index=0') => handler.fetch(new Request(`http://localhost${CHANGES_OPEN_PATH}${query}`, { method: 'POST' })) - const read = (query = '?sessionId=owner&seq=9') => handler.fetch(new Request(`http://localhost${CHANGED_FILES_PATH}${query}`)) - const compare = (query = '?sessionId=owner&seq=9&index=0') => handler.fetch(new Request(`http://localhost${CHANGES_DIFF_PATH}${query}`)) - return { handler, applications, root, cwd, ctx, data, readEvent, open, read, compare, comparison, diff, opener, outside, summary } + const open = (query = '?sessionId=owner&seq=9&index=0') => handler.fetch(new Request(`http://localhost${CHANGES_OPEN_PATH}${query}`, { method: 'POST' }), connection.browserPeer) + const read = (query = '?sessionId=owner&seq=9') => handler.fetch(new Request(`http://localhost${CHANGED_FILES_PATH}${query}`), connection.browserPeer) + const compare = (query = '?sessionId=owner&seq=9&index=0') => handler.fetch(new Request(`http://localhost${CHANGES_DIFF_PATH}${query}`), connection.browserPeer) + return { + browserPeer: connection.browserPeer, handler, applications, root, cwd, ctx, data, readEvent, open, read, compare, comparison, diff, + opener, outside, summary, + } } describe('change summary route', () => { @@ -203,17 +206,17 @@ describe('changed files native open route', () => { it('queries handlers only after file authorization and forwards explicit application choices', async () => { - const { handler, applications, opener, open, ctx } = await fixture() + const { browserPeer, handler, applications, opener, open, ctx } = await fixture() const url = 'http://localhost/api/changes.open?sessionId=owner&seq=9&index=0' - const response = await handler.fetch(new Request(url)) + const response = await handler.fetch(new Request(url), browserPeer) expect(response.status).toBe(200) expect(await response.json()).toEqual([{ id: 'player', name: 'Player', default: true, icon: null }]) expect(opener).not.toHaveBeenCalled() expect((await open('?sessionId=owner&seq=9&index=0&application=player')).status).toBe(204) expect(opener.mock.lastCall?.[0]).toMatchObject({ application: 'player' }) - expect((await handler.fetch(new Request(url.replace('index=0', 'index=999')))).status).toBe(404) + expect((await handler.fetch(new Request(url.replace('index=0', 'index=999')), browserPeer)).status).toBe(404) vi.spyOn(ctx.fs, 'processPathFromHostPath').mockReturnValue(undefined) - expect((await handler.fetch(new Request(url))).status).toBe(422) + expect((await handler.fetch(new Request(url), browserPeer)).status).toBe(422) expect(applications).toHaveBeenCalledOnce() }) diff --git a/packages/client/ui-deliverables/tests/present-open.host.spec.ts b/packages/client/ui-deliverables/tests/present-open.host.spec.ts index b313ee0aa8..6d6057d204 100644 --- a/packages/client/ui-deliverables/tests/present-open.host.spec.ts +++ b/packages/client/ui-deliverables/tests/present-open.host.spec.ts @@ -54,17 +54,19 @@ async function fixture() { const handler = connection.createSharedFetchHandler('/api') const open = (query = '?sessionId=owner&seq=7&index=0', signal?: AbortSignal) => handler.fetch(new Request( `http://localhost${PRESENT_OPEN_PATH}${query}`, { method: 'POST', signal: signal ?? null }, - )) - return { applications, root, cwd, ctx, fiber, file, session, readEvent, open, opener, handler, resolveAgent } + ), connection.browserPeer) + return { + browserPeer: connection.browserPeer, applications, root, cwd, ctx, fiber, file, session, readEvent, open, opener, handler, resolveAgent, + } } describe('Presented workspace file native open route', () => { it('opens the source itself with current bytes and leaves it intact at disposal', async () => { - const { cwd, open, file, fiber, opener, handler, ctx } = await fixture() + const { browserPeer, cwd, open, file, fiber, opener, handler, ctx } = await fixture() const source = await realpath(join(cwd, file.path)) expect(presentedFileUrl(SessionId('owner'), 7, 0)).toBe('api/present.open?sessionId=owner&seq=7&index=0') - expect((await handler.fetch(new Request(`http://localhost${PRESENT_OPEN_PATH}`))).status).toBe(400) - expect((await handler.fetch(new Request('http://localhost/api/present.download?sessionId=owner&seq=7&index=0'))).status).toBe(404) + expect((await handler.fetch(new Request(`http://localhost${PRESENT_OPEN_PATH}`), browserPeer)).status).toBe(400) + expect((await handler.fetch(new Request('http://localhost/api/present.download?sessionId=owner&seq=7&index=0'), browserPeer)).status).toBe(404) for (const contents of ['current source', 'edited source']) { await writeFile(source, contents) const response = await open() @@ -189,8 +191,8 @@ describe('Presented workspace file native open route', () => { it('reports the serving desktop and reveals only an authorized declared source', async () => { - const { cwd, file, open, opener, handler } = await fixture() - const info = await handler.fetch(new Request('http://localhost/api/present.host')) + const { browserPeer, cwd, file, open, opener, handler } = await fixture() + const info = await handler.fetch(new Request('http://localhost/api/present.host'), browserPeer) expect(await info.json()).toEqual({ name: 'desktop', available: true, fileManager: 'finder' }) expect((await open('?sessionId=owner&seq=7&index=0&action=reveal')).status).toBe(204) expect(opener).toHaveBeenCalledWith({ path: await realpath(join(cwd, file.path)), action: 'reveal' }, expect.any(AbortSignal)) @@ -201,9 +203,9 @@ it('reports the serving desktop and reveals only an authorized declared source', }) it('refuses native actions when the configured Host desktop is unavailable', async () => { - const { ctx, open, opener, handler } = await fixture() + const { browserPeer, ctx, open, opener, handler } = await fixture() vi.spyOn(ctx.sessionController, 'workspaceDesktop').mockReturnValue({ name: 'desktop', available: false, fileManager: 'finder' }) - expect(await (await handler.fetch(new Request('http://localhost/api/present.host'))).json()).toMatchObject({ available: false }) + expect(await (await handler.fetch(new Request('http://localhost/api/present.host'), browserPeer)).json()).toMatchObject({ available: false }) for (const action of ['open', 'reveal']) { expect((await open(`?sessionId=owner&seq=7&index=0&action=${action}`)).status).toBe(409) } @@ -237,16 +239,16 @@ it('uses the deployment workspace root when the viewed Session has no cwd', asyn it('queries handlers only after file authorization and forwards explicit application choices', async () => { - const { handler, applications, opener, open, ctx } = await fixture() + const { browserPeer, handler, applications, opener, open, ctx } = await fixture() const url = 'http://localhost/api/present.open?sessionId=owner&seq=7&index=0' - const response = await handler.fetch(new Request(url)) + const response = await handler.fetch(new Request(url), browserPeer) expect(response.status).toBe(200) expect(await response.json()).toEqual([{ id: 'player', name: 'Player', default: true, icon: null }]) expect(opener).not.toHaveBeenCalled() expect((await open('?sessionId=owner&seq=7&index=0&application=player')).status).toBe(204) expect(opener.mock.lastCall?.[0]).toMatchObject({ application: 'player' }) - expect((await handler.fetch(new Request(url.replace('index=0', 'index=999')))).status).toBe(404) + expect((await handler.fetch(new Request(url.replace('index=0', 'index=999')), browserPeer)).status).toBe(404) vi.spyOn(ctx.fs, 'processPathFromHostPath').mockReturnValue(undefined) - expect((await handler.fetch(new Request(url))).status).toBe(422) + expect((await handler.fetch(new Request(url), browserPeer)).status).toBe(422) expect(applications).toHaveBeenCalledOnce() }) diff --git a/packages/experimental/inspector/tests/loader-composition.host.spec.ts b/packages/experimental/inspector/tests/loader-composition.host.spec.ts index 462a5b146a..f4972ba680 100644 --- a/packages/experimental/inspector/tests/loader-composition.host.spec.ts +++ b/packages/experimental/inspector/tests/loader-composition.host.spec.ts @@ -83,12 +83,13 @@ describe('experimental Inspector through a real Loader composition', () => { .find(entry => entry.options.name === '@deepseek-ai/dsh-experimental-inspector') expect(inspectorEntry?.disabled).toBe(false) expect(open).toHaveBeenCalledTimes(flag ? 1 : 0) - const api = (context.connection as HostConnectionService).createSharedFetchHandler('/api') + const connection = context.connection as HostConnectionService + const api = connection.createSharedFetchHandler('/api') expect(inspectorEntry?.fiber).toBeDefined() await vi.waitFor(async () => { expect((await context!.inspector.cordis.getTree()).host?.source.kind).toBe('host') }) - const bootstrap = await api.fetch(new Request(`http://localhost${INSPECTOR_BOOTSTRAP_PATH}`)) + const bootstrap = await api.fetch(new Request(`http://localhost${INSPECTOR_BOOTSTRAP_PATH}`), connection.browserPeer) expect(await bootstrap.json()).toHaveProperty('endpoint', expect.stringContaining('/ingest')) await inspectorEntry!.fiber!.dispose() diff --git a/packages/experimental/inspector/tests/plugin.host.spec.ts b/packages/experimental/inspector/tests/plugin.host.spec.ts index 0fe950bfed..79ba34dd6a 100644 --- a/packages/experimental/inspector/tests/plugin.host.spec.ts +++ b/packages/experimental/inspector/tests/plugin.host.spec.ts @@ -79,7 +79,7 @@ describe('experimental Inspector Host plugin', () => { const value = bootstrap?.kind === 'global' ? bootstrap.value : undefined const api = connection.createSharedFetchHandler('/api') - const fetched = await api.fetch(new Request(`http://localhost${INSPECTOR_BOOTSTRAP_PATH}`)) + const fetched = await api.fetch(new Request(`http://localhost${INSPECTOR_BOOTSTRAP_PATH}`), connection.browserPeer) expect(fetched.headers.get('cache-control')).toBe('no-store') expect(await fetched.json()).toEqual(value) expect(open).not.toHaveBeenCalled() @@ -115,7 +115,7 @@ describe('experimental Inspector Host plugin', () => { const afterDispose: IndexInjection[] = [] context.emit('webserver/index-inject', afterDispose) expect(afterDispose).toEqual([]) - expect((await api.fetch(new Request(`http://localhost${INSPECTOR_BOOTSTRAP_PATH}`))).status).toBe(404) + expect((await api.fetch(new Request(`http://localhost${INSPECTOR_BOOTSTRAP_PATH}`), connection.browserPeer)).status).toBe(404) }) it('closes the started Worker when a later plugin registration fails', async () => { @@ -155,7 +155,7 @@ describe('experimental Inspector Host plugin', () => { app: { name: 'chrome' }, wait: false, }) const api = connection.createSharedFetchHandler('/api') - const response = await api.fetch(new Request(`http://localhost${INSPECTOR_BOOTSTRAP_PATH}`)) + const response = await api.fetch(new Request(`http://localhost${INSPECTOR_BOOTSTRAP_PATH}`), connection.browserPeer) expect(response.status).toBe(200) expect(await response.json()).toHaveProperty('endpoint', expect.stringContaining('/ingest')) }) diff --git a/packages/experimental/webworker-runtime/src/worker-host.ts b/packages/experimental/webworker-runtime/src/worker-host.ts index caca4d7bfc..39769f1871 100644 --- a/packages/experimental/webworker-runtime/src/worker-host.ts +++ b/packages/experimental/webworker-runtime/src/worker-host.ts @@ -270,12 +270,13 @@ export function createWorkerHost(options: WorkerHostOptions): WorkerHost { const usage = loader.usage() console.info(`webworker host: tree active (modules=${String(usage.modules)}, data overlays=${String(overlays.length)}, direct lane=connection.createSharedFetchHandler, als causality=${options.alsCausality === undefined ? 'inert' : 'snapshot/restore'}, image lowering=${LOWERING_VERSION})`) + // The worker tree serves only the page that spawned it, so every call speaks for the browser Peer. + const peer = connection.browserPeer tunnel.serve({ - directFetch: (request: Request) => handler.fetch(request), + directFetch: (request: Request) => handler.fetch(request, peer), bootPayload: () => readBootPayload(ctx), - // 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), + typertGateway.wireStream.open(endpoint, payload, uplink, peer, signal), streamFailure: typertGateway.wireStream.failure, }) started.resolve() diff --git a/packages/session-query/session-log-export/tests/archive.host.spec.ts b/packages/session-query/session-log-export/tests/archive.host.spec.ts index 6e88d8a76f..61791185b3 100644 --- a/packages/session-query/session-log-export/tests/archive.host.spec.ts +++ b/packages/session-query/session-log-export/tests/archive.host.spec.ts @@ -180,7 +180,7 @@ async function buildApi( await fiber.await() const handler = connection.createSharedFetchHandler('/api') return { - fetch: handler, + fetch: { fetch: (request: Request) => handler.fetch(request, connection.browserPeer) }, downloads: { sessionLog: ( request: { sessionId: SessionId; includeDescendants: boolean }, @@ -189,7 +189,7 @@ async function buildApi( const url = new URL(`http://host${SessionLogExport.SESSION_LOG_EXPORT_PATH}`) url.searchParams.set('sessionId', request.sessionId) url.searchParams.set('includeDescendants', String(request.includeDescendants)) - return handler.fetch(new Request(url, { signal })) + return handler.fetch(new Request(url, { signal }), connection.browserPeer) }, }, } diff --git a/packages/session-query/session-log-export/tests/route.host.spec.ts b/packages/session-query/session-log-export/tests/route.host.spec.ts index 554fdb73e9..5d793aec0e 100644 --- a/packages/session-query/session-log-export/tests/route.host.spec.ts +++ b/packages/session-query/session-log-export/tests/route.host.spec.ts @@ -65,7 +65,7 @@ describe('Session log export Fetch route', () => { const response = await shared.fetch(new Request( `http://host${SESSION_LOG_EXPORT_PATH}?sessionId=session-1`, - )) + ), connection.browserPeer) expect(response.status).toBe(200) expect(response.headers.get('content-type')).toBe('application/zip') const files = unzipSync(new Uint8Array(await response.arrayBuffer())) @@ -73,26 +73,26 @@ describe('Session log export Fetch route', () => { const head = await shared.fetch(new Request( `http://host${SESSION_LOG_EXPORT_PATH}?sessionId=session-1`, { method: 'HEAD' }, - )) + ), connection.browserPeer) expect(head.status).toBe(200) expect(head.body).toBeNull() await dispose() expect((await shared.fetch(new Request( `http://host${SESSION_LOG_EXPORT_PATH}?sessionId=session-1`, - ))).status).toBe(404) + ), connection.browserPeer)).status).toBe(404) }) it('validates the query before reporting missing export services', async () => { const { connection, dispose } = await mounted(false) const shared = connection.createSharedFetchHandler('/api') - expect((await shared.fetch(new Request(`http://host${SESSION_LOG_EXPORT_PATH}`))).status).toBe(400) + expect((await shared.fetch(new Request(`http://host${SESSION_LOG_EXPORT_PATH}`), connection.browserPeer)).status).toBe(400) expect((await shared.fetch(new Request( `http://host${SESSION_LOG_EXPORT_PATH}?sessionId=session-1&includeDescendants=1`, - ))).status).toBe(400) + ), connection.browserPeer)).status).toBe(400) expect((await shared.fetch(new Request( `http://host${SESSION_LOG_EXPORT_PATH}?sessionId=session-1`, - ))).status).toBe(500) + ), connection.browserPeer)).status).toBe(500) await dispose() }) diff --git a/packages/typert/protocol/src/index.ts b/packages/typert/protocol/src/index.ts index cac28eace5..e6a7a91c02 100644 --- a/packages/typert/protocol/src/index.ts +++ b/packages/typert/protocol/src/index.ts @@ -13,6 +13,7 @@ export { TYPERT_OWNED_VALUE, isTypertOwnedValue, typertOwnedValue } from './owne export type { TypertOwnedValue } from './owned-value.ts' export { isRemoteJsonValue, isRemoteUplinkItem } from './json-value.ts' export { createTypertRemoteInfo, isTypertRemoteMethod } from './remote-info.ts' +export { principalOf } from './principal.ts' const TYPERT_REMOTE_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/ @@ -31,6 +32,8 @@ export type { InvocationSourceLocation, PeerId, PeerScope, + Principal, + PrincipalMap, RemoteDecoded, RemoteErrorCode, RemoteErrorDetailsMap, diff --git a/packages/typert/protocol/src/principal.ts b/packages/typert/protocol/src/principal.ts new file mode 100644 index 0000000000..0c3abf1688 --- /dev/null +++ b/packages/typert/protocol/src/principal.ts @@ -0,0 +1,17 @@ +/** + * Reads the non-owner caller of the current Remote call. + * @module @deepseek-ai/dsh-typert-protocol/principal + */ + +import type { Principal, RemoteInvocation } from './types.ts' + +/** + * The identity a Remote-call producer records on durable output. + * @param invocation - `ctx.invocation`; `undefined` outside a Remote call. + * @returns the caller's principal when it is not the owner; `undefined` for the + * owner and for trusted in-process code outside a Remote call. + */ +export function principalOf(invocation: RemoteInvocation | undefined): Principal | undefined { + const principal = invocation?.peer.principal + return principal?.kind === 'owner' ? undefined : principal +} diff --git a/packages/typert/protocol/src/types.ts b/packages/typert/protocol/src/types.ts index 43743a34e8..d82b46c44f 100644 --- a/packages/typert/protocol/src/types.ts +++ b/packages/typert/protocol/src/types.ts @@ -383,13 +383,31 @@ export interface InvocationDescriptor { export type PeerId = Branded<'PeerId'> /** - * One Peer's session on this Host. Connection owns it: `ctx` is the Cordis + * Merge-extensible caller identities. Each driver declares its own entry. + * Every entry is a JSON value with a unique literal `kind`, carries no + * credential, and is safe to persist in a Session log. + */ +export interface PrincipalMap { + /** The local user who owns this Host. */ + owner: { readonly kind: 'owner' } +} + +/** One caller identity; the authorization layer reads only this. */ +export type Principal = PrincipalMap[keyof PrincipalMap] + +/** + * One Peer's session on this Host. Its creator 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. + * Peer, and `principal` is the caller identity fixed when the Peer was created. */ export interface PeerScope { readonly id: PeerId + /** Connection-lifetime registrations; disposed with the Peer. */ readonly ctx: Context + /** Caller identity fixed at creation by in-process Host code; no wire field can set it. */ + readonly principal: Principal + /** Aborted once the Peer is disposed; Remote streams that speak for the Peer end with it. */ + readonly signal: AbortSignal /** * Tear down every registration made through `ctx`. * @returns settles once the scope has quiesced; racing calls share one completion. @@ -418,7 +436,7 @@ export interface RemoteInvocation { } /** Cordis service key of the receiving Service. */ readonly service: string - /** Peer the call speaks for; an in-process carrier speaks for the operator. */ + /** Peer the call speaks for; every carrier names it explicitly. */ readonly peer: PeerScope /** Carrier cancellation: Client cancel, socket close, or an uplink failure. */ readonly signal: AbortSignal diff --git a/snapshots/session/cordis-inspect-timeout/client-fixture.mjs b/snapshots/session/cordis-inspect-timeout/client-fixture.mjs index 75b456bd55..d84658a049 100644 --- a/snapshots/session/cordis-inspect-timeout/client-fixture.mjs +++ b/snapshots/session/cordis-inspect-timeout/client-fixture.mjs @@ -18,10 +18,18 @@ export function registerSilentClientTransport(ctx) { else signal.addEventListener('abort', resolve, { once: true }) }) }, { home: '/snapshot-host' })) + // The owner's in-process Client: every Gateway carrier names its Peer. + const peer = { + id: 'snapshot-client-peer', + ctx, + principal: { kind: 'owner' }, + signal: new AbortController().signal, + dispose: async () => {}, + } return async () => { const controller = new AbortController() const stream = await ctx.typertGateway.wireStream.open( - '$events', { args: {} }, (async function* () {})(), undefined, controller.signal, + '$events', { args: {} }, (async function* () {})(), peer, controller.signal, ) const iterator = stream[Symbol.asyncIterator]() const close = ctx.effect(() => async () => { From d6e408d9577b123a6bab2c4d06c116717aeccf17 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:46:28 +0800 Subject: [PATCH 16/35] feat(session-controller): record non-owner principals on user message sources --- docs/persistence-catalog.i18n.yaml | 314 +- docs/persistence-catalog.md | 225 +- docs/persistence-catalog.zh.md | 225 +- .../2026-10-13-user-rpc-principal.i18n.yaml | 28 + .../2026-10-13-user-rpc-principal.md | 60 + .../2026-10-13-user-rpc-principal.schema.json | 11559 ++++++++++++++++ .../2026-10-13-user-rpc-principal.zh.md | 60 + .../historical-formats/README.i18n.yaml | 4 +- .../historical-formats/README.md | 2 +- .../historical-formats/README.zh.md | 2 +- docs/persistence-schema.json | 3799 ++--- .../api/session-controller/src/commands.ts | 38 +- packages/api/session-controller/src/index.ts | 32 +- packages/api/session-controller/src/types.ts | 9 +- .../tests/commands-upload-file.host.spec.ts | 37 +- packages/subagent/subagent/src/index.ts | 6 +- .../subagent/subagent/tests/control.spec.ts | 30 + 17 files changed, 14168 insertions(+), 2262 deletions(-) create mode 100644 docs/persistence-changes/2026-10-13-user-rpc-principal.i18n.yaml create mode 100644 docs/persistence-changes/2026-10-13-user-rpc-principal.md create mode 100644 docs/persistence-changes/2026-10-13-user-rpc-principal.schema.json create mode 100644 docs/persistence-changes/2026-10-13-user-rpc-principal.zh.md diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index ab62a37959..4f88b2f226 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -9,11 +9,11 @@ en: 1387c4505dde2faf zh: 48570fa1c13d6910 /session-persistence-event-catalog/persistence-type-fingerprints: - en: f6421cacbfe510db - zh: 6c24e981ac836d0c + en: a696c43a89d5fbe8 + zh: 6855eb0239b8dcb3 /session-persistence-event-catalog/event-envelope: - en: 7ebb1a2e0dd1f916 - zh: 8946f415dda5c1b8 + en: 689f781d95689e20 + zh: 602f6a7f0835d698 /session-persistence-event-catalog/events: en: f4776ba3d0fd53ce zh: e86769f50a69055a @@ -45,11 +45,11 @@ en: 44426b6cd0dc01cf zh: 44426b6cd0dc01cf /session-persistence-event-catalog/events/assistant/assistant-attempt-log-only: - en: 6baf474f8c4a905e - zh: b92f1c041441ba98 + en: 5b002a8dcefbfc71 + zh: e44f53ed4bf1f929 /session-persistence-event-catalog/events/assistant/assistant-message-surface: - en: 70721b64d98f892e - zh: ad5091727753c698 + en: a0f8289f97125e4f + zh: 1ff54770086eb016 /session-persistence-event-catalog/events/command: en: 2cecb5b0ee955732 zh: 2cecb5b0ee955732 @@ -84,8 +84,8 @@ en: 3344204b1858edb1 zh: 3344204b1858edb1 /session-persistence-event-catalog/events/developer/developer-message-surface: - en: d1ef5d25c01a725e - zh: eea5a11e0c4516ee + en: cd036bde54c62690 + zh: 5a21179b33551ed5 /session-persistence-event-catalog/events/feedback: en: f50dbac6867fa64d zh: f50dbac6867fa64d @@ -141,8 +141,8 @@ en: 9edf4ef31e60e880 zh: 9edf4ef31e60e880 /session-persistence-event-catalog/events/model/model-selection-log-only: - en: ea963e9ec025c667 - zh: f6eea77d22442cd8 + en: 0d9e20c2357ae7ac + zh: 23401d59e97d6011 /session-persistence-event-catalog/events/permission: en: 19917bc5438e60af zh: 19917bc5438e60af @@ -159,11 +159,11 @@ en: a8df82440ebdf5f8 zh: a8df82440ebdf5f8 /session-persistence-event-catalog/events/request/request-context-log-only: - en: e1a46fbd7273e666 - zh: e3390b27a540e353 + en: 799784e7cd019889 + zh: db6c59d673b4acff /session-persistence-event-catalog/events/request/request-header-log-only: - en: 897d315d9a0489d2 - zh: 98726636aa3c4ae1 + en: fb095eddad22b5f5 + zh: 49f1a3ac0a9ec57c /session-persistence-event-catalog/events/sandbox: en: ec841ab8439335b0 zh: ec841ab8439335b0 @@ -180,8 +180,8 @@ en: a1ac8f2660a4f61e zh: a1ac8f2660a4f61e /session-persistence-event-catalog/events/session/session-end-seed-log-only: - en: 53b95ea8a0fd3bd5 - zh: c5a0d2f068637efa + en: 70c3c0d6614f2d30 + zh: bb4dd52f5bcf351c /session-persistence-event-catalog/events/session/session-title-log-only: en: 6c6a27578dff5828 zh: 9a5add2bc0df2d0a @@ -198,11 +198,11 @@ en: bd0dcbbc352b771a zh: bd0dcbbc352b771a /session-persistence-event-catalog/events/step/step-end-log-only: - en: e24b5dd8b2b806aa - zh: a02bc5c3d7b07168 + en: 2de61d81867d485c + zh: 21025a396ef7c88f /session-persistence-event-catalog/events/step/step-start-log-only: - en: 71b35276d634c512 - zh: b1b7f063cffac1de + en: cb8f05bf3b59adef + zh: 686b33b689c4a841 /session-persistence-event-catalog/events/subagent: en: 9630752e97ddbfbc zh: 9630752e97ddbfbc @@ -219,8 +219,8 @@ en: 8cac41d2fb575975 zh: 8cac41d2fb575975 /session-persistence-event-catalog/events/system/system-message-surface: - en: 59abc66d6a56e861 - zh: cc2d67c879079151 + en: 92b1f61ac638da71 + zh: 2d60ba9d38abda05 /session-persistence-event-catalog/events/team: en: a640800a38b3c874 zh: a640800a38b3c874 @@ -246,8 +246,8 @@ en: 28a24430609494b2 zh: 28a24430609494b2 /session-persistence-event-catalog/events/tool/tool-call-log-only: - en: 96cc0c05d3a2d01b - zh: f6f86d5bd29db6a5 + en: 03f36237cbf9e267 + zh: 83fe2e69bbbfd3c1 /session-persistence-event-catalog/events/tool/tool-ptc-dispatch-log-only: en: 3d5d72b99e702a8c zh: e8c3b82cd5361c4a @@ -255,8 +255,8 @@ en: de38cf70d81ad7c3 zh: dd0a2a8be099271d /session-persistence-event-catalog/events/tool/tool-result-surface: - en: 2857bd718f6e7264 - zh: 61aac43cff6451b6 + en: 283e3cf1fdd57f05 + zh: 0bc3a15ae4f47aaf /session-persistence-event-catalog/events/tool-workflow: en: 1eac7deac6af15f3 zh: 1eac7deac6af15f3 @@ -276,17 +276,17 @@ en: d7e8c999a0851a0c zh: d7e8c999a0851a0c /session-persistence-event-catalog/events/turn/turn-end-log-only: - en: db0b56bc4d2959d1 - zh: 4587a42273da90d0 + en: ce5ef6916c95ea16 + zh: 9652c8d40b5dfb6d /session-persistence-event-catalog/events/turn/turn-start-log-only: - en: 09ef649bb3c92015 - zh: 69244b81c8c1f614 + en: 999ffb7ad4e600aa + zh: a8720bf8578431ae /session-persistence-event-catalog/events/user: en: d864a88c03e8971c zh: d864a88c03e8971c /session-persistence-event-catalog/events/user/user-message-surface: - en: 6d302c03787ebb1f - zh: d53f597a8b651309 + en: 55128b5458eae10f + zh: 4198ea89093484f6 /session-persistence-event-catalog/events/web: en: d278748103cf1640 zh: d278748103cf1640 @@ -621,8 +621,11 @@ en: afd1613dd3d4da80 zh: afd1613dd3d4da80 /session-persistence-event-catalog/resolved-persistence-types/other: - en: 588a8f46adda6707 - zh: 588a8f46adda6707 + en: d4ae27f96b0df108 + zh: d4ae27f96b0df108 +/session-persistence-event-catalog/resolved-persistence-types/owner: + en: 5ae91798cfeb7895 + zh: 5ae91798cfeb7895 /session-persistence-event-catalog/resolved-persistence-types/parent: en: 83af3e4fc9243790 zh: 83af3e4fc9243790 @@ -975,8 +978,8 @@ en: e393deed01de3665 zh: 0fa98ad93c22ce79 /session-persistence-event-catalog/resolved-persistence-types/commandsource: - en: 86c7e2b4ae099661 - zh: 2ffe1bd3f25b96fd + en: 53feeeb70547e7df + zh: 99d9e901d9b4afe5 /session-persistence-event-catalog/resolved-persistence-types/compactioncheckpointsource: en: 87f2aaf2b23a5249 zh: 3d6c5ed6603bee31 @@ -999,14 +1002,14 @@ en: 586743dfeab78c55 zh: 26ccef4a02dd7a58 /session-persistence-event-catalog/resolved-persistence-types/deepseeksearchllmrequest: - en: 8bb149f81f7c2544 - zh: 7e9c410f530b7c82 + en: db71ecccc1e5409e + zh: 7b55744e022dc102 /session-persistence-event-catalog/resolved-persistence-types/developermessage: - en: da88473da1299be0 - zh: fe756703e34fcc42 + en: 997b5db27df00446 + zh: 07e5197ddd7bd59b /session-persistence-event-catalog/resolved-persistence-types/epochheader: - en: d31020fa1fda6fa4 - zh: 77984c4bf09443fb + en: 3786becd1ca9c697 + zh: ea8e6fd4f6590567 /session-persistence-event-catalog/resolved-persistence-types/everyscheduledispatchchange: en: 54c487394ce0db1d zh: 48d7c9affbe841cb @@ -1113,11 +1116,11 @@ en: 6dbe099c62f7c226 zh: 6fd788f63c038c91 /session-persistence-event-catalog/resolved-persistence-types/llmretrystartedeventdata: - en: cc63fb8b8fad7d63 - zh: 2026e3da98cad54e + en: c2d9edd8d9f492b7 + zh: f59c687ac691f7ce /session-persistence-event-catalog/resolved-persistence-types/message: - en: e478c640a9c17a4f - zh: b4c6a4930a3789a1 + en: 594707e4894819a8 + zh: 268c4bfd5c4c28f2 /session-persistence-event-catalog/resolved-persistence-types/messagefeedbackdelete: en: c582953270d4ca44 zh: 966ccfe2e7660a2a @@ -1128,20 +1131,20 @@ en: f58575a1fc96df42 zh: 9f0c6ed9033e5c9d /session-persistence-event-catalog/resolved-persistence-types/messagefeedbackrating: - en: 46f4cbb41e7c053a - zh: 511e10d1be567bc5 + en: 674296d54c1914d0 + zh: 3d71ab319514be25 /session-persistence-event-catalog/resolved-persistence-types/messagesource: - en: c4ed18665fbc1e15 - zh: 6db2b051fc329408 + en: 3fab179fe8d58c31 + zh: 5328c9161ff908a9 /session-persistence-event-catalog/resolved-persistence-types/message~2: - en: 2c79aaf7cec7d9d6 - zh: 1403dd3cf9ed4a82 + en: 8bd12543fcdcda5f + zh: d4c6b17083948856 /session-persistence-event-catalog/resolved-persistence-types/modelmessagesource: en: 3b677f021d7dd4d3 zh: 2a875742b22879a2 /session-persistence-event-catalog/resolved-persistence-types/modelselection: - en: 771e9ab3e64076bc - zh: 30d5095a93549d55 + en: 6d66ffc1b393f2f2 + zh: ef6cb30b71f287e7 /session-persistence-event-catalog/resolved-persistence-types/oneshotscheduledispatchchange: en: a3300babe57b6b67 zh: 4aaf06a149b399c3 @@ -1149,14 +1152,17 @@ en: 9ba0cfd5ee9d6a38 zh: 1414fd7468c42651 /session-persistence-event-catalog/resolved-persistence-types/optionalsessionseq: - en: 7baa520fa984480e - zh: 6f809a83a720416d + en: 6d4afd6c237b50fd + zh: ec07f774c853f27b /session-persistence-event-catalog/resolved-persistence-types/presentedfile: en: 3e36e2f29ff1b506 zh: 3dcc1a1dced59d9f /session-persistence-event-catalog/resolved-persistence-types/presentedfile~2: - en: 807fde1cf42ddef3 - zh: 2ea131667b990d1e + en: bb11552744e477b8 + zh: afb881f3ebf97e68 +/session-persistence-event-catalog/resolved-persistence-types/principal: + en: d1e45e03f161dc7c + zh: 4f80af59ca4967c5 /session-persistence-event-catalog/resolved-persistence-types/ptcdispatcheventdata: en: 81e66c5a07e17dd6 zh: 0ecf3cfceba14d2b @@ -1170,11 +1176,11 @@ en: 3460f0c910a247fd zh: 3990587ab01a1907 /session-persistence-event-catalog/resolved-persistence-types/requestcontext: - en: cc8bb7075d546941 - zh: 77209abf30744be0 + en: b1ade5ed2c42b1ad + zh: f1a6126bec25657b /session-persistence-event-catalog/resolved-persistence-types/requestheaderreason: - en: 93c4403f94ecdc14 - zh: 2b2eb0c400881d71 + en: 0db3aa2b122a4454 + zh: 15b6cfd8896935b8 /session-persistence-event-catalog/resolved-persistence-types/sandboxmode: en: c9fa5343ea99733b zh: d6de41351c57a73f @@ -1188,17 +1194,17 @@ en: 35c0b15c285d045e zh: 8ccce6ea60dd5742 /session-persistence-event-catalog/resolved-persistence-types/sessionheader: - en: 0f60d736cd9d6c94 - zh: 205b8833a4efc5e5 + en: 0536dab0d2eed6ad + zh: 54396338c9917818 /session-persistence-event-catalog/resolved-persistence-types/sessionreferencesource: en: 9a54c4478ad3b620 zh: 02c4c1fd7e252770 /session-persistence-event-catalog/resolved-persistence-types/sessiontitleeventdata: - en: 4ff17eae0b30056d - zh: 6642afbaa87da3ab + en: bf7325029c9338d6 + zh: e5f98271511e5318 /session-persistence-event-catalog/resolved-persistence-types/sessiontitlellmrequesteventdata: - en: d7491d51e36cd1b6 - zh: 2fa7d1c33bace7fc + en: 89dcb9a9b957c126 + zh: 132aeb7dfd435290 /session-persistence-event-catalog/resolved-persistence-types/sessiontitlemodelidentity: en: 9531468264499498 zh: 9da23c0ad2776563 @@ -1227,8 +1233,8 @@ en: d34acab659e74957 zh: 25cabc6c9fb44a7e /session-persistence-event-catalog/resolved-persistence-types/surfaceop: - en: 4f3349f21317ba18 - zh: 508c26670085953a + en: 95f8ef2fb8a7c154 + zh: 04cdad161fb88cc3 /session-persistence-event-catalog/resolved-persistence-types/systemmessage: en: 94876079d6696df9 zh: d25c7ad9f5054eb7 @@ -1302,17 +1308,17 @@ en: 54090e169899c573 zh: 530ea8d0d0f8cf68 /session-persistence-event-catalog/resolved-persistence-types/turnendcancelcause: - en: 544292eddeff5faa - zh: 4e36e575a5030fb2 + en: 4dc89c072fe5d0d1 + zh: 4ed2c9e9e8b4f7e2 /session-persistence-event-catalog/resolved-persistence-types/turnendreason: - en: cdf0bbf6399a3677 - zh: f4422bf67b216bae + en: eec92deb07c000fa + zh: 666682b6f3efcfa3 /session-persistence-event-catalog/resolved-persistence-types/usermessage: - en: b1c19f6d8f6f31fc - zh: 3843640f47897934 + en: bd3aedd570ded4d2 + zh: f020d0816d3a6c1a /session-persistence-event-catalog/resolved-persistence-types/usermessage~2: - en: 2ee253b3743ee6a2 - zh: 5ee8b08850d93d8e + en: b99d66ffe277f0bc + zh: eacc9c1f62ae66c1 /session-persistence-event-catalog/resolved-persistence-types/workflowagentoutcome: en: d0a3f9aab18c3de5 zh: 76ac69a835ca4e29 @@ -1332,14 +1338,14 @@ en: bccefcfce00a211f zh: bccefcfce00a211f /session-persistence-event-catalog/resolved-persistence-types/number: - en: 5e7d82a493a1d4d8 - zh: d2d785891dd766fc + en: 0223d7ffb67ac272 + zh: c2a8221caab6d350 /session-persistence-event-catalog/resolved-persistence-types/number~2: en: 2786fef560851bb4 zh: 5d787475356b2606 /session-persistence-event-catalog/resolved-persistence-types/string: - en: 5fa180e9ba84fb2f - zh: ba6249510e691374 + en: 009c7ecb8c7d3bb5 + zh: 7f56d672f376b22f /session-persistence-event-catalog/resolved-persistence-types/string~2: en: 0089620bf4638263 zh: 1922bef1713094bb @@ -1401,8 +1407,8 @@ en: a117573a5d58cb3c zh: d5ad59e17a3a815d /session-persistence-event-catalog/resolved-persistence-types/arguments-callid-name-step: - en: bbb8322870e4695e - zh: dc90a029bf156604 + en: 99e67b046fa9cf40 + zh: 88060ee577d02d20 /session-persistence-event-catalog/resolved-persistence-types/callid-files-turn: en: 5f4d3f66ed86a14d zh: 7c729360fa1279e6 @@ -1434,8 +1440,8 @@ en: a1f188c9058cf621 zh: eb43c0f045fb9afb /session-persistence-event-catalog/resolved-persistence-types/code-name-reason: - en: 5b7790906008fcba - zh: 3d1a6bdb82d82fde + en: 404a76066a59f3c6 + zh: ec9d510bdbac9786 /session-persistence-event-catalog/resolved-persistence-types/commandid-kind-sourceeventseq-text: en: 87b03e1c73d2a326 zh: d7b9f06863cad891 @@ -1476,14 +1482,14 @@ en: 4b7a8793663d3fef zh: 249459c825d330c3 /session-persistence-event-catalog/resolved-persistence-types/endseq-op-startseq: - en: ba27e6f100b73e81 - zh: 82a12774bf5611f8 + en: 318404925fed44c8 + zh: 6cc8b0fcd8d11540 /session-persistence-event-catalog/resolved-persistence-types/error-kind-error: - en: b24398b25f46a94f - zh: 40640447d21ba296 + en: 72572b3d4f2f0c01 + zh: 4c4234325c1713fc /session-persistence-event-catalog/resolved-persistence-types/error-message-meta-step: - en: 423077da59c080a5 - zh: ea8a92a786603279 + en: 8cc3e4d05fa25012 + zh: 2c2d1c1936dd99ce /session-persistence-event-catalog/resolved-persistence-types/failure-kind-aborted: en: 349cd90f2598fd12 zh: 1eda02d520ad5d76 @@ -1491,11 +1497,11 @@ en: 51af619ed8532a43 zh: a5ad878c63d0e046 /session-persistence-event-catalog/resolved-persistence-types/header-reason-startsseries: - en: 562fcce1a6c17ade - zh: 15828c9bf4dcbe6e + en: fe0f6163c5202da6 + zh: 98feea5f03b725e1 /session-persistence-event-catalog/resolved-persistence-types/headerseq-message-step-turn: - en: 884dda8acdf28a83 - zh: 53ef06336629743c + en: 05903e55f510fd79 + zh: 90a17b10f682abcb /session-persistence-event-catalog/resolved-persistence-types/height-width: en: a5df50710083ac25 zh: aad3ccf6f6de70e4 @@ -1512,29 +1518,29 @@ en: 41f73c58a77dfa28 zh: af77f4aee3cb4a40 /session-persistence-event-catalog/resolved-persistence-types/inherited: - en: f46de4c8d413b567 - zh: af0283688492ac43 + en: 8a9199b1784c70f5 + zh: 76c98bc74686fda0 /session-persistence-event-catalog/resolved-persistence-types/inserted-outcome-removedcount-start: - en: 62e1cb96abf41fd5 - zh: c24c03766f6790d8 + en: e77d5d524dd8b4ea + zh: 4dac28dda78a510f /session-persistence-event-catalog/resolved-persistence-types/interrupted-message-step-stream: - en: f460572284c92755 - zh: bae7561ce887b040 + en: ec10cd8e9c202a81 + zh: e08f859bcb13f0d4 /session-persistence-event-catalog/resolved-persistence-types/kind-aborted-reason: - en: c7755cbd343d67ee - zh: e00a7477cd664cb5 + en: 48424cbed3fbc269 + zh: 2ce88c04ad002104 /session-persistence-event-catalog/resolved-persistence-types/kind-blocked: - en: 46465e6fa6292ec3 - zh: b8ed143600bf51d2 + en: 2fad818b19823c48 + zh: a6b33dee21c3fdc9 /session-persistence-event-catalog/resolved-persistence-types/kind-completed: - en: 2f3ffb879c568b64 - zh: ffc7d0f2b64494fc + en: e8f76b61a39ebeb0 + zh: e61a304be00b4490 /session-persistence-event-catalog/resolved-persistence-types/kind-cordis-host-runner: en: fb7b8207a6ef3b81 zh: 9d6f112181d90701 /session-persistence-event-catalog/resolved-persistence-types/kind-disposed: - en: 70d5bdb48ced0ea9 - zh: fc950fee2c166ff0 + en: e3c9527803bc6de3 + zh: 375c82ce2ac5282b /session-persistence-event-catalog/resolved-persistence-types/kind-dsh-session-title-llm: en: 428529e5dedc761d zh: 3ca0b54c9c521ea0 @@ -1560,11 +1566,11 @@ en: 78e34e6d0d70339e zh: 072127e40cfb9686 /session-persistence-event-catalog/resolved-persistence-types/kind-forked: - en: 4b299abd1cf27c78 - zh: dce75fa13b690720 + en: dc750041126fe853 + zh: 736505cd1e57db62 /session-persistence-event-catalog/resolved-persistence-types/kind-hook: - en: 965c99cedd28afdf - zh: 15d8f9991883bd5c + en: 78278a869370695f + zh: 6cdc47f1876f3b23 /session-persistence-event-catalog/resolved-persistence-types/kind-hooks-claude-code: en: 5eb383a67f11ac4d zh: 1943747cb4995dbc @@ -1608,14 +1614,14 @@ en: a8a338be3b91008f zh: c7632c42c869c2f6 /session-persistence-event-catalog/resolved-persistence-types/kind-interrupted: - en: 9e87f168b322c617 - zh: bf81e0aa69f46c08 + en: b2dea1e08facaa40 + zh: 71c9b8d4e3d1d2b8 /session-persistence-event-catalog/resolved-persistence-types/kind-legacy: - en: 7c000bffbf0c567e - zh: b3210306a125820b + en: c6f68741f8631d4f + zh: 090b58f2b86c02d0 /session-persistence-event-catalog/resolved-persistence-types/kind-max-tokens: - en: 20433ac79d2cf7a7 - zh: 8907d87c5179fca8 + en: 4746f91df74cc1dd + zh: d0427e9191359299 /session-persistence-event-catalog/resolved-persistence-types/kind-model-selection: en: a5165486dca91116 zh: b7051ca8167806ef @@ -1638,8 +1644,8 @@ en: 509f9a91aedb2da1 zh: db1071678bfb98d1 /session-persistence-event-catalog/resolved-persistence-types/kind-parent: - en: 96dec53b9a43815a - zh: 4163af6bc636ecc9 + en: 7a3ac2238405913d + zh: b249a4d6d0508d40 /session-persistence-event-catalog/resolved-persistence-types/kind-plan-mode: en: 9758304800b717df zh: dccf14f3a5d0d861 @@ -1821,11 +1827,11 @@ en: 08be4349548406a9 zh: 8e7cf9966198ef78 /session-persistence-event-catalog/resolved-persistence-types/kind-tool-registry: - en: 79db83dd2c430faf - zh: 173f568241f5ce86 + en: 809a23c25ded8497 + zh: e9cd23e281f8d31f /session-persistence-event-catalog/resolved-persistence-types/kind-user: - en: d23abbfb20eb4fc0 - zh: 65207e79db74e2fe + en: 9b6318ab4d7eba12 + zh: f475439ef40b2681 /session-persistence-event-catalog/resolved-persistence-types/kind-user-approval: en: a52d2dfbaf844127 zh: 4c5306ca40f20873 @@ -1863,8 +1869,8 @@ en: 82b0989dad6bbfc0 zh: 0789b63f1d59b2ed /session-persistence-event-catalog/resolved-persistence-types/message-step-turn: - en: 7cc30865737643f2 - zh: 512c6c81ed10a9b6 + en: dcaa676f106cfdfd + zh: e3941fea97c943fb /session-persistence-event-catalog/resolved-persistence-types/message-teamid-version: en: f778769acb264dd5 zh: eba9b81d04e9f57b @@ -1881,8 +1887,8 @@ en: f60ecbd156c6f063 zh: c9149679bbacff21 /session-persistence-event-catalog/resolved-persistence-types/reason-turn: - en: 168c2ac7db7371b4 - zh: f851223c95582eb9 + en: a4ba4d0ee1396381 + zh: b6f595f8a696d512 /session-persistence-event-catalog/resolved-persistence-types/role-user: en: 04b34b8ffe7e7764 zh: a792d5371516fd9d @@ -1893,11 +1899,11 @@ en: 7a2ab7b62244f43b zh: 1084e3f8876feebc /session-persistence-event-catalog/resolved-persistence-types/step-stream-turn: - en: 9d436c515695f35d - zh: 247a7c0bb24337d3 + en: 84004f9b9c6bf24c + zh: ce1abe27b0bc43e2 /session-persistence-event-catalog/resolved-persistence-types/step-turn: - en: 6c9cd916657e052e - zh: ed60789608cee181 + en: 989fad01cdd866ba + zh: 4f08dafda68f0c77 /session-persistence-event-catalog/resolved-persistence-types/targets: en: 5360af614fe753e3 zh: 96427c489cc71de6 @@ -1908,14 +1914,14 @@ en: 06e6c1837e2b5d50 zh: 37cebfe3e41294ec /session-persistence-event-catalog/resolved-persistence-types/turn: - en: a79b2ab16b9ed215 - zh: 764cd1400ee8db63 + en: c58b89cec274f956 + zh: 056a29ed1ada9690 /session-persistence-event-catalog/resolved-persistence-types/type-agent-preset-selected: - en: 52112a8633ba49e4 - zh: 95318f8c8b2d19ee + en: c8c731ee23bed85a + zh: f4ffc80f7828701d /session-persistence-event-catalog/resolved-persistence-types/type-agent-inbox-spliced: - en: 35219c983f8168c9 - zh: 5bdf2bc555cc5e1a + en: 62dfe9447efaceb1 + zh: 076454de19b5d7d7 /session-persistence-event-catalog/resolved-persistence-types/type-approval-asked: en: 9c77f18b0dad22f0 zh: 8894cf2878fe90db @@ -1959,11 +1965,11 @@ en: ba73fb10b7e2ffc9 zh: 8d5c97e63e5f16da /session-persistence-event-catalog/resolved-persistence-types/type-deliverables-presented: - en: d69ad6b38df667d3 - zh: 72fb317a507a0b3b + en: a4d9eab2928e9b30 + zh: b36702c866518273 /session-persistence-event-catalog/resolved-persistence-types/type-developer-message: - en: 1d16681802c679ce - zh: 01c03831212dd260 + en: ed8c78af3a89ba8f + zh: 4f859687aa13c1a3 /session-persistence-event-catalog/resolved-persistence-types/type-feedback-message-delete: en: 204561e2311db97e zh: 77264396bd5e25c4 @@ -2034,11 +2040,11 @@ en: acf7e21e4d21ebff zh: fb83a853634d204a /session-persistence-event-catalog/resolved-persistence-types/type-session-title: - en: 203f5661ca1850b1 - zh: b3d9ec1019188df4 + en: b99fbacda0db1abd + zh: 9e75ce6b1d174d48 /session-persistence-event-catalog/resolved-persistence-types/type-session-title-llm-request: - en: 246920f06c3a2175 - zh: 56deee3b86499174 + en: 7ba30b98d8e3f653 + zh: ca68b01ab4b6ba3d /session-persistence-event-catalog/resolved-persistence-types/type-step-end: en: 7fc233c4a569dae2 zh: bc2b3fed4a397c00 @@ -2115,11 +2121,11 @@ en: 1b045de96172be4b zh: 8a15c6f35b576b37 /session-persistence-event-catalog/resolved-persistence-types/type-usage: - en: a5579e3f96903381 - zh: deac8bf4949cf17a + en: 1eabcbd128f86602 + zh: c6a2067a05f4e692 /session-persistence-event-catalog/resolved-persistence-types/type-user-message: - en: fb49c375aec5952c - zh: 19b80920c1068b86 + en: afab869c0592b46c + zh: f15874a1e728ab0d /session-persistence-event-catalog/resolved-persistence-types/type-web-deepseek-search-llm-request: en: f109997e295af37d zh: b2dcfd599f73b61c diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 32bdc47a0a..67f829b9d8 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -23,7 +23,7 @@ The [format references](persistence-changes/historical-formats/README.md) cover | `JsonlHeaderLine` | header | `18ee62b8900a4c3d046700f05d7a4d49d6cab2a660a020c87481d1603dd8bd4f` | [`HeaderLine`](#persistence-type-sha256-18ee62b8900a4c3d046700f05d7a4d49d6cab2a660a020c87481d1603dd8bd4f) | | `SessionEventEnvelope` | envelope | `5776e5553ff2dfe3f5bc202dbb1e7c9f93e35a531aebb7764c23b2b6153b2ccc` | [`union (3 variants)`](#persistence-type-sha256-5776e5553ff2dfe3f5bc202dbb1e7c9f93e35a531aebb7764c23b2b6153b2ccc) | | `event:agent-preset/selected` | event | `a10c17474eaf2ddab7095a099e0fe3d046fc18e56c3e344fc8894c05ff9ef97b` | [`{ type: "agent-preset/selected" }`](#persistence-type-sha256-a10c17474eaf2ddab7095a099e0fe3d046fc18e56c3e344fc8894c05ff9ef97b) | -| `event:agent/inbox/spliced` | event | `9a36727716d0e73f681f3b725e7f28f89417c68174c8af0ff2b1ef52b08326a0` | [`{ type: "agent/inbox/spliced" }`](#persistence-type-sha256-9a36727716d0e73f681f3b725e7f28f89417c68174c8af0ff2b1ef52b08326a0) | +| `event:agent/inbox/spliced` | event | `0755b7d1128319ba2e389619cbbf2c961ad648882b4e91da7f3e9582cc8a2034` | [`{ type: "agent/inbox/spliced" }`](#persistence-type-sha256-0755b7d1128319ba2e389619cbbf2c961ad648882b4e91da7f3e9582cc8a2034) | | `event:approval/asked` | event | `3bfeb47b58606f4661904bc723da612782214c463d01e6d61cd6d6193d7374e1` | [`{ type: "approval/asked" }`](#persistence-type-sha256-3bfeb47b58606f4661904bc723da612782214c463d01e6d61cd6d6193d7374e1) | | `event:approval/decided` | event | `bb1ab3d08f49a9f3b265f844cd78d5c49813062a7b34b54904b426f85d0ff6e3` | [`{ type: "approval/decided" }`](#persistence-type-sha256-bb1ab3d08f49a9f3b265f844cd78d5c49813062a7b34b54904b426f85d0ff6e3) | | `event:approval/policy` | event | `26718e15e7e395bce9642dba5bbe09b3b1a4ce2213d20d566cd9207d7fc5fb78` | [`{ type: "approval/policy" }`](#persistence-type-sha256-26718e15e7e395bce9642dba5bbe09b3b1a4ce2213d20d566cd9207d7fc5fb78) | @@ -36,7 +36,7 @@ The [format references](persistence-changes/historical-formats/README.md) cover | `event:compaction/start` | event | `db874d463b0fdec77e9da1c4568f37cb70bd6596781eb93800db44fb8a116965` | [`{ type: "compaction/start" }`](#persistence-type-sha256-db874d463b0fdec77e9da1c4568f37cb70bd6596781eb93800db44fb8a116965) | | `event:compaction/summary` | event | `e2f9a41e0989f54ed8cee80f8db2bcf9d60a5c810dc9d45b83fa050b9dce7602` | [`{ type: "compaction/summary" }`](#persistence-type-sha256-e2f9a41e0989f54ed8cee80f8db2bcf9d60a5c810dc9d45b83fa050b9dce7602) | | `event:deliverables/presented` | event | `13d3d180f977bf78081d487ffa0ecb75857349bcab29a5a3fb48189fca2a6176` | [`{ type: "deliverables/presented" }`](#persistence-type-sha256-13d3d180f977bf78081d487ffa0ecb75857349bcab29a5a3fb48189fca2a6176) | -| `event:developer/message` | event | `ba5b10ea77d68f4a29439a9bd2a14f6a560acf55731749d57237757cc72fbc06` | [`{ type: "developer/message" }`](#persistence-type-sha256-ba5b10ea77d68f4a29439a9bd2a14f6a560acf55731749d57237757cc72fbc06) | +| `event:developer/message` | event | `97e0162f983c27456ec71269fb4976b1851588b8c1aeac5d071d43e11f95e03c` | [`{ type: "developer/message" }`](#persistence-type-sha256-97e0162f983c27456ec71269fb4976b1851588b8c1aeac5d071d43e11f95e03c) | | `event:feedback/message-delete` | event | `3ee93b06f3a125850337602bcdf155d2538c43a5c944ec55b1b3c365152d6796` | [`{ type: "feedback/message-delete" }`](#persistence-type-sha256-3ee93b06f3a125850337602bcdf155d2538c43a5c944ec55b1b3c365152d6796) | | `event:feedback/message-put` | event | `b5086d249e8502e9ead1d39156bb8d559bde7951cac0f14ce150345b4e42a2bf` | [`{ type: "feedback/message-put" }`](#persistence-type-sha256-b5086d249e8502e9ead1d39156bb8d559bde7951cac0f14ce150345b4e42a2bf) | | `event:feedback/record` | event | `b54940ff095c17e874c5be03815f4c2145a256cf3a1d34dae4ab2f7769dfffe8` | [`{ type: "feedback/record" }`](#persistence-type-sha256-b54940ff095c17e874c5be03815f4c2145a256cf3a1d34dae4ab2f7769dfffe8) | @@ -58,7 +58,7 @@ The [format references](persistence-changes/historical-formats/README.md) cover | `event:session-log-deepseek/delivery-accepted` | event | `d63b8b8ffad9c02fd80c43a17df4f240c1fe8118ecca9de34f9d5871838ab5b9` | [`{ type: "session-log-deepseek/delivery-accepted" }`](#persistence-type-sha256-d63b8b8ffad9c02fd80c43a17df4f240c1fe8118ecca9de34f9d5871838ab5b9) | | `event:session/end-seed` | event | `5e6db6e24948d4a853c71cb9fabd252ad051ce93d4672c1266cf837c1c17b84e` | [`{ type: "session/end-seed" }`](#persistence-type-sha256-5e6db6e24948d4a853c71cb9fabd252ad051ce93d4672c1266cf837c1c17b84e) | | `event:session/title` | event | `1b912703e2d64f91c99c675b8f805b01076c8325b905c1218ad81ef0b24909d5` | [`{ type: "session/title" }`](#persistence-type-sha256-1b912703e2d64f91c99c675b8f805b01076c8325b905c1218ad81ef0b24909d5) | -| `event:session/title-llm-request` | event | `b33545b3244861bcbf73b407d24b53402710f5acacab7ae939537e5504a68dc6` | [`{ type: "session/title-llm-request" }`](#persistence-type-sha256-b33545b3244861bcbf73b407d24b53402710f5acacab7ae939537e5504a68dc6) | +| `event:session/title-llm-request` | event | `79a4c87f8bfb4d6d05cd9952b99210d6a7db3b478c4a01d94b4c50602308c056` | [`{ type: "session/title-llm-request" }`](#persistence-type-sha256-79a4c87f8bfb4d6d05cd9952b99210d6a7db3b478c4a01d94b4c50602308c056) | | `event:step/end` | event | `e0a787e6ec76c7c94fecbc501b489164ab0293db05bc947914077ad01e674f05` | [`{ type: "step/end" }`](#persistence-type-sha256-e0a787e6ec76c7c94fecbc501b489164ab0293db05bc947914077ad01e674f05) | | `event:step/start` | event | `4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986` | [`{ type: "step/start" }`](#persistence-type-sha256-4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986) | | `event:subagent/catalog` | event | `53750b9abf61efb7992b0aadc3e247ec927449c63266df28230fd1139f5590ac` | [`{ type: "subagent/catalog" }`](#persistence-type-sha256-53750b9abf61efb7992b0aadc3e247ec927449c63266df28230fd1139f5590ac) | @@ -80,7 +80,7 @@ The [format references](persistence-changes/historical-formats/README.md) cover | `event:tool/result` | event | `7c9f44e90a0058f4cc532ae20dad0c10afa6eba22e70a6c79fc79490bad64397` | [`{ type: "tool/result" }`](#persistence-type-sha256-7c9f44e90a0058f4cc532ae20dad0c10afa6eba22e70a6c79fc79490bad64397) | | `event:turn/end` | event | `0f8512903d94f57a4748fa1a2092e64342856796684e6b8343db685b192745ce` | [`{ type: "turn/end" }`](#persistence-type-sha256-0f8512903d94f57a4748fa1a2092e64342856796684e6b8343db685b192745ce) | | `event:turn/start` | event | `aa0957eca50aeb28bcd2e6930b95809926edacb550c8c340ba526ba6b861b3d8` | [`{ type: "turn/start" }`](#persistence-type-sha256-aa0957eca50aeb28bcd2e6930b95809926edacb550c8c340ba526ba6b861b3d8) | -| `event:user/message` | event | `2ace3b8059b82b6d9837fed518ca97a99eb37c4482df7aad0e71c85489428ade` | [`{ type: "user/message" }`](#persistence-type-sha256-2ace3b8059b82b6d9837fed518ca97a99eb37c4482df7aad0e71c85489428ade) | +| `event:user/message` | event | `2ae8594a998c9862a597129e782277bcc518266712a703b5c3161bdf552d13b8` | [`{ type: "user/message" }`](#persistence-type-sha256-2ae8594a998c9862a597129e782277bcc518266712a703b5c3161bdf552d13b8) | | `event:web/deepseek-search-llm-request` | event | `cf6e3aaf1e2de6480aa0157730a41b9a492108a55304100b0f7e112711dd4331` | [`{ type: "web/deepseek-search-llm-request" }`](#persistence-type-sha256-cf6e3aaf1e2de6480aa0157730a41b9a492108a55304100b0f7e112711dd4331) | | `event:working-directory/change` | event | `124aa62078ca3311353b39cc3bf6c75da281338be6bbf672f8b361d828cddca3` | [`{ type: "working-directory/change" }`](#persistence-type-sha256-124aa62078ca3311353b39cc3bf6c75da281338be6bbf672f8b361d828cddca3) | | `event:workspace/changes` | event | `e308ccf867a5398e316e0af8cb6ce238a8d33a63b9b384c8250a686786285f72` | [`{ type: "workspace/changes" }`](#persistence-type-sha256-e308ccf867a5398e316e0af8cb6ce238a8d33a63b9b384c8250a686786285f72) | @@ -160,7 +160,7 @@ export type SessionEvent = { }[T] ``` -Sources: [`packages/core/session/src/types.ts:431`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:439`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:462`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:493`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:432`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:440`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:463`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:494`](../packages/core/session/src/types.ts) ## Events @@ -290,7 +290,7 @@ Source: [`packages/interaction/user-approval/src/index.ts:40`](../packages/inter 'assistant/attempt': { turn: number; step: number; stream: AssistantStreamRecord[] } ``` -Source: [`packages/core/session/src/types.ts:355`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:356`](../packages/core/session/src/types.ts) @@ -320,7 +320,7 @@ Source: [`packages/core/session/src/types.ts:355`](../packages/core/session/src/ Types: [TokenUsage](subsystems/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:341`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:342`](../packages/core/session/src/types.ts) ### `command/*` @@ -508,7 +508,7 @@ Source: [`packages/deliverables/tool-present/src/types.ts:15`](../packages/deliv } ``` -Source: [`packages/core/session/src/types.ts:311`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:312`](../packages/core/session/src/types.ts) ### `feedback/*` @@ -700,7 +700,7 @@ Source: [`packages/llm/llm-retry/src/types.ts:11`](../packages/llm/llm-retry/src 'model/selection': ModelSelection ``` -Source: [`packages/api/session-controller/src/types.ts:40`](../packages/api/session-controller/src/types.ts) +Source: [`packages/api/session-controller/src/types.ts:41`](../packages/api/session-controller/src/types.ts) ### `permission/*` @@ -753,7 +753,7 @@ Source: [`packages/plan/plan-mode/src/index.ts:52`](../packages/plan/plan-mode/s 'request/context': RequestContext ``` -Source: [`packages/core/session/src/types.ts:402`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:403`](../packages/core/session/src/types.ts) @@ -772,7 +772,7 @@ Source: [`packages/core/session/src/types.ts:402`](../packages/core/session/src/ } ``` -Source: [`packages/core/session/src/types.ts:390`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:391`](../packages/core/session/src/types.ts) ### `sandbox/*` @@ -849,7 +849,7 @@ Source: [`packages/schedule/schedule/src/types.ts:365`](../packages/schedule/sch 'session/end-seed': { inherited?: true } ``` -Source: [`packages/core/session/src/types.ts:427`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:428`](../packages/core/session/src/types.ts) @@ -911,7 +911,7 @@ Source: [`packages/session/session-log-deepseek/src/types.ts:81`](../packages/se 'step/end': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:301`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:302`](../packages/core/session/src/types.ts) @@ -922,7 +922,7 @@ Source: [`packages/core/session/src/types.ts:301`](../packages/core/session/src/ 'step/start': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:299`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:300`](../packages/core/session/src/types.ts) ### `subagent/*` @@ -998,7 +998,7 @@ Source: [`packages/subagent/tool-subagent/src/model-selection-state.ts:17`](../p 'system/message': { turn: number; step: number; message: SystemMessage } ``` -Source: [`packages/core/session/src/types.ts:330`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:331`](../packages/core/session/src/types.ts) ### `team/*` @@ -1091,7 +1091,7 @@ Source: [`packages/todo/tool-todo/src/types.ts:31`](../packages/todo/tool-todo/s Types: [ToolCallId](subsystems/core.md) -Source: [`packages/core/session/src/types.ts:361`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:362`](../packages/core/session/src/types.ts) @@ -1172,7 +1172,7 @@ Source: [`packages/core/tools/src/types.ts:45`](../packages/core/tools/src/types } ``` -Source: [`packages/core/session/src/types.ts:375`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:376`](../packages/core/session/src/types.ts) ### `tool-workflow/*` @@ -1252,7 +1252,7 @@ Source: [`packages/workflow/tool-workflow/src/types.ts:47`](../packages/workflow Types: [TurnEndReason](subsystems/session.md) -Source: [`packages/core/session/src/types.ts:297`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:298`](../packages/core/session/src/types.ts) @@ -1268,7 +1268,7 @@ Source: [`packages/core/session/src/types.ts:297`](../packages/core/session/src/ 'turn/start': { turn: number } ``` -Source: [`packages/core/session/src/types.ts:288`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:289`](../packages/core/session/src/types.ts) ### `user/*` @@ -1287,7 +1287,7 @@ Source: [`packages/core/session/src/types.ts:288`](../packages/core/session/src/ 'user/message': UserMessage ``` -Source: [`packages/core/session/src/types.ts:309`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:310`](../packages/core/session/src/types.ts) ### `web/*` @@ -2185,6 +2185,14 @@ SHA-256: `9d80129e13edfb6feb03ed327e6085c6ef60b97ad074244b2f5fd4667424a6a4` `"other"` + + +### `"owner"` + +SHA-256: `95a3ffccbf3c8afcf9a2f3b355f724da8e07e98dc47ee741166dde7f9ea058bd` + +`"owner"` + ### `"parent"` @@ -3206,7 +3214,7 @@ Array of [`AssistantStreamRecord`](#persistence-type-sha256-33341aac09312300de5a SHA-256: `ada310bf0bdb8fed51f3b56ea63f6ea6b18bbd587f04fccb63a14ab0b2a24e05` -Sources: [`packages/core/session/src/types.ts:190`](../packages/core/session/src/types.ts) · [`packages/interaction/commands/src/types.ts:75`](../packages/interaction/commands/src/types.ts) · [`packages/interaction/commands/src/types.ts:79`](../packages/interaction/commands/src/types.ts) · [`packages/llm/llm/src/message.ts:111`](../packages/llm/llm/src/message.ts) · [`packages/session/session-title/src/types.ts:35`](../packages/session/session-title/src/types.ts) +Sources: [`packages/core/session/src/types.ts:191`](../packages/core/session/src/types.ts) · [`packages/interaction/commands/src/types.ts:75`](../packages/interaction/commands/src/types.ts) · [`packages/interaction/commands/src/types.ts:79`](../packages/interaction/commands/src/types.ts) · [`packages/llm/llm/src/message.ts:111`](../packages/llm/llm/src/message.ts) · [`packages/session/session-title/src/types.ts:35`](../packages/session/session-title/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -3349,7 +3357,7 @@ Sources: [`packages/web/web-search-deepseek/src/provider.ts:55`](../packages/web | `body` | required | [`{ max_tokens, messages, model, tools }`](#persistence-type-sha256-930a6567a10bb62ddd157bd8abdf4b182810c8c3b49d91309b3b009a5fed9731) | | `endpoint` | required | `string` | - + @@ -3357,7 +3365,7 @@ Sources: [`packages/web/web-search-deepseek/src/provider.ts:55`](../packages/web ### `DeveloperMessage` -SHA-256: `af99c95d46bed802870f1ebefe0ea11f92a03cb37b8e0cbe3f94b7aaaf509b08` +SHA-256: `0f6265188dae7ba6faa26830a180bb4deb0760561f97ee0c9d399ccf6c9cf1f7` Sources: [`packages/llm/llm/src/message.ts:157`](../packages/llm/llm/src/message.ts) @@ -3366,7 +3374,7 @@ Sources: [`packages/llm/llm/src/message.ts:157`](../packages/llm/llm/src/message | `content` | required | [`ContentBlock[]`](#persistence-type-sha256-b52f215a6cd691058a3413c12f161fa3b563a2f4895adb3df6f744ec4b0a6ef1) | | `id` | required | `string` | | `role` | required | `"developer"` | -| `source` | required | [`MessageSource`](#persistence-type-sha256-1fa156c85450ca31063d0e0d2853028e6dac9687f2d3191fa811648210118154) | +| `source` | required | [`MessageSource`](#persistence-type-sha256-5bc2d7f02503fc6b3e1bb7e0b7fa22c0b9e4b9841fa3175b2ad8e607ae3525a8) | Source compatibility: `source` — `session-source-attribution` v1; `session.developer-message.source`; `kind`; `preserve`. @@ -3382,7 +3390,7 @@ Attribution-only additions: `tmux-context`, `user-question-reply`, `working-dire SHA-256: `9a930283b91d493d6347146f2475bb107294b379415ca02162354f46a64164f7` -Sources: [`packages/core/session/src/types.ts:240`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:241`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -4065,7 +4073,7 @@ Sources: [`packages/llm/llm-retry/src/types.ts:43`](../packages/llm/llm-retry/sr | `step` | required | `number` | | `turn` | required | `number` | - + @@ -4073,17 +4081,17 @@ Sources: [`packages/llm/llm-retry/src/types.ts:43`](../packages/llm/llm-retry/sr ### `Message` -SHA-256: `d901c4f5e856591c3217af983caa15bba568d580700f0958bab56b88004eced9` +SHA-256: `040cd65423bd64264d225504f1c572d239eeb1793d0c2856ad8e42bb6e1e148b` Sources: [`packages/llm/llm/src/message.ts:196`](../packages/llm/llm/src/message.ts) One of: - [`ToolResultMessage`](#persistence-type-sha256-4b259f7f8f0a44ad5e90b70a73553ced1df27012d126921d397ff403b6c86731) -- [`DeveloperMessage`](#persistence-type-sha256-af99c95d46bed802870f1ebefe0ea11f92a03cb37b8e0cbe3f94b7aaaf509b08) -- [`UserMessage`](#persistence-type-sha256-89b2edff547be1ecfcd78ef18aeeb3cf507fcbf7d6093ec04fb111ef4c8e8a97) -- [`SystemMessage`](#persistence-type-sha256-09f3f8dc417f278257202714132f98ba7b52957d7b558c4a04456603890eb363) +- [`DeveloperMessage`](#persistence-type-sha256-0f6265188dae7ba6faa26830a180bb4deb0760561f97ee0c9d399ccf6c9cf1f7) +- [`UserMessage`](#persistence-type-sha256-2cf075b013d363ad743b5a4982900678d108499c721bc11255137402003db711) - [`AssistantMessage`](#persistence-type-sha256-fa87537f4a9c1e25602990a8bf686b383e9e00352ec8946e16ae24ba7c1625be) +- [`SystemMessage`](#persistence-type-sha256-09f3f8dc417f278257202714132f98ba7b52957d7b558c4a04456603890eb363) @@ -4158,7 +4166,7 @@ One of: - `"negative"` - `"positive"` - + @@ -4166,7 +4174,7 @@ One of: ### `MessageSource` -SHA-256: `1fa156c85450ca31063d0e0d2853028e6dac9687f2d3191fa811648210118154` +SHA-256: `5bc2d7f02503fc6b3e1bb7e0b7fa22c0b9e4b9841fa3175b2ad8e607ae3525a8` Sources: [`packages/llm/llm/src/message.ts:136`](../packages/llm/llm/src/message.ts) @@ -4271,7 +4279,7 @@ Sources: [`packages/llm/llm/src/message.ts:136`](../packages/llm/llm/src/message | `tool-jobs` | `"snapshot"` | `sections`: [`ContextSnapshotSection[]`](#persistence-type-sha256-c748fb42c80a120038a8c587af80ec39924803c401364db11c8974d5bf6d0262) | [`{ kind: "tool-jobs", form: "snapshot" }`](#persistence-type-sha256-c5228b7a55498d84f529591744a0ab26e0b93ade18a70a26db1377a1ecfa6b49) | | `tool-jobs` | not declared | none | [`{ kind: "tool-jobs" }`](#persistence-type-sha256-24ce31aa528272e59f21cd26da9eca08968bcc97529756a749bfdab71746d9c8) | | `tool-registry` | not declared | none | [`{ kind: "tool-registry" }`](#persistence-type-sha256-8fcb0b44a575e998665c053643d1ab04c82f39253ebd190ac0c975b78e40b543) | -| `user` | not declared | `rpcId`: `string` | [`{ kind: "user" }`](#persistence-type-sha256-6a4f72e2e179e17b922f2a9392c0e1c8f8c707f32494372454850a3eb184a6e7) | +| `user` | not declared | `rpcId`: `string` | [`{ kind: "user" }`](#persistence-type-sha256-6dacb1584f2bfbe209dc6b6f578d51da6d55595f64d0e63c727c98f359278def) | | `user` | not declared | none | [`CommandSource`](#persistence-type-sha256-ada310bf0bdb8fed51f3b56ea63f6ea6b18bbd587f04fccb63a14ab0b2a24e05) | | `user-approval` | `"catalog"` | none | [`{ kind: "user-approval", form: "catalog" }`](#persistence-type-sha256-825ca23416b1ea9d81442afead5cf9229a6fa05842f042135cc3aa9710a6a2d8) | | `user-approval` | `"instructions"` | none | [`{ kind: "user-approval", form: "instructions" }`](#persistence-type-sha256-aa2bf9e82b9fdeb94a42af20031fffa30e4e23f01f4ac39757427d4c64c407f0) | @@ -4284,13 +4292,13 @@ Sources: [`packages/llm/llm/src/message.ts:136`](../packages/llm/llm/src/message | `webhook` | `"notice"` | `deliveryId`: `string`; `provider`: `string`; `ruleId`: `string`; `source`: `string`; `summary`: `string` | [`{ kind: "webhook", form: "notice" }`](#persistence-type-sha256-9edc162949abf93bd5ff049191b345454ba104f583937ce5580fb50f067d2b94) | | `working-directory` | not declared | none | [`{ kind: "working-directory" }`](#persistence-type-sha256-2235d957bf4b7c6cf959231d480f0ba1f3a6d72d82981493fb6c7c963021d3d5) | - + ### `Message[]` -SHA-256: `4be8fd28fefa5534675ea9aabd0a24efc2838b989bbc74da20a1efbd0daede8c` +SHA-256: `a927d444c928ac0efd9dd8b2e7d7d04d711c264cb71578d81e0e2fd143ff669f` -Array of [`Message`](#persistence-type-sha256-d901c4f5e856591c3217af983caa15bba568d580700f0958bab56b88004eced9). +Array of [`Message`](#persistence-type-sha256-040cd65423bd64264d225504f1c572d239eeb1793d0c2856ad8e42bb6e1e148b). @@ -4321,7 +4329,7 @@ Sources: [`packages/llm/llm/src/message.ts:24`](../packages/llm/llm/src/message. SHA-256: `f09e5e7acf5ff5ccd193ff2eeec015fa2ff7c29d98bc465f8c3302058d47e29b` -Sources: [`packages/api/session-controller/src/types.ts:100`](../packages/api/session-controller/src/types.ts) +Sources: [`packages/api/session-controller/src/types.ts:101`](../packages/api/session-controller/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -4376,7 +4384,7 @@ Sources: [`packages/subagent/subagent/src/descriptor.ts:61`](../packages/subagen SHA-256: `3bd652ebfa8726b3ce3937a4f1bd2759e02f86b3a08a20f9e41a8513656fbc5f` -Sources: [`packages/core/session/src/types.ts:65`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:66`](../packages/core/session/src/types.ts) One of: @@ -4408,6 +4416,22 @@ SHA-256: `1d9cb3caa96100b18b1911fa3ff8c751ef6992971a03c5f4d9aefc1a0004a345` Array of [`PresentedFile`](#persistence-type-sha256-b8fc636a2121df9d8423c242e03c9d23b7ab7c6fdce00e45746932c60b730f97). + + + + + + +### `Principal` + +SHA-256: `deebfc7c62177ea840f23c6362b8615bc78ffa93377dd5d243c04e541c413af5` + +Sources: [`packages/typert/protocol/src/types.ts:457`](../packages/typert/protocol/src/types.ts) · [`packages/typert/protocol/src/types.ts:461`](../packages/typert/protocol/src/types.ts) + +| Property | Presence | Type | +|---|---|---| +| `kind` | required | `"owner"` | + @@ -4496,7 +4520,7 @@ Sources: [`packages/llm/llm/src/types.ts:446`](../packages/llm/llm/src/types.ts) SHA-256: `b158f3ff6923714807c0d73882f0b05dc87f8fa85c9e90814852d48bbc866081` -Sources: [`packages/core/session/src/types.ts:254`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:255`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -4515,7 +4539,7 @@ Sources: [`packages/core/session/src/types.ts:254`](../packages/core/session/src SHA-256: `530da6c0267813bb4511af72883cc518834e624e3b3220a7062955fd57a788a6` -Sources: [`packages/core/session/src/types.ts:273`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:274`](../packages/core/session/src/types.ts) One of: @@ -4607,7 +4631,7 @@ Sources: [`packages/schedule/schedule/src/types.ts:230`](../packages/schedule/sc SHA-256: `1a3440e3577382704d42a6263aa463504eb74c566734a55e9503a63efcd02445` -Sources: [`packages/core/session/src/types.ts:94`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:95`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -4658,7 +4682,7 @@ Sources: [`packages/session/session-title/src/types.ts:41`](../packages/session/ | `source` | required | [`SessionTitleSource`](#persistence-type-sha256-30d5a537b296685770b1241c4b4bbde19d5a31ff81aa23df73fab5e90b8fc65f) | | `title` | required | `string` | - + @@ -4666,7 +4690,7 @@ Sources: [`packages/session/session-title/src/types.ts:41`](../packages/session/ ### `SessionTitleLlmRequestEventData` -SHA-256: `045926475a46f625f84ae2be292b384817059f16927c69a9adab93717923ac19` +SHA-256: `00c054d3018c198d92f459563331edd1385e58345c677bb499791df2da0ade9f` Sources: [`packages/session/session-title-llm/src/index.ts:37`](../packages/session/session-title-llm/src/index.ts) @@ -4674,7 +4698,7 @@ Sources: [`packages/session/session-title-llm/src/index.ts:37`](../packages/sess |---|---|---| | `maxTokens` | required | `number` | | `messageSeqs` | required | [`number[]`](#persistence-type-sha256-5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78) | -| `messages` | required | [`Message[]`](#persistence-type-sha256-4be8fd28fefa5534675ea9aabd0a24efc2838b989bbc74da20a1efbd0daede8c) | +| `messages` | required | [`Message[]`](#persistence-type-sha256-a927d444c928ac0efd9dd8b2e7d7d04d711c264cb71578d81e0e2fd143ff669f) | | `reasoningEffort` | optional | `string` | | `route` | required | [`SessionTitleModelIdentity`](#persistence-type-sha256-07e1a58c58b593f507b8e7f25723bb0aa42baae6e00f5ecfddf28cb1e64d3235) | | `system` | required | `string` | @@ -4853,7 +4877,7 @@ Sources: [`packages/subagent/subagent/src/continuation-messages.ts:29`](../packa SHA-256: `335e242de1fcc17b6ca920fc420d71bec2d76e53e37955c00948b65ab77f05c5` -Sources: [`packages/core/session/src/types.ts:462`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:463`](../packages/core/session/src/types.ts) One of: @@ -5298,7 +5322,7 @@ Sources: [`packages/workflow/tool-workflow/src/types.ts:14`](../packages/workflo SHA-256: `1f8c126cfab3df812cd115cfc23e3256f0fa2383400f4ce91ecad313577e59b0` -Sources: [`packages/core/session/src/types.ts:196`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:197`](../packages/core/session/src/types.ts) | kind | Form property | Other required fields | Full definition | |---|---|---|---| @@ -5318,7 +5342,7 @@ Sources: [`packages/core/session/src/types.ts:196`](../packages/core/session/src SHA-256: `f1687f982fa3d41ef29e159feed154ecd13af4dd209acaee16038f223f2e9402` -Sources: [`packages/core/session/src/types.ts:232`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:233`](../packages/core/session/src/types.ts) | kind | Form property | Other required fields | Full definition | |---|---|---|---| @@ -5330,7 +5354,7 @@ Sources: [`packages/core/session/src/types.ts:232`](../packages/core/session/src | `interrupted` | not declared | none | [`{ kind: "interrupted" }`](#persistence-type-sha256-56b7c047694875a843ba020dfea3c8380d54ae4ff88ab349e72004dc21e07c23) | | `max-tokens` | not declared | none | [`{ kind: "max-tokens" }`](#persistence-type-sha256-ab7ec5f9eb2c6dfacca5da4c37cf7ba85d04172053ee6b1ebf1709ffcd99c1a7) | - + @@ -5338,7 +5362,7 @@ Sources: [`packages/core/session/src/types.ts:232`](../packages/core/session/src ### `UserMessage` -SHA-256: `89b2edff547be1ecfcd78ef18aeeb3cf507fcbf7d6093ec04fb111ef4c8e8a97` +SHA-256: `2cf075b013d363ad743b5a4982900678d108499c721bc11255137402003db711` Sources: [`packages/llm/llm/src/message.ts:162`](../packages/llm/llm/src/message.ts) @@ -5347,19 +5371,19 @@ Sources: [`packages/llm/llm/src/message.ts:162`](../packages/llm/llm/src/message | `content` | required | [`ContentBlock[]`](#persistence-type-sha256-b52f215a6cd691058a3413c12f161fa3b563a2f4895adb3df6f744ec4b0a6ef1) | | `id` | required | `string` | | `role` | required | `"user"` | -| `source` | required | [`MessageSource`](#persistence-type-sha256-1fa156c85450ca31063d0e0d2853028e6dac9687f2d3191fa811648210118154) | +| `source` | required | [`MessageSource`](#persistence-type-sha256-5bc2d7f02503fc6b3e1bb7e0b7fa22c0b9e4b9841fa3175b2ad8e607ae3525a8) | Source compatibility: `source` — `session-source-attribution` v1; `session.user-message.source`; `kind`; `preserve`. Attribution-only additions: `tmux-context`, `user-question-reply`, `working-directory`. - + ### `UserMessage[]` -SHA-256: `2fef90def36aa758ca096c5f6ad0e00dbb2101943e19ca54b05618801974894a` +SHA-256: `454a0fa879e38076bdcdbd87ef561cd67d7e43e58a287cf7dbaa7b6cf7847020` -Array of [`UserMessage`](#persistence-type-sha256-89b2edff547be1ecfcd78ef18aeeb3cf507fcbf7d6093ec04fb111ef4c8e8a97). +Array of [`UserMessage`](#persistence-type-sha256-2cf075b013d363ad743b5a4982900678d108499c721bc11255137402003db711). @@ -5439,7 +5463,7 @@ SHA-256: `938f7c7357bc403ea3b281a9dd016939df7a0880fc8db12612585be574d0d615` SHA-256: `0657d3ca5ac4e7bf79b00bcf860b1c7159d01a9da137497221ec2f1776e365f7` -Sources: [`packages/core/session/src/types.ts:32`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:33`](../packages/core/session/src/types.ts) `number` @@ -5551,7 +5575,7 @@ Array of `number`. SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b` -Sources: [`packages/api/session-controller/src/types.ts:408`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:20`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:21`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:33`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:9`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/webhook/src/brand.ts:12`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:6`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:9`](../packages/experimental/webhook/src/brand.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/jobs/jobs/src/brand.ts:19`](../packages/jobs/jobs/src/brand.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) +Sources: [`packages/api/session-controller/src/types.ts:409`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:21`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:21`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:33`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:9`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/webhook/src/brand.ts:12`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:6`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:9`](../packages/experimental/webhook/src/brand.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/jobs/jobs/src/brand.ts:19`](../packages/jobs/jobs/src/brand.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) `string` @@ -5788,7 +5812,7 @@ Sources: [`packages/interaction/commands/src/types.ts:105`](../packages/interact SHA-256: `e4c18e294232c3ba6c9f1999f168263cc55956147cfe4121720899be8e52edd1` -Sources: [`packages/core/session/src/types.ts:361`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:362`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -5947,7 +5971,7 @@ SHA-256: `cbbe8377ee744964537dbafd83607219e58ba79a13f6db0d0645c7ad3d5e8a09` SHA-256: `a81059ca06999da1e712414520c2c65e491d052fcf9640fae20aa38f0a5dc735` -Sources: [`packages/core/session/src/types.ts:383`](../packages/core/session/src/types.ts) · [`packages/core/tools/src/types.ts:27`](../packages/core/tools/src/types.ts) +Sources: [`packages/core/session/src/types.ts:384`](../packages/core/session/src/types.ts) · [`packages/core/tools/src/types.ts:27`](../packages/core/tools/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -6167,7 +6191,7 @@ Sources: [`packages/compaction/compaction/src/types.ts:38`](../packages/compacti SHA-256: `bcf0caf62d964b2fcf5404bd5c909cfa9a21c3f3c96e6b7e36d9e33565223825` -Sources: [`packages/core/session/src/types.ts:464`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:465`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -6181,7 +6205,7 @@ Sources: [`packages/core/session/src/types.ts:464`](../packages/core/session/src SHA-256: `79650cfa10aa3c52440328d85e8a25f0ffadc8d7efd784e3c0819d8bb74a1985` -Sources: [`packages/core/session/src/types.ts:212`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:213`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -6194,7 +6218,7 @@ Sources: [`packages/core/session/src/types.ts:212`](../packages/core/session/src SHA-256: `5302e763151277614b97e77f00e3b326694c6c1247b916a20503c344dc970777` -Sources: [`packages/core/session/src/types.ts:375`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:376`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -6236,7 +6260,7 @@ Sources: [`packages/llm/llm/src/types.ts:161`](../packages/llm/llm/src/types.ts) SHA-256: `ad0c38a0adb82a94c925456df41586d9b5f9202fca18593eae2213d4445b4585` -Sources: [`packages/core/session/src/types.ts:390`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:391`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -6244,18 +6268,18 @@ Sources: [`packages/core/session/src/types.ts:390`](../packages/core/session/src | `reason` | required | [`RequestHeaderReason`](#persistence-type-sha256-530da6c0267813bb4511af72883cc518834e624e3b3220a7062955fd57a788a6) | | `startsSeries` | optional | `true` | - + ### `{ headerSeq?, message, step, turn }` -SHA-256: `5b55817a403569fbb2d379496df2e3f3f71107f6c577a218028ce187c60b61e6` +SHA-256: `f4ed4717b4c259e933b550a8dcd90dc93628cda8a3c7affcaa53b9c13c1d53eb` -Sources: [`packages/core/session/src/types.ts:311`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:312`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| | `headerSeq` | optional | `number` | -| `message` | required | [`DeveloperMessage`](#persistence-type-sha256-af99c95d46bed802870f1ebefe0ea11f92a03cb37b8e0cbe3f94b7aaaf509b08) | +| `message` | required | [`DeveloperMessage`](#persistence-type-sha256-0f6265188dae7ba6faa26830a180bb4deb0760561f97ee0c9d399ccf6c9cf1f7) | | `step` | required | `number` | | `turn` | required | `number` | @@ -6333,23 +6357,23 @@ SHA-256: `e3e77b26f0148755a505f5b8ea843a3be117827d4703bb5754d9dc8ddbef521f` SHA-256: `17d1afb770d9941936130996da00dc86782cfef731d8d6526162c301256a4ac3` -Sources: [`packages/core/session/src/types.ts:427`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:428`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| | `inherited` | optional | `true` | - + ### `{ inserted, outcome?, removedCount?, start, … }` -SHA-256: `83a665476a9ee9af05cb1fdded12dd647d1b97b491200d7f12ef1af7cdac4428` +SHA-256: `fa106c5b33b657e8ef16e8cbf39f3b7d44e5cbcdf4f9303d9db6410f28951e42` Sources: [`packages/core/agent/src/types.ts:96`](../packages/core/agent/src/types.ts) | Property | Presence | Type | |---|---|---| -| `inserted` | required | [`UserMessage[]`](#persistence-type-sha256-2fef90def36aa758ca096c5f6ad0e00dbb2101943e19ca54b05618801974894a) | +| `inserted` | required | [`UserMessage[]`](#persistence-type-sha256-454a0fa879e38076bdcdbd87ef561cd67d7e43e58a287cf7dbaa7b6cf7847020) | | `outcome` | optional | `"canceled"` | | `removedCount` | optional | `number` | | `start` | required | `number` | @@ -6361,7 +6385,7 @@ Sources: [`packages/core/agent/src/types.ts:96`](../packages/core/agent/src/type SHA-256: `1ccb56061942f5ccb0d30c9e4f2883b9d115ff906560029de637b61d3cde896d` -Sources: [`packages/core/session/src/types.ts:341`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:342`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -6378,7 +6402,7 @@ Sources: [`packages/core/session/src/types.ts:341`](../packages/core/session/src SHA-256: `0a30c3be336cdd4f8f34e66543a3d46d611c43986edd9b3ce6cbb9b721d3b5d3` -Sources: [`packages/core/session/src/types.ts:204`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:205`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -6391,7 +6415,7 @@ Sources: [`packages/core/session/src/types.ts:204`](../packages/core/session/src SHA-256: `023a355b2be0d688fb6e4df8388dadb942761d46cfe96d0f499aae9631a159d4` -Sources: [`packages/core/session/src/types.ts:206`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:207`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -6403,7 +6427,7 @@ Sources: [`packages/core/session/src/types.ts:206`](../packages/core/session/src SHA-256: `b6ef9c6210be8350cd465f485cf520031fffa6bf3809c61a1e713e834c5982d0` -Sources: [`packages/core/session/src/types.ts:202`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:203`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -6427,7 +6451,7 @@ Sources: [`packages/extensions/cordis-host-runner/src/index.ts:14`](../packages/ SHA-256: `4bb451330ff699f36b45539ceb0eb2606ead72084e2c5bb637c36cc80e2252db` -Sources: [`packages/core/session/src/types.ts:193`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:194`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -6529,7 +6553,7 @@ Sources: [`packages/session/session-title/src/types.ts:29`](../packages/session/ SHA-256: `ec8b5ff7c77ca6893ac7ba5a0c5b2aefeb52f12c64450b7359660c2a7e8a53d7` -Sources: [`packages/core/session/src/types.ts:228`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:229`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -6541,7 +6565,7 @@ Sources: [`packages/core/session/src/types.ts:228`](../packages/core/session/src SHA-256: `6c5a37d42ebdb12db4410699259335f1e149e3a0260425684c00b153afc8990d` -Sources: [`packages/core/session/src/types.ts:192`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:193`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -6710,7 +6734,7 @@ SHA-256: `88841202942c7683b3b7e5ea6a796047ec326b6701ac23e58cc236d85613b9fa` SHA-256: `56b7c047694875a843ba020dfea3c8380d54ae4ff88ab349e72004dc21e07c23` -Sources: [`packages/core/session/src/types.ts:221`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:222`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -6722,7 +6746,7 @@ Sources: [`packages/core/session/src/types.ts:221`](../packages/core/session/src SHA-256: `eaa71be0fbf0f702df8ea6c11d0aa2f9782263d45a10d0fb14eda662f4bfe04c` -Sources: [`packages/core/session/src/types.ts:196`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:197`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -6734,7 +6758,7 @@ Sources: [`packages/core/session/src/types.ts:196`](../packages/core/session/src SHA-256: `ab7ec5f9eb2c6dfacca5da4c37cf7ba85d04172053ee6b1ebf1709ffcd99c1a7` -Sources: [`packages/core/session/src/types.ts:214`](../packages/core/session/src/types.ts) · [`packages/llm/llm/src/types.ts:159`](../packages/llm/llm/src/types.ts) +Sources: [`packages/core/session/src/types.ts:215`](../packages/core/session/src/types.ts) · [`packages/llm/llm/src/types.ts:159`](../packages/llm/llm/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -6824,7 +6848,7 @@ SHA-256: `52c6113cb0e53f345324bf84f01928bea5d5df57ee80bc0dd65407b8bc5a3734` SHA-256: `6c03546143cdf6ae88f1745ac98c5139efa1588d1415249bbc5f3104d32ab800` -Sources: [`packages/core/session/src/types.ts:191`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:192`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -7516,18 +7540,19 @@ Sources: [`packages/core/tools/src/index.ts:35`](../packages/core/tools/src/inde |---|---|---| | `kind` | required | `"tool-registry"` | - + ### `{ kind: "user" }` -SHA-256: `6a4f72e2e179e17b922f2a9392c0e1c8f8c707f32494372454850a3eb184a6e7` +SHA-256: `6dacb1584f2bfbe209dc6b6f578d51da6d55595f64d0e63c727c98f359278def` -Sources: [`packages/api/session-controller/src/types.ts:413`](../packages/api/session-controller/src/types.ts) +Sources: [`packages/api/session-controller/src/types.ts:418`](../packages/api/session-controller/src/types.ts) | Property | Presence | Type | |---|---|---| | `clientTimeZone` | optional | `string` | | `kind` | required | `"user"` | +| `principal` | optional | [`Principal`](#persistence-type-sha256-deebfc7c62177ea840f23c6362b8615bc78ffa93377dd5d243c04e541c413af5) | | `rpcId` | required | `string` | @@ -7687,7 +7712,7 @@ Sources: [`packages/experimental/agent-team/src/types.ts:230`](../packages/exper SHA-256: `6bc8e51124d0874bc89e1d0ceff381a9c3cd0fc240667d69f841ce4f60f65fe5` -Sources: [`packages/core/session/src/types.ts:330`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:331`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -7768,7 +7793,7 @@ Sources: [`packages/interaction/permission-presets/src/index.ts:59`](../packages SHA-256: `e481c46af3813e0010558e93f578b2986acd658404cec2d1679e3ac2a6c48654` -Sources: [`packages/core/session/src/types.ts:297`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:298`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -7822,7 +7847,7 @@ Sources: [`packages/compaction/compaction/src/types.ts:82`](../packages/compacti SHA-256: `54f9276a1617d7c12594722c89cfe864bcbe4517e9a6f9c98bab14f199a135e0` -Sources: [`packages/core/session/src/types.ts:355`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:356`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -7836,7 +7861,7 @@ Sources: [`packages/core/session/src/types.ts:355`](../packages/core/session/src SHA-256: `ef11a3b7fa2033ae4279545ff8a8a2ee4e301b9b73e5b91654c48e6d08283545` -Sources: [`packages/core/session/src/types.ts:299`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:301`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:300`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:302`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -7887,7 +7912,7 @@ Sources: [`packages/todo/tool-todo/src/types.ts:31`](../packages/todo/tool-todo/ SHA-256: `f90eb4ab2d3897bc20c521a038e3119d581f791ec974b1a0004f63a8ab9e232d` -Sources: [`packages/core/session/src/types.ts:288`](../packages/core/session/src/types.ts) · [`packages/deliverables/workspace-changes/src/types.ts:106`](../packages/deliverables/workspace-changes/src/types.ts) +Sources: [`packages/core/session/src/types.ts:289`](../packages/core/session/src/types.ts) · [`packages/deliverables/workspace-changes/src/types.ts:106`](../packages/deliverables/workspace-changes/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -7909,17 +7934,17 @@ SHA-256: `a10c17474eaf2ddab7095a099e0fe3d046fc18e56c3e344fc8894c05ff9ef97b` | `time` | required | `number` | | `type` | required | `"agent-preset/selected"` | - + ### `{ type: "agent/inbox/spliced" }` -SHA-256: `9a36727716d0e73f681f3b725e7f28f89417c68174c8af0ff2b1ef52b08326a0` +SHA-256: `0755b7d1128319ba2e389619cbbf2c961ad648882b4e91da7f3e9582cc8a2034` | Property | Presence | Type | |---|---|---| -| `data` | required | [`{ inserted, outcome?, removedCount?, start, … }`](#persistence-type-sha256-83a665476a9ee9af05cb1fdded12dd647d1b97b491200d7f12ef1af7cdac4428) | +| `data` | required | [`{ inserted, outcome?, removedCount?, start, … }`](#persistence-type-sha256-fa106c5b33b657e8ef16e8cbf39f3b7d44e5cbcdf4f9303d9db6410f28951e42) | | `ignorable` | optional | `true` | | `seq` | required | `number` | | `time` | required | `number` | @@ -8160,17 +8185,17 @@ SHA-256: `13d3d180f977bf78081d487ffa0ecb75857349bcab29a5a3fb48189fca2a6176` | `time` | required | `number` | | `type` | required | `"deliverables/presented"` | - + ### `{ type: "developer/message" }` -SHA-256: `ba5b10ea77d68f4a29439a9bd2a14f6a560acf55731749d57237757cc72fbc06` +SHA-256: `97e0162f983c27456ec71269fb4976b1851588b8c1aeac5d071d43e11f95e03c` | Property | Presence | Type | |---|---|---| -| `data` | required | [`{ headerSeq?, message, step, turn }`](#persistence-type-sha256-5b55817a403569fbb2d379496df2e3f3f71107f6c577a218028ce187c60b61e6) | +| `data` | required | [`{ headerSeq?, message, step, turn }`](#persistence-type-sha256-f4ed4717b4c259e933b550a8dcd90dc93628cda8a3c7affcaa53b9c13c1d53eb) | | `ignorable` | optional | `true` | | `seq` | required | `number` | | `sourceEventSeqs` | optional | [`number[]`](#persistence-type-sha256-5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78) | @@ -8558,17 +8583,17 @@ SHA-256: `1b912703e2d64f91c99c675b8f805b01076c8325b905c1218ad81ef0b24909d5` | `time` | required | `number` | | `type` | required | `"session/title"` | - + ### `{ type: "session/title-llm-request" }` -SHA-256: `b33545b3244861bcbf73b407d24b53402710f5acacab7ae939537e5504a68dc6` +SHA-256: `79a4c87f8bfb4d6d05cd9952b99210d6a7db3b478c4a01d94b4c50602308c056` | Property | Presence | Type | |---|---|---| -| `data` | required | [`SessionTitleLlmRequestEventData`](#persistence-type-sha256-045926475a46f625f84ae2be292b384817059f16927c69a9adab93717923ac19) | +| `data` | required | [`SessionTitleLlmRequestEventData`](#persistence-type-sha256-00c054d3018c198d92f459563331edd1385e58345c677bb499791df2da0ade9f) | | `ignorable` | optional | `true` | | `seq` | required | `number` | | `time` | required | `number` | @@ -8991,17 +9016,17 @@ Sources: [`packages/llm/llm/src/types.ts:474`](../packages/llm/llm/src/types.ts) | `type` | required | `"usage"` | | `usage` | required | [`TokenUsage`](#persistence-type-sha256-fec5442b9ee4122e10514d81d5a43648003fe8bfb7ee2ba50c0cdbdd383ed394) | - + ### `{ type: "user/message" }` -SHA-256: `2ace3b8059b82b6d9837fed518ca97a99eb37c4482df7aad0e71c85489428ade` +SHA-256: `2ae8594a998c9862a597129e782277bcc518266712a703b5c3161bdf552d13b8` | Property | Presence | Type | |---|---|---| -| `data` | required | [`UserMessage`](#persistence-type-sha256-89b2edff547be1ecfcd78ef18aeeb3cf507fcbf7d6093ec04fb111ef4c8e8a97) | +| `data` | required | [`UserMessage`](#persistence-type-sha256-2cf075b013d363ad743b5a4982900678d108499c721bc11255137402003db711) | | `ignorable` | optional | `true` | | `seq` | required | `number` | | `sourceEventSeqs` | optional | [`number[]`](#persistence-type-sha256-5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78) | diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 991e8743f7..c1eb47569f 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -25,7 +25,7 @@ | `JsonlHeaderLine` | header | `18ee62b8900a4c3d046700f05d7a4d49d6cab2a660a020c87481d1603dd8bd4f` | [`HeaderLine`](#persistence-type-sha256-18ee62b8900a4c3d046700f05d7a4d49d6cab2a660a020c87481d1603dd8bd4f) | | `SessionEventEnvelope` | envelope | `5776e5553ff2dfe3f5bc202dbb1e7c9f93e35a531aebb7764c23b2b6153b2ccc` | [`union (3 variants)`](#persistence-type-sha256-5776e5553ff2dfe3f5bc202dbb1e7c9f93e35a531aebb7764c23b2b6153b2ccc) | | `event:agent-preset/selected` | event | `a10c17474eaf2ddab7095a099e0fe3d046fc18e56c3e344fc8894c05ff9ef97b` | [`{ type: "agent-preset/selected" }`](#persistence-type-sha256-a10c17474eaf2ddab7095a099e0fe3d046fc18e56c3e344fc8894c05ff9ef97b) | -| `event:agent/inbox/spliced` | event | `9a36727716d0e73f681f3b725e7f28f89417c68174c8af0ff2b1ef52b08326a0` | [`{ type: "agent/inbox/spliced" }`](#persistence-type-sha256-9a36727716d0e73f681f3b725e7f28f89417c68174c8af0ff2b1ef52b08326a0) | +| `event:agent/inbox/spliced` | event | `0755b7d1128319ba2e389619cbbf2c961ad648882b4e91da7f3e9582cc8a2034` | [`{ type: "agent/inbox/spliced" }`](#persistence-type-sha256-0755b7d1128319ba2e389619cbbf2c961ad648882b4e91da7f3e9582cc8a2034) | | `event:approval/asked` | event | `3bfeb47b58606f4661904bc723da612782214c463d01e6d61cd6d6193d7374e1` | [`{ type: "approval/asked" }`](#persistence-type-sha256-3bfeb47b58606f4661904bc723da612782214c463d01e6d61cd6d6193d7374e1) | | `event:approval/decided` | event | `bb1ab3d08f49a9f3b265f844cd78d5c49813062a7b34b54904b426f85d0ff6e3` | [`{ type: "approval/decided" }`](#persistence-type-sha256-bb1ab3d08f49a9f3b265f844cd78d5c49813062a7b34b54904b426f85d0ff6e3) | | `event:approval/policy` | event | `26718e15e7e395bce9642dba5bbe09b3b1a4ce2213d20d566cd9207d7fc5fb78` | [`{ type: "approval/policy" }`](#persistence-type-sha256-26718e15e7e395bce9642dba5bbe09b3b1a4ce2213d20d566cd9207d7fc5fb78) | @@ -38,7 +38,7 @@ | `event:compaction/start` | event | `db874d463b0fdec77e9da1c4568f37cb70bd6596781eb93800db44fb8a116965` | [`{ type: "compaction/start" }`](#persistence-type-sha256-db874d463b0fdec77e9da1c4568f37cb70bd6596781eb93800db44fb8a116965) | | `event:compaction/summary` | event | `e2f9a41e0989f54ed8cee80f8db2bcf9d60a5c810dc9d45b83fa050b9dce7602` | [`{ type: "compaction/summary" }`](#persistence-type-sha256-e2f9a41e0989f54ed8cee80f8db2bcf9d60a5c810dc9d45b83fa050b9dce7602) | | `event:deliverables/presented` | event | `13d3d180f977bf78081d487ffa0ecb75857349bcab29a5a3fb48189fca2a6176` | [`{ type: "deliverables/presented" }`](#persistence-type-sha256-13d3d180f977bf78081d487ffa0ecb75857349bcab29a5a3fb48189fca2a6176) | -| `event:developer/message` | event | `ba5b10ea77d68f4a29439a9bd2a14f6a560acf55731749d57237757cc72fbc06` | [`{ type: "developer/message" }`](#persistence-type-sha256-ba5b10ea77d68f4a29439a9bd2a14f6a560acf55731749d57237757cc72fbc06) | +| `event:developer/message` | event | `97e0162f983c27456ec71269fb4976b1851588b8c1aeac5d071d43e11f95e03c` | [`{ type: "developer/message" }`](#persistence-type-sha256-97e0162f983c27456ec71269fb4976b1851588b8c1aeac5d071d43e11f95e03c) | | `event:feedback/message-delete` | event | `3ee93b06f3a125850337602bcdf155d2538c43a5c944ec55b1b3c365152d6796` | [`{ type: "feedback/message-delete" }`](#persistence-type-sha256-3ee93b06f3a125850337602bcdf155d2538c43a5c944ec55b1b3c365152d6796) | | `event:feedback/message-put` | event | `b5086d249e8502e9ead1d39156bb8d559bde7951cac0f14ce150345b4e42a2bf` | [`{ type: "feedback/message-put" }`](#persistence-type-sha256-b5086d249e8502e9ead1d39156bb8d559bde7951cac0f14ce150345b4e42a2bf) | | `event:feedback/record` | event | `b54940ff095c17e874c5be03815f4c2145a256cf3a1d34dae4ab2f7769dfffe8` | [`{ type: "feedback/record" }`](#persistence-type-sha256-b54940ff095c17e874c5be03815f4c2145a256cf3a1d34dae4ab2f7769dfffe8) | @@ -60,7 +60,7 @@ | `event:session-log-deepseek/delivery-accepted` | event | `d63b8b8ffad9c02fd80c43a17df4f240c1fe8118ecca9de34f9d5871838ab5b9` | [`{ type: "session-log-deepseek/delivery-accepted" }`](#persistence-type-sha256-d63b8b8ffad9c02fd80c43a17df4f240c1fe8118ecca9de34f9d5871838ab5b9) | | `event:session/end-seed` | event | `5e6db6e24948d4a853c71cb9fabd252ad051ce93d4672c1266cf837c1c17b84e` | [`{ type: "session/end-seed" }`](#persistence-type-sha256-5e6db6e24948d4a853c71cb9fabd252ad051ce93d4672c1266cf837c1c17b84e) | | `event:session/title` | event | `1b912703e2d64f91c99c675b8f805b01076c8325b905c1218ad81ef0b24909d5` | [`{ type: "session/title" }`](#persistence-type-sha256-1b912703e2d64f91c99c675b8f805b01076c8325b905c1218ad81ef0b24909d5) | -| `event:session/title-llm-request` | event | `b33545b3244861bcbf73b407d24b53402710f5acacab7ae939537e5504a68dc6` | [`{ type: "session/title-llm-request" }`](#persistence-type-sha256-b33545b3244861bcbf73b407d24b53402710f5acacab7ae939537e5504a68dc6) | +| `event:session/title-llm-request` | event | `79a4c87f8bfb4d6d05cd9952b99210d6a7db3b478c4a01d94b4c50602308c056` | [`{ type: "session/title-llm-request" }`](#persistence-type-sha256-79a4c87f8bfb4d6d05cd9952b99210d6a7db3b478c4a01d94b4c50602308c056) | | `event:step/end` | event | `e0a787e6ec76c7c94fecbc501b489164ab0293db05bc947914077ad01e674f05` | [`{ type: "step/end" }`](#persistence-type-sha256-e0a787e6ec76c7c94fecbc501b489164ab0293db05bc947914077ad01e674f05) | | `event:step/start` | event | `4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986` | [`{ type: "step/start" }`](#persistence-type-sha256-4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986) | | `event:subagent/catalog` | event | `53750b9abf61efb7992b0aadc3e247ec927449c63266df28230fd1139f5590ac` | [`{ type: "subagent/catalog" }`](#persistence-type-sha256-53750b9abf61efb7992b0aadc3e247ec927449c63266df28230fd1139f5590ac) | @@ -82,7 +82,7 @@ | `event:tool/result` | event | `7c9f44e90a0058f4cc532ae20dad0c10afa6eba22e70a6c79fc79490bad64397` | [`{ type: "tool/result" }`](#persistence-type-sha256-7c9f44e90a0058f4cc532ae20dad0c10afa6eba22e70a6c79fc79490bad64397) | | `event:turn/end` | event | `0f8512903d94f57a4748fa1a2092e64342856796684e6b8343db685b192745ce` | [`{ type: "turn/end" }`](#persistence-type-sha256-0f8512903d94f57a4748fa1a2092e64342856796684e6b8343db685b192745ce) | | `event:turn/start` | event | `aa0957eca50aeb28bcd2e6930b95809926edacb550c8c340ba526ba6b861b3d8` | [`{ type: "turn/start" }`](#persistence-type-sha256-aa0957eca50aeb28bcd2e6930b95809926edacb550c8c340ba526ba6b861b3d8) | -| `event:user/message` | event | `2ace3b8059b82b6d9837fed518ca97a99eb37c4482df7aad0e71c85489428ade` | [`{ type: "user/message" }`](#persistence-type-sha256-2ace3b8059b82b6d9837fed518ca97a99eb37c4482df7aad0e71c85489428ade) | +| `event:user/message` | event | `2ae8594a998c9862a597129e782277bcc518266712a703b5c3161bdf552d13b8` | [`{ type: "user/message" }`](#persistence-type-sha256-2ae8594a998c9862a597129e782277bcc518266712a703b5c3161bdf552d13b8) | | `event:web/deepseek-search-llm-request` | event | `cf6e3aaf1e2de6480aa0157730a41b9a492108a55304100b0f7e112711dd4331` | [`{ type: "web/deepseek-search-llm-request" }`](#persistence-type-sha256-cf6e3aaf1e2de6480aa0157730a41b9a492108a55304100b0f7e112711dd4331) | | `event:working-directory/change` | event | `124aa62078ca3311353b39cc3bf6c75da281338be6bbf672f8b361d828cddca3` | [`{ type: "working-directory/change" }`](#persistence-type-sha256-124aa62078ca3311353b39cc3bf6c75da281338be6bbf672f8b361d828cddca3) | | `event:workspace/changes` | event | `e308ccf867a5398e316e0af8cb6ce238a8d33a63b9b384c8250a686786285f72` | [`{ type: "workspace/changes" }`](#persistence-type-sha256-e308ccf867a5398e316e0af8cb6ce238a8d33a63b9b384c8250a686786285f72) | @@ -162,7 +162,7 @@ export type SessionEvent = { }[T] ``` -来源:[`packages/core/session/src/types.ts:431`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:439`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:462`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:493`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:432`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:440`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:463`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:494`](../packages/core/session/src/types.ts) ## 事件 @@ -292,7 +292,7 @@ export type SessionEvent = { 'assistant/attempt': { turn: number; step: number; stream: AssistantStreamRecord[] } ``` -来源:[`packages/core/session/src/types.ts:355`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:356`](../packages/core/session/src/types.ts) @@ -322,7 +322,7 @@ export type SessionEvent = { 类型:[TokenUsage](subsystems/llm-streaming.zh.md) -来源:[`packages/core/session/src/types.ts:341`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:342`](../packages/core/session/src/types.ts) ### `command/*` @@ -510,7 +510,7 @@ export type SessionEvent = { } ``` -来源:[`packages/core/session/src/types.ts:311`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:312`](../packages/core/session/src/types.ts) ### `feedback/*` @@ -702,7 +702,7 @@ export type SessionEvent = { 'model/selection': ModelSelection ``` -来源:[`packages/api/session-controller/src/types.ts:40`](../packages/api/session-controller/src/types.ts) +来源:[`packages/api/session-controller/src/types.ts:41`](../packages/api/session-controller/src/types.ts) ### `permission/*` @@ -755,7 +755,7 @@ export type SessionEvent = { 'request/context': RequestContext ``` -来源:[`packages/core/session/src/types.ts:402`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:403`](../packages/core/session/src/types.ts) @@ -774,7 +774,7 @@ export type SessionEvent = { } ``` -来源:[`packages/core/session/src/types.ts:390`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:391`](../packages/core/session/src/types.ts) ### `sandbox/*` @@ -851,7 +851,7 @@ export type SessionEvent = { 'session/end-seed': { inherited?: true } ``` -来源:[`packages/core/session/src/types.ts:427`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:428`](../packages/core/session/src/types.ts) @@ -913,7 +913,7 @@ export type SessionEvent = { 'step/end': { turn: number; step: number } ``` -来源:[`packages/core/session/src/types.ts:301`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:302`](../packages/core/session/src/types.ts) @@ -924,7 +924,7 @@ export type SessionEvent = { 'step/start': { turn: number; step: number } ``` -来源:[`packages/core/session/src/types.ts:299`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:300`](../packages/core/session/src/types.ts) ### `subagent/*` @@ -1000,7 +1000,7 @@ export type SessionEvent = { 'system/message': { turn: number; step: number; message: SystemMessage } ``` -来源:[`packages/core/session/src/types.ts:330`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:331`](../packages/core/session/src/types.ts) ### `team/*` @@ -1093,7 +1093,7 @@ export type SessionEvent = { 类型:[ToolCallId](subsystems/core.zh.md) -来源:[`packages/core/session/src/types.ts:361`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:362`](../packages/core/session/src/types.ts) @@ -1174,7 +1174,7 @@ export type SessionEvent = { } ``` -来源:[`packages/core/session/src/types.ts:375`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:376`](../packages/core/session/src/types.ts) ### `tool-workflow/*` @@ -1254,7 +1254,7 @@ export type SessionEvent = { 类型:[TurnEndReason](subsystems/session.zh.md) -来源:[`packages/core/session/src/types.ts:297`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:298`](../packages/core/session/src/types.ts) @@ -1270,7 +1270,7 @@ export type SessionEvent = { 'turn/start': { turn: number } ``` -来源:[`packages/core/session/src/types.ts:288`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:289`](../packages/core/session/src/types.ts) ### `user/*` @@ -1289,7 +1289,7 @@ export type SessionEvent = { 'user/message': UserMessage ``` -来源:[`packages/core/session/src/types.ts:309`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:310`](../packages/core/session/src/types.ts) ### `web/*` @@ -2187,6 +2187,14 @@ SHA-256: `9d80129e13edfb6feb03ed327e6085c6ef60b97ad074244b2f5fd4667424a6a4` `"other"` + + +### `"owner"` + +SHA-256: `95a3ffccbf3c8afcf9a2f3b355f724da8e07e98dc47ee741166dde7f9ea058bd` + +`"owner"` + ### `"parent"` @@ -3208,7 +3216,7 @@ SHA-256: `84bbfdce7d2eab5b0c53e72ff7406db856c8bfd621b965b72fd86598ba493bf7` SHA-256: `ada310bf0bdb8fed51f3b56ea63f6ea6b18bbd587f04fccb63a14ab0b2a24e05` -来源:[`packages/core/session/src/types.ts:190`](../packages/core/session/src/types.ts) · [`packages/interaction/commands/src/types.ts:75`](../packages/interaction/commands/src/types.ts) · [`packages/interaction/commands/src/types.ts:79`](../packages/interaction/commands/src/types.ts) · [`packages/llm/llm/src/message.ts:111`](../packages/llm/llm/src/message.ts) · [`packages/session/session-title/src/types.ts:35`](../packages/session/session-title/src/types.ts) +来源:[`packages/core/session/src/types.ts:191`](../packages/core/session/src/types.ts) · [`packages/interaction/commands/src/types.ts:75`](../packages/interaction/commands/src/types.ts) · [`packages/interaction/commands/src/types.ts:79`](../packages/interaction/commands/src/types.ts) · [`packages/llm/llm/src/message.ts:111`](../packages/llm/llm/src/message.ts) · [`packages/session/session-title/src/types.ts:35`](../packages/session/session-title/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -3351,7 +3359,7 @@ SHA-256: `2517ba143a271508d3ca35126d5aca8f7f1facf5aaddce4adf011832042fa2b9` | `body` | 必需 | [`{ max_tokens, messages, model, tools }`](#persistence-type-sha256-930a6567a10bb62ddd157bd8abdf4b182810c8c3b49d91309b3b009a5fed9731) | | `endpoint` | 必需 | `string` | - + @@ -3359,7 +3367,7 @@ SHA-256: `2517ba143a271508d3ca35126d5aca8f7f1facf5aaddce4adf011832042fa2b9` ### `DeveloperMessage` -SHA-256: `af99c95d46bed802870f1ebefe0ea11f92a03cb37b8e0cbe3f94b7aaaf509b08` +SHA-256: `0f6265188dae7ba6faa26830a180bb4deb0760561f97ee0c9d399ccf6c9cf1f7` 来源:[`packages/llm/llm/src/message.ts:157`](../packages/llm/llm/src/message.ts) @@ -3368,7 +3376,7 @@ SHA-256: `af99c95d46bed802870f1ebefe0ea11f92a03cb37b8e0cbe3f94b7aaaf509b08` | `content` | 必需 | [`ContentBlock[]`](#persistence-type-sha256-b52f215a6cd691058a3413c12f161fa3b563a2f4895adb3df6f744ec4b0a6ef1) | | `id` | 必需 | `string` | | `role` | 必需 | `"developer"` | -| `source` | 必需 | [`MessageSource`](#persistence-type-sha256-1fa156c85450ca31063d0e0d2853028e6dac9687f2d3191fa811648210118154) | +| `source` | 必需 | [`MessageSource`](#persistence-type-sha256-5bc2d7f02503fc6b3e1bb7e0b7fa22c0b9e4b9841fa3175b2ad8e607ae3525a8) | 消息源兼容策略:`source` — `session-source-attribution` v1; `session.developer-message.source`; `kind`; `preserve`. @@ -3384,7 +3392,7 @@ SHA-256: `af99c95d46bed802870f1ebefe0ea11f92a03cb37b8e0cbe3f94b7aaaf509b08` SHA-256: `9a930283b91d493d6347146f2475bb107294b379415ca02162354f46a64164f7` -来源:[`packages/core/session/src/types.ts:240`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:241`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -4067,7 +4075,7 @@ SHA-256: `6f72ece8f317cd080c4f20a93f1bf709965f5b81ac4669528f5a023191cfcf20` | `step` | 必需 | `number` | | `turn` | 必需 | `number` | - + @@ -4075,17 +4083,17 @@ SHA-256: `6f72ece8f317cd080c4f20a93f1bf709965f5b81ac4669528f5a023191cfcf20` ### `Message` -SHA-256: `d901c4f5e856591c3217af983caa15bba568d580700f0958bab56b88004eced9` +SHA-256: `040cd65423bd64264d225504f1c572d239eeb1793d0c2856ad8e42bb6e1e148b` 来源:[`packages/llm/llm/src/message.ts:196`](../packages/llm/llm/src/message.ts) 以下类型之一: - [`ToolResultMessage`](#persistence-type-sha256-4b259f7f8f0a44ad5e90b70a73553ced1df27012d126921d397ff403b6c86731) -- [`DeveloperMessage`](#persistence-type-sha256-af99c95d46bed802870f1ebefe0ea11f92a03cb37b8e0cbe3f94b7aaaf509b08) -- [`UserMessage`](#persistence-type-sha256-89b2edff547be1ecfcd78ef18aeeb3cf507fcbf7d6093ec04fb111ef4c8e8a97) -- [`SystemMessage`](#persistence-type-sha256-09f3f8dc417f278257202714132f98ba7b52957d7b558c4a04456603890eb363) +- [`DeveloperMessage`](#persistence-type-sha256-0f6265188dae7ba6faa26830a180bb4deb0760561f97ee0c9d399ccf6c9cf1f7) +- [`UserMessage`](#persistence-type-sha256-2cf075b013d363ad743b5a4982900678d108499c721bc11255137402003db711) - [`AssistantMessage`](#persistence-type-sha256-fa87537f4a9c1e25602990a8bf686b383e9e00352ec8946e16ae24ba7c1625be) +- [`SystemMessage`](#persistence-type-sha256-09f3f8dc417f278257202714132f98ba7b52957d7b558c4a04456603890eb363) @@ -4160,7 +4168,7 @@ SHA-256: `390eed1028aba02774f14408647acec10ec5516e97d4f15ecde2b9989d5a69f2` - `"negative"` - `"positive"` - + @@ -4168,7 +4176,7 @@ SHA-256: `390eed1028aba02774f14408647acec10ec5516e97d4f15ecde2b9989d5a69f2` ### `MessageSource` -SHA-256: `1fa156c85450ca31063d0e0d2853028e6dac9687f2d3191fa811648210118154` +SHA-256: `5bc2d7f02503fc6b3e1bb7e0b7fa22c0b9e4b9841fa3175b2ad8e607ae3525a8` 来源:[`packages/llm/llm/src/message.ts:136`](../packages/llm/llm/src/message.ts) @@ -4273,7 +4281,7 @@ SHA-256: `1fa156c85450ca31063d0e0d2853028e6dac9687f2d3191fa811648210118154` | `tool-jobs` | `"snapshot"` | `sections`: [`ContextSnapshotSection[]`](#persistence-type-sha256-c748fb42c80a120038a8c587af80ec39924803c401364db11c8974d5bf6d0262) | [`{ kind: "tool-jobs", form: "snapshot" }`](#persistence-type-sha256-c5228b7a55498d84f529591744a0ab26e0b93ade18a70a26db1377a1ecfa6b49) | | `tool-jobs` | 未声明 | 无 | [`{ kind: "tool-jobs" }`](#persistence-type-sha256-24ce31aa528272e59f21cd26da9eca08968bcc97529756a749bfdab71746d9c8) | | `tool-registry` | 未声明 | 无 | [`{ kind: "tool-registry" }`](#persistence-type-sha256-8fcb0b44a575e998665c053643d1ab04c82f39253ebd190ac0c975b78e40b543) | -| `user` | 未声明 | `rpcId`: `string` | [`{ kind: "user" }`](#persistence-type-sha256-6a4f72e2e179e17b922f2a9392c0e1c8f8c707f32494372454850a3eb184a6e7) | +| `user` | 未声明 | `rpcId`: `string` | [`{ kind: "user" }`](#persistence-type-sha256-6dacb1584f2bfbe209dc6b6f578d51da6d55595f64d0e63c727c98f359278def) | | `user` | 未声明 | 无 | [`CommandSource`](#persistence-type-sha256-ada310bf0bdb8fed51f3b56ea63f6ea6b18bbd587f04fccb63a14ab0b2a24e05) | | `user-approval` | `"catalog"` | 无 | [`{ kind: "user-approval", form: "catalog" }`](#persistence-type-sha256-825ca23416b1ea9d81442afead5cf9229a6fa05842f042135cc3aa9710a6a2d8) | | `user-approval` | `"instructions"` | 无 | [`{ kind: "user-approval", form: "instructions" }`](#persistence-type-sha256-aa2bf9e82b9fdeb94a42af20031fffa30e4e23f01f4ac39757427d4c64c407f0) | @@ -4286,13 +4294,13 @@ SHA-256: `1fa156c85450ca31063d0e0d2853028e6dac9687f2d3191fa811648210118154` | `webhook` | `"notice"` | `deliveryId`: `string`; `provider`: `string`; `ruleId`: `string`; `source`: `string`; `summary`: `string` | [`{ kind: "webhook", form: "notice" }`](#persistence-type-sha256-9edc162949abf93bd5ff049191b345454ba104f583937ce5580fb50f067d2b94) | | `working-directory` | 未声明 | 无 | [`{ kind: "working-directory" }`](#persistence-type-sha256-2235d957bf4b7c6cf959231d480f0ba1f3a6d72d82981493fb6c7c963021d3d5) | - + ### `Message[]` -SHA-256: `4be8fd28fefa5534675ea9aabd0a24efc2838b989bbc74da20a1efbd0daede8c` +SHA-256: `a927d444c928ac0efd9dd8b2e7d7d04d711c264cb71578d81e0e2fd143ff669f` -[`Message`](#persistence-type-sha256-d901c4f5e856591c3217af983caa15bba568d580700f0958bab56b88004eced9) 的数组。 +[`Message`](#persistence-type-sha256-040cd65423bd64264d225504f1c572d239eeb1793d0c2856ad8e42bb6e1e148b) 的数组。 @@ -4323,7 +4331,7 @@ SHA-256: `719c51f67074f97034a6c94297fe1f4b5d3c8d392c57586dab732aa5398c7a91` SHA-256: `f09e5e7acf5ff5ccd193ff2eeec015fa2ff7c29d98bc465f8c3302058d47e29b` -来源:[`packages/api/session-controller/src/types.ts:100`](../packages/api/session-controller/src/types.ts) +来源:[`packages/api/session-controller/src/types.ts:101`](../packages/api/session-controller/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -4378,7 +4386,7 @@ SHA-256: `5f758f3de5f00c18968a7c6d347083aac5c9ae13f6a79c78f6611b23c4973564` SHA-256: `3bd652ebfa8726b3ce3937a4f1bd2759e02f86b3a08a20f9e41a8513656fbc5f` -来源:[`packages/core/session/src/types.ts:65`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:66`](../packages/core/session/src/types.ts) 以下类型之一: @@ -4410,6 +4418,22 @@ SHA-256: `1d9cb3caa96100b18b1911fa3ff8c751ef6992971a03c5f4d9aefc1a0004a345` [`PresentedFile`](#persistence-type-sha256-b8fc636a2121df9d8423c242e03c9d23b7ab7c6fdce00e45746932c60b730f97) 的数组。 + + + + + + +### `Principal` + +SHA-256: `deebfc7c62177ea840f23c6362b8615bc78ffa93377dd5d243c04e541c413af5` + +来源:[`packages/typert/protocol/src/types.ts:457`](../packages/typert/protocol/src/types.ts) · [`packages/typert/protocol/src/types.ts:461`](../packages/typert/protocol/src/types.ts) + +| 属性 | 存在性 | 类型 | +|---|---|---| +| `kind` | 必需 | `"owner"` | + @@ -4498,7 +4522,7 @@ SHA-256: `0acd94fe8794574c9ae72c7855ee16d29c546096d4af760d16d1225385764555` SHA-256: `b158f3ff6923714807c0d73882f0b05dc87f8fa85c9e90814852d48bbc866081` -来源:[`packages/core/session/src/types.ts:254`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:255`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -4517,7 +4541,7 @@ SHA-256: `b158f3ff6923714807c0d73882f0b05dc87f8fa85c9e90814852d48bbc866081` SHA-256: `530da6c0267813bb4511af72883cc518834e624e3b3220a7062955fd57a788a6` -来源:[`packages/core/session/src/types.ts:273`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:274`](../packages/core/session/src/types.ts) 以下类型之一: @@ -4609,7 +4633,7 @@ SHA-256: `18dc21f12bbac8ade8377e6fbb54179e20c3d955d73fba3244c57fb6bf001df0` SHA-256: `1a3440e3577382704d42a6263aa463504eb74c566734a55e9503a63efcd02445` -来源:[`packages/core/session/src/types.ts:94`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:95`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -4660,7 +4684,7 @@ SHA-256: `b56f6a885da3dc394c69a3e3cc5c0cfc4da15601b63fda4989eee8f40efc5be4` | `source` | 必需 | [`SessionTitleSource`](#persistence-type-sha256-30d5a537b296685770b1241c4b4bbde19d5a31ff81aa23df73fab5e90b8fc65f) | | `title` | 必需 | `string` | - + @@ -4668,7 +4692,7 @@ SHA-256: `b56f6a885da3dc394c69a3e3cc5c0cfc4da15601b63fda4989eee8f40efc5be4` ### `SessionTitleLlmRequestEventData` -SHA-256: `045926475a46f625f84ae2be292b384817059f16927c69a9adab93717923ac19` +SHA-256: `00c054d3018c198d92f459563331edd1385e58345c677bb499791df2da0ade9f` 来源:[`packages/session/session-title-llm/src/index.ts:37`](../packages/session/session-title-llm/src/index.ts) @@ -4676,7 +4700,7 @@ SHA-256: `045926475a46f625f84ae2be292b384817059f16927c69a9adab93717923ac19` |---|---|---| | `maxTokens` | 必需 | `number` | | `messageSeqs` | 必需 | [`number[]`](#persistence-type-sha256-5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78) | -| `messages` | 必需 | [`Message[]`](#persistence-type-sha256-4be8fd28fefa5534675ea9aabd0a24efc2838b989bbc74da20a1efbd0daede8c) | +| `messages` | 必需 | [`Message[]`](#persistence-type-sha256-a927d444c928ac0efd9dd8b2e7d7d04d711c264cb71578d81e0e2fd143ff669f) | | `reasoningEffort` | 可选 | `string` | | `route` | 必需 | [`SessionTitleModelIdentity`](#persistence-type-sha256-07e1a58c58b593f507b8e7f25723bb0aa42baae6e00f5ecfddf28cb1e64d3235) | | `system` | 必需 | `string` | @@ -4855,7 +4879,7 @@ SHA-256: `4e97c3d85c0fc817ee58873c38c25a2474abaaa0af4839311b50b68db3b8cf1a` SHA-256: `335e242de1fcc17b6ca920fc420d71bec2d76e53e37955c00948b65ab77f05c5` -来源:[`packages/core/session/src/types.ts:462`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:463`](../packages/core/session/src/types.ts) 以下类型之一: @@ -5300,7 +5324,7 @@ SHA-256: `a6da9ad5d42ae83a6833a8288b076688e8e0801633ea36cbdd8a30a6d9f3160c` SHA-256: `1f8c126cfab3df812cd115cfc23e3256f0fa2383400f4ce91ecad313577e59b0` -来源:[`packages/core/session/src/types.ts:196`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:197`](../packages/core/session/src/types.ts) | kind | form 属性 | 其他必需字段 | 完整定义 | |---|---|---|---| @@ -5320,7 +5344,7 @@ SHA-256: `1f8c126cfab3df812cd115cfc23e3256f0fa2383400f4ce91ecad313577e59b0` SHA-256: `f1687f982fa3d41ef29e159feed154ecd13af4dd209acaee16038f223f2e9402` -来源:[`packages/core/session/src/types.ts:232`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:233`](../packages/core/session/src/types.ts) | kind | form 属性 | 其他必需字段 | 完整定义 | |---|---|---|---| @@ -5332,7 +5356,7 @@ SHA-256: `f1687f982fa3d41ef29e159feed154ecd13af4dd209acaee16038f223f2e9402` | `interrupted` | 未声明 | 无 | [`{ kind: "interrupted" }`](#persistence-type-sha256-56b7c047694875a843ba020dfea3c8380d54ae4ff88ab349e72004dc21e07c23) | | `max-tokens` | 未声明 | 无 | [`{ kind: "max-tokens" }`](#persistence-type-sha256-ab7ec5f9eb2c6dfacca5da4c37cf7ba85d04172053ee6b1ebf1709ffcd99c1a7) | - + @@ -5340,7 +5364,7 @@ SHA-256: `f1687f982fa3d41ef29e159feed154ecd13af4dd209acaee16038f223f2e9402` ### `UserMessage` -SHA-256: `89b2edff547be1ecfcd78ef18aeeb3cf507fcbf7d6093ec04fb111ef4c8e8a97` +SHA-256: `2cf075b013d363ad743b5a4982900678d108499c721bc11255137402003db711` 来源:[`packages/llm/llm/src/message.ts:162`](../packages/llm/llm/src/message.ts) @@ -5349,19 +5373,19 @@ SHA-256: `89b2edff547be1ecfcd78ef18aeeb3cf507fcbf7d6093ec04fb111ef4c8e8a97` | `content` | 必需 | [`ContentBlock[]`](#persistence-type-sha256-b52f215a6cd691058a3413c12f161fa3b563a2f4895adb3df6f744ec4b0a6ef1) | | `id` | 必需 | `string` | | `role` | 必需 | `"user"` | -| `source` | 必需 | [`MessageSource`](#persistence-type-sha256-1fa156c85450ca31063d0e0d2853028e6dac9687f2d3191fa811648210118154) | +| `source` | 必需 | [`MessageSource`](#persistence-type-sha256-5bc2d7f02503fc6b3e1bb7e0b7fa22c0b9e4b9841fa3175b2ad8e607ae3525a8) | 消息源兼容策略:`source` — `session-source-attribution` v1; `session.user-message.source`; `kind`; `preserve`. 仅表示归属的新增 kind:`tmux-context`, `user-question-reply`, `working-directory`. - + ### `UserMessage[]` -SHA-256: `2fef90def36aa758ca096c5f6ad0e00dbb2101943e19ca54b05618801974894a` +SHA-256: `454a0fa879e38076bdcdbd87ef561cd67d7e43e58a287cf7dbaa7b6cf7847020` -[`UserMessage`](#persistence-type-sha256-89b2edff547be1ecfcd78ef18aeeb3cf507fcbf7d6093ec04fb111ef4c8e8a97) 的数组。 +[`UserMessage`](#persistence-type-sha256-2cf075b013d363ad743b5a4982900678d108499c721bc11255137402003db711) 的数组。 @@ -5441,7 +5465,7 @@ SHA-256: `938f7c7357bc403ea3b281a9dd016939df7a0880fc8db12612585be574d0d615` SHA-256: `0657d3ca5ac4e7bf79b00bcf860b1c7159d01a9da137497221ec2f1776e365f7` -来源:[`packages/core/session/src/types.ts:32`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:33`](../packages/core/session/src/types.ts) `number` @@ -5553,7 +5577,7 @@ SHA-256: `5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78` SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b` -来源:[`packages/api/session-controller/src/types.ts:408`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:20`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:21`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:33`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:9`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/webhook/src/brand.ts:12`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:6`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:9`](../packages/experimental/webhook/src/brand.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/jobs/jobs/src/brand.ts:19`](../packages/jobs/jobs/src/brand.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) +来源:[`packages/api/session-controller/src/types.ts:409`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:21`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:21`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:33`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:9`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/webhook/src/brand.ts:12`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:6`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:9`](../packages/experimental/webhook/src/brand.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/jobs/jobs/src/brand.ts:19`](../packages/jobs/jobs/src/brand.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) `string` @@ -5790,7 +5814,7 @@ SHA-256: `a853902cfab417b8f08aa51ad6855fea46f34ed4588a74e47403035111d0fb86` SHA-256: `e4c18e294232c3ba6c9f1999f168263cc55956147cfe4121720899be8e52edd1` -来源:[`packages/core/session/src/types.ts:361`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:362`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -5949,7 +5973,7 @@ SHA-256: `cbbe8377ee744964537dbafd83607219e58ba79a13f6db0d0645c7ad3d5e8a09` SHA-256: `a81059ca06999da1e712414520c2c65e491d052fcf9640fae20aa38f0a5dc735` -来源:[`packages/core/session/src/types.ts:383`](../packages/core/session/src/types.ts) · [`packages/core/tools/src/types.ts:27`](../packages/core/tools/src/types.ts) +来源:[`packages/core/session/src/types.ts:384`](../packages/core/session/src/types.ts) · [`packages/core/tools/src/types.ts:27`](../packages/core/tools/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -6169,7 +6193,7 @@ SHA-256: `9e41386b3a0c9572b0d63078492ebb3997da7d3a830d44e0259418bd02f4bcb2` SHA-256: `bcf0caf62d964b2fcf5404bd5c909cfa9a21c3f3c96e6b7e36d9e33565223825` -来源:[`packages/core/session/src/types.ts:464`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:465`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -6183,7 +6207,7 @@ SHA-256: `bcf0caf62d964b2fcf5404bd5c909cfa9a21c3f3c96e6b7e36d9e33565223825` SHA-256: `79650cfa10aa3c52440328d85e8a25f0ffadc8d7efd784e3c0819d8bb74a1985` -来源:[`packages/core/session/src/types.ts:212`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:213`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -6196,7 +6220,7 @@ SHA-256: `79650cfa10aa3c52440328d85e8a25f0ffadc8d7efd784e3c0819d8bb74a1985` SHA-256: `5302e763151277614b97e77f00e3b326694c6c1247b916a20503c344dc970777` -来源:[`packages/core/session/src/types.ts:375`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:376`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -6238,7 +6262,7 @@ SHA-256: `693f4952fb5565da96c06bace8924b098ac2fbc915fde0ff8019701183abc057` SHA-256: `ad0c38a0adb82a94c925456df41586d9b5f9202fca18593eae2213d4445b4585` -来源:[`packages/core/session/src/types.ts:390`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:391`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -6246,18 +6270,18 @@ SHA-256: `ad0c38a0adb82a94c925456df41586d9b5f9202fca18593eae2213d4445b4585` | `reason` | 必需 | [`RequestHeaderReason`](#persistence-type-sha256-530da6c0267813bb4511af72883cc518834e624e3b3220a7062955fd57a788a6) | | `startsSeries` | 可选 | `true` | - + ### `{ headerSeq?, message, step, turn }` -SHA-256: `5b55817a403569fbb2d379496df2e3f3f71107f6c577a218028ce187c60b61e6` +SHA-256: `f4ed4717b4c259e933b550a8dcd90dc93628cda8a3c7affcaa53b9c13c1d53eb` -来源:[`packages/core/session/src/types.ts:311`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:312`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| | `headerSeq` | 可选 | `number` | -| `message` | 必需 | [`DeveloperMessage`](#persistence-type-sha256-af99c95d46bed802870f1ebefe0ea11f92a03cb37b8e0cbe3f94b7aaaf509b08) | +| `message` | 必需 | [`DeveloperMessage`](#persistence-type-sha256-0f6265188dae7ba6faa26830a180bb4deb0760561f97ee0c9d399ccf6c9cf1f7) | | `step` | 必需 | `number` | | `turn` | 必需 | `number` | @@ -6335,23 +6359,23 @@ SHA-256: `e3e77b26f0148755a505f5b8ea843a3be117827d4703bb5754d9dc8ddbef521f` SHA-256: `17d1afb770d9941936130996da00dc86782cfef731d8d6526162c301256a4ac3` -来源:[`packages/core/session/src/types.ts:427`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:428`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| | `inherited` | 可选 | `true` | - + ### `{ inserted, outcome?, removedCount?, start, … }` -SHA-256: `83a665476a9ee9af05cb1fdded12dd647d1b97b491200d7f12ef1af7cdac4428` +SHA-256: `fa106c5b33b657e8ef16e8cbf39f3b7d44e5cbcdf4f9303d9db6410f28951e42` 来源:[`packages/core/agent/src/types.ts:96`](../packages/core/agent/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| -| `inserted` | 必需 | [`UserMessage[]`](#persistence-type-sha256-2fef90def36aa758ca096c5f6ad0e00dbb2101943e19ca54b05618801974894a) | +| `inserted` | 必需 | [`UserMessage[]`](#persistence-type-sha256-454a0fa879e38076bdcdbd87ef561cd67d7e43e58a287cf7dbaa7b6cf7847020) | | `outcome` | 可选 | `"canceled"` | | `removedCount` | 可选 | `number` | | `start` | 必需 | `number` | @@ -6363,7 +6387,7 @@ SHA-256: `83a665476a9ee9af05cb1fdded12dd647d1b97b491200d7f12ef1af7cdac4428` SHA-256: `1ccb56061942f5ccb0d30c9e4f2883b9d115ff906560029de637b61d3cde896d` -来源:[`packages/core/session/src/types.ts:341`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:342`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -6380,7 +6404,7 @@ SHA-256: `1ccb56061942f5ccb0d30c9e4f2883b9d115ff906560029de637b61d3cde896d` SHA-256: `0a30c3be336cdd4f8f34e66543a3d46d611c43986edd9b3ce6cbb9b721d3b5d3` -来源:[`packages/core/session/src/types.ts:204`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:205`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -6393,7 +6417,7 @@ SHA-256: `0a30c3be336cdd4f8f34e66543a3d46d611c43986edd9b3ce6cbb9b721d3b5d3` SHA-256: `023a355b2be0d688fb6e4df8388dadb942761d46cfe96d0f499aae9631a159d4` -来源:[`packages/core/session/src/types.ts:206`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:207`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -6405,7 +6429,7 @@ SHA-256: `023a355b2be0d688fb6e4df8388dadb942761d46cfe96d0f499aae9631a159d4` SHA-256: `b6ef9c6210be8350cd465f485cf520031fffa6bf3809c61a1e713e834c5982d0` -来源:[`packages/core/session/src/types.ts:202`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:203`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -6429,7 +6453,7 @@ SHA-256: `02b2c23f2d94b028c9588a8faace3fce1217405db346da3389368b495457bf79` SHA-256: `4bb451330ff699f36b45539ceb0eb2606ead72084e2c5bb637c36cc80e2252db` -来源:[`packages/core/session/src/types.ts:193`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:194`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -6531,7 +6555,7 @@ SHA-256: `1b5649238876e28811e80f10ec67a00c197ad3babd4ecc61a22b09064b88c597` SHA-256: `ec8b5ff7c77ca6893ac7ba5a0c5b2aefeb52f12c64450b7359660c2a7e8a53d7` -来源:[`packages/core/session/src/types.ts:228`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:229`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -6543,7 +6567,7 @@ SHA-256: `ec8b5ff7c77ca6893ac7ba5a0c5b2aefeb52f12c64450b7359660c2a7e8a53d7` SHA-256: `6c5a37d42ebdb12db4410699259335f1e149e3a0260425684c00b153afc8990d` -来源:[`packages/core/session/src/types.ts:192`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:193`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -6712,7 +6736,7 @@ SHA-256: `88841202942c7683b3b7e5ea6a796047ec326b6701ac23e58cc236d85613b9fa` SHA-256: `56b7c047694875a843ba020dfea3c8380d54ae4ff88ab349e72004dc21e07c23` -来源:[`packages/core/session/src/types.ts:221`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:222`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -6724,7 +6748,7 @@ SHA-256: `56b7c047694875a843ba020dfea3c8380d54ae4ff88ab349e72004dc21e07c23` SHA-256: `eaa71be0fbf0f702df8ea6c11d0aa2f9782263d45a10d0fb14eda662f4bfe04c` -来源:[`packages/core/session/src/types.ts:196`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:197`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -6736,7 +6760,7 @@ SHA-256: `eaa71be0fbf0f702df8ea6c11d0aa2f9782263d45a10d0fb14eda662f4bfe04c` SHA-256: `ab7ec5f9eb2c6dfacca5da4c37cf7ba85d04172053ee6b1ebf1709ffcd99c1a7` -来源:[`packages/core/session/src/types.ts:214`](../packages/core/session/src/types.ts) · [`packages/llm/llm/src/types.ts:159`](../packages/llm/llm/src/types.ts) +来源:[`packages/core/session/src/types.ts:215`](../packages/core/session/src/types.ts) · [`packages/llm/llm/src/types.ts:159`](../packages/llm/llm/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -6826,7 +6850,7 @@ SHA-256: `52c6113cb0e53f345324bf84f01928bea5d5df57ee80bc0dd65407b8bc5a3734` SHA-256: `6c03546143cdf6ae88f1745ac98c5139efa1588d1415249bbc5f3104d32ab800` -来源:[`packages/core/session/src/types.ts:191`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:192`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -7518,18 +7542,19 @@ SHA-256: `8fcb0b44a575e998665c053643d1ab04c82f39253ebd190ac0c975b78e40b543` |---|---|---| | `kind` | 必需 | `"tool-registry"` | - + ### `{ kind: "user" }` -SHA-256: `6a4f72e2e179e17b922f2a9392c0e1c8f8c707f32494372454850a3eb184a6e7` +SHA-256: `6dacb1584f2bfbe209dc6b6f578d51da6d55595f64d0e63c727c98f359278def` -来源:[`packages/api/session-controller/src/types.ts:413`](../packages/api/session-controller/src/types.ts) +来源:[`packages/api/session-controller/src/types.ts:418`](../packages/api/session-controller/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| | `clientTimeZone` | 可选 | `string` | | `kind` | 必需 | `"user"` | +| `principal` | 可选 | [`Principal`](#persistence-type-sha256-deebfc7c62177ea840f23c6362b8615bc78ffa93377dd5d243c04e541c413af5) | | `rpcId` | 必需 | `string` | @@ -7689,7 +7714,7 @@ SHA-256: `b1ebd5cda47d90d980bb05bffc3f970c51537b9aff15b3bc3d639811727741b1` SHA-256: `6bc8e51124d0874bc89e1d0ceff381a9c3cd0fc240667d69f841ce4f60f65fe5` -来源:[`packages/core/session/src/types.ts:330`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:331`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -7770,7 +7795,7 @@ SHA-256: `8a9c990773f798eac10ca8878e6bcc531ee080bc589cac956730e462cc6a09ad` SHA-256: `e481c46af3813e0010558e93f578b2986acd658404cec2d1679e3ac2a6c48654` -来源:[`packages/core/session/src/types.ts:297`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:298`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -7824,7 +7849,7 @@ SHA-256: `5dd766ccd1702a1142ad8b4bd44f2242f8e8a6752669ad3de0a5d224329b1438` SHA-256: `54f9276a1617d7c12594722c89cfe864bcbe4517e9a6f9c98bab14f199a135e0` -来源:[`packages/core/session/src/types.ts:355`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:356`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -7838,7 +7863,7 @@ SHA-256: `54f9276a1617d7c12594722c89cfe864bcbe4517e9a6f9c98bab14f199a135e0` SHA-256: `ef11a3b7fa2033ae4279545ff8a8a2ee4e301b9b73e5b91654c48e6d08283545` -来源:[`packages/core/session/src/types.ts:299`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:301`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:300`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:302`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -7889,7 +7914,7 @@ SHA-256: `79ddc907312f97cf9553d4841e8b3fc937097bd8d97900a790efad3c05255e99` SHA-256: `f90eb4ab2d3897bc20c521a038e3119d581f791ec974b1a0004f63a8ab9e232d` -来源:[`packages/core/session/src/types.ts:288`](../packages/core/session/src/types.ts) · [`packages/deliverables/workspace-changes/src/types.ts:106`](../packages/deliverables/workspace-changes/src/types.ts) +来源:[`packages/core/session/src/types.ts:289`](../packages/core/session/src/types.ts) · [`packages/deliverables/workspace-changes/src/types.ts:106`](../packages/deliverables/workspace-changes/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -7911,17 +7936,17 @@ SHA-256: `a10c17474eaf2ddab7095a099e0fe3d046fc18e56c3e344fc8894c05ff9ef97b` | `time` | 必需 | `number` | | `type` | 必需 | `"agent-preset/selected"` | - + ### `{ type: "agent/inbox/spliced" }` -SHA-256: `9a36727716d0e73f681f3b725e7f28f89417c68174c8af0ff2b1ef52b08326a0` +SHA-256: `0755b7d1128319ba2e389619cbbf2c961ad648882b4e91da7f3e9582cc8a2034` | 属性 | 存在性 | 类型 | |---|---|---| -| `data` | 必需 | [`{ inserted, outcome?, removedCount?, start, … }`](#persistence-type-sha256-83a665476a9ee9af05cb1fdded12dd647d1b97b491200d7f12ef1af7cdac4428) | +| `data` | 必需 | [`{ inserted, outcome?, removedCount?, start, … }`](#persistence-type-sha256-fa106c5b33b657e8ef16e8cbf39f3b7d44e5cbcdf4f9303d9db6410f28951e42) | | `ignorable` | 可选 | `true` | | `seq` | 必需 | `number` | | `time` | 必需 | `number` | @@ -8162,17 +8187,17 @@ SHA-256: `13d3d180f977bf78081d487ffa0ecb75857349bcab29a5a3fb48189fca2a6176` | `time` | 必需 | `number` | | `type` | 必需 | `"deliverables/presented"` | - + ### `{ type: "developer/message" }` -SHA-256: `ba5b10ea77d68f4a29439a9bd2a14f6a560acf55731749d57237757cc72fbc06` +SHA-256: `97e0162f983c27456ec71269fb4976b1851588b8c1aeac5d071d43e11f95e03c` | 属性 | 存在性 | 类型 | |---|---|---| -| `data` | 必需 | [`{ headerSeq?, message, step, turn }`](#persistence-type-sha256-5b55817a403569fbb2d379496df2e3f3f71107f6c577a218028ce187c60b61e6) | +| `data` | 必需 | [`{ headerSeq?, message, step, turn }`](#persistence-type-sha256-f4ed4717b4c259e933b550a8dcd90dc93628cda8a3c7affcaa53b9c13c1d53eb) | | `ignorable` | 可选 | `true` | | `seq` | 必需 | `number` | | `sourceEventSeqs` | 可选 | [`number[]`](#persistence-type-sha256-5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78) | @@ -8560,17 +8585,17 @@ SHA-256: `1b912703e2d64f91c99c675b8f805b01076c8325b905c1218ad81ef0b24909d5` | `time` | 必需 | `number` | | `type` | 必需 | `"session/title"` | - + ### `{ type: "session/title-llm-request" }` -SHA-256: `b33545b3244861bcbf73b407d24b53402710f5acacab7ae939537e5504a68dc6` +SHA-256: `79a4c87f8bfb4d6d05cd9952b99210d6a7db3b478c4a01d94b4c50602308c056` | 属性 | 存在性 | 类型 | |---|---|---| -| `data` | 必需 | [`SessionTitleLlmRequestEventData`](#persistence-type-sha256-045926475a46f625f84ae2be292b384817059f16927c69a9adab93717923ac19) | +| `data` | 必需 | [`SessionTitleLlmRequestEventData`](#persistence-type-sha256-00c054d3018c198d92f459563331edd1385e58345c677bb499791df2da0ade9f) | | `ignorable` | 可选 | `true` | | `seq` | 必需 | `number` | | `time` | 必需 | `number` | @@ -8993,17 +9018,17 @@ SHA-256: `f1a96afe7503ac1817de769cd0ba13d9b80111c7034c50106b3344ecc14cc515` | `type` | 必需 | `"usage"` | | `usage` | 必需 | [`TokenUsage`](#persistence-type-sha256-fec5442b9ee4122e10514d81d5a43648003fe8bfb7ee2ba50c0cdbdd383ed394) | - + ### `{ type: "user/message" }` -SHA-256: `2ace3b8059b82b6d9837fed518ca97a99eb37c4482df7aad0e71c85489428ade` +SHA-256: `2ae8594a998c9862a597129e782277bcc518266712a703b5c3161bdf552d13b8` | 属性 | 存在性 | 类型 | |---|---|---| -| `data` | 必需 | [`UserMessage`](#persistence-type-sha256-89b2edff547be1ecfcd78ef18aeeb3cf507fcbf7d6093ec04fb111ef4c8e8a97) | +| `data` | 必需 | [`UserMessage`](#persistence-type-sha256-2cf075b013d363ad743b5a4982900678d108499c721bc11255137402003db711) | | `ignorable` | 可选 | `true` | | `seq` | 必需 | `number` | | `sourceEventSeqs` | 可选 | [`number[]`](#persistence-type-sha256-5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78) | diff --git a/docs/persistence-changes/2026-10-13-user-rpc-principal.i18n.yaml b/docs/persistence-changes/2026-10-13-user-rpc-principal.i18n.yaml new file mode 100644 index 0000000000..13e1d9e422 --- /dev/null +++ b/docs/persistence-changes/2026-10-13-user-rpc-principal.i18n.yaml @@ -0,0 +1,28 @@ +# Bilingual-pair consistency record for 2026-10-13-user-rpc-principal.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/persistence-changes/2026-10-13-user-rpc-principal.md +/: + en: 3d5a424ae7d7e46b + zh: c22a8e4a62d8cc1a +/2026-10-13-user-rpc-principal: + en: c8b8c9fe1f1fb295 + zh: 6775ad8687023eb9 +/2026-10-13-user-rpc-principal/summary: + en: 9fd17b8ef1f569d2 + zh: d1e9679296ce47a0 +/2026-10-13-user-rpc-principal/table-of-contents: + en: 6cf314a069ceb451 + zh: 9adff678e9ecab3b +/2026-10-13-user-rpc-principal/declaration: + en: 6907136f28e48c90 + zh: 02f8298485f3d8e7 +/2026-10-13-user-rpc-principal/compatibility: + en: ae44b935893f7eaa + zh: 686893a2a812570d +/2026-10-13-user-rpc-principal/verification: + en: 53e8380b9c0c63a9 + zh: 3adac787e267570d +/2026-10-13-user-rpc-principal/dev-note: + en: b75c8458928b72b9 + zh: 003ba4e17b4b5269 diff --git a/docs/persistence-changes/2026-10-13-user-rpc-principal.md b/docs/persistence-changes/2026-10-13-user-rpc-principal.md new file mode 100644 index 0000000000..cccf5c6a32 --- /dev/null +++ b/docs/persistence-changes/2026-10-13-user-rpc-principal.md @@ -0,0 +1,60 @@ +--- +description: "Records a persistence type transition and its compatibility acknowledgement." +kind: persistence-change +--- + +# 2026-10-13-user-rpc-principal + +English | [中文](2026-10-13-user-rpc-principal.zh.md) + +## Summary + +Adds an optional principal to the user-rpc message source for prompts submitted by a caller other than the owner. + +## Table of Contents + +- [Declaration](#declaration) +- [Compatibility](#compatibility) +- [Verification](#verification) +- [Dev Note](#dev-note) + + +## Declaration + +```yaml persistence-change +schemaVersion: 1 +id: 2026-10-13-user-rpc-principal +baseline: false +changes: + - root: "event:agent/inbox/spliced" + previous: "2026-10-05-working-directory-attribution" + after: "0755b7d1128319ba2e389619cbbf2c961ad648882b4e91da7f3e9582cc8a2034" + decision: same-version + - root: "event:developer/message" + previous: "2026-10-05-working-directory-attribution" + after: "97e0162f983c27456ec71269fb4976b1851588b8c1aeac5d071d43e11f95e03c" + decision: same-version + - root: "event:session/title-llm-request" + previous: "2026-10-07-title-reasoning-effort" + after: "79a4c87f8bfb4d6d05cd9952b99210d6a7db3b478c4a01d94b4c50602308c056" + decision: same-version + - root: "event:user/message" + previous: "2026-10-05-working-directory-attribution" + after: "2ae8594a998c9862a597129e782277bcc518266712a703b5c3161bdf552d13b8" + decision: same-version +``` + + +## Compatibility + +Owner prompts omit the field, so their sources and every existing Session log are unchanged; an absent principal means the owner. A reader that predates the field ignores it and treats the message as an ordinary user message, because the kind stays 'user'. A current reader of an older log finds no principal and treats every message as the owner's. The field is not part of any model request: provider adapters read only the model-visible message content. SESSION_FORMAT_VERSION does not change. + + +## Verification + +pnpm exec vitest run packages/api/session-controller/tests/remote-access.host.spec.ts packages/subagent/subagent/tests/control.spec.ts packages/api/session-controller/tests/session-cold.host.spec.ts: non-owner prompts persist source.principal in agent/inbox/spliced, owner and existing time-zone sources stay byte-identical; all tests passed. + + +## Dev Note + +None. diff --git a/docs/persistence-changes/2026-10-13-user-rpc-principal.schema.json b/docs/persistence-changes/2026-10-13-user-rpc-principal.schema.json new file mode 100644 index 0000000000..82d7ab993e --- /dev/null +++ b/docs/persistence-changes/2026-10-13-user-rpc-principal.schema.json @@ -0,0 +1,11559 @@ +{ + "formatVersion": 2, + "roots": [ + { + "key": "event:agent/inbox/spliced", + "kind": "event", + "event": "agent/inbox/spliced", + "surface": false, + "digest": "0755b7d1128319ba2e389619cbbf2c961ad648882b4e91da7f3e9582cc8a2034", + "schema": { + "root": 0, + "nodes": [ + { + "kind": "object", + "properties": [ + { + "name": "data", + "type": 1, + "optional": false + }, + { + "name": "ignorable", + "type": 27, + "optional": true + }, + { + "name": "seq", + "type": 20, + "optional": false + }, + { + "name": "time", + "type": 20, + "optional": false + }, + { + "name": "type", + "type": 206, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "inserted", + "type": 2, + "optional": false + }, + { + "name": "outcome", + "type": 202, + "optional": true + }, + { + "name": "removedCount", + "type": 20, + "optional": true + }, + { + "name": "start", + "type": 20, + "optional": false + }, + { + "name": "target", + "type": 203, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "array", + "element": 3 + }, + { + "kind": "object", + "properties": [ + { + "name": "content", + "type": 4, + "optional": false + }, + { + "name": "id", + "type": 7, + "optional": false + }, + { + "name": "role", + "type": 32, + "optional": false + }, + { + "name": "source", + "type": 33, + "optional": false, + "compatibility": { + "version": 1, + "policy": "session-source-attribution", + "binding": "session.user-message.source", + "discriminator": "kind", + "unknownKinds": "preserve", + "attributionKinds": [ + "tmux-context", + "user-question-reply", + "working-directory" + ] + } + } + ], + "indices": [] + }, + { + "kind": "array", + "element": 5 + }, + { + "kind": "union", + "types": [ + 6, + 9, + 11, + 14, + 16, + 18, + 29 + ] + }, + { + "kind": "object", + "properties": [ + { + "name": "text", + "type": 7, + "optional": false + }, + { + "name": "type", + "type": 8, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "string" + }, + { + "kind": "literal", + "value": "reasoning" + }, + { + "kind": "object", + "properties": [ + { + "name": "text", + "type": 7, + "optional": false + }, + { + "name": "type", + "type": 10, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "text" + }, + { + "kind": "object", + "properties": [ + { + "name": "tool", + "type": 12, + "optional": true + }, + { + "name": "toolName", + "type": 7, + "optional": false + }, + { + "name": "type", + "type": 13, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "never" + }, + { + "kind": "literal", + "value": "tool-addition" + }, + { + "kind": "object", + "properties": [ + { + "name": "toolName", + "type": 7, + "optional": false + }, + { + "name": "type", + "type": 15, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "tool-removal" + }, + { + "kind": "object", + "properties": [ + { + "name": "arguments", + "type": 7, + "optional": false + }, + { + "name": "id", + "type": 7, + "optional": false + }, + { + "name": "name", + "type": 7, + "optional": false + }, + { + "name": "type", + "type": 17, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "tool-call" + }, + { + "kind": "object", + "properties": [ + { + "name": "attachment", + "type": 19, + "optional": false + }, + { + "name": "offloaded", + "type": 27, + "optional": true + }, + { + "name": "type", + "type": 28, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "attachmentId", + "type": 7, + "optional": false + }, + { + "name": "bytes", + "type": 20, + "optional": false + }, + { + "name": "height", + "type": 20, + "optional": false + }, + { + "name": "mediaType", + "type": 21, + "optional": false + }, + { + "name": "name", + "type": 7, + "optional": true + }, + { + "name": "originalDimensions", + "type": 26, + "optional": true + }, + { + "name": "width", + "type": 20, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "number" + }, + { + "kind": "union", + "types": [ + 22, + 23, + 24, + 25 + ] + }, + { + "kind": "literal", + "value": "image/gif" + }, + { + "kind": "literal", + "value": "image/jpeg" + }, + { + "kind": "literal", + "value": "image/png" + }, + { + "kind": "literal", + "value": "image/webp" + }, + { + "kind": "object", + "properties": [ + { + "name": "height", + "type": 20, + "optional": false + }, + { + "name": "width", + "type": 20, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": true + }, + { + "kind": "literal", + "value": "image" + }, + { + "kind": "object", + "properties": [ + { + "name": "attachment", + "type": 30, + "optional": false + }, + { + "name": "type", + "type": 31, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "attachmentId", + "type": 7, + "optional": false + }, + { + "name": "bytes", + "type": 20, + "optional": false + }, + { + "name": "name", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "file" + }, + { + "kind": "literal", + "value": "user" + }, + { + "kind": "union", + "types": [ + 34, + 39, + 41, + 43, + 45, + 48, + 51, + 53, + 55, + 57, + 59, + 61, + 63, + 65, + 67, + 69, + 70, + 71, + 72, + 73, + 75, + 76, + 77, + 78, + 79, + 80, + 81, + 82, + 83, + 84, + 85, + 86, + 87, + 89, + 90, + 91, + 92, + 93, + 94, + 95, + 96, + 97, + 98, + 99, + 100, + 101, + 102, + 103, + 104, + 105, + 106, + 107, + 108, + 109, + 110, + 111, + 112, + 113, + 114, + 116, + 117, + 118, + 119, + 120, + 121, + 122, + 123, + 124, + 125, + 126, + 127, + 128, + 130, + 132, + 135, + 136, + 137, + 138, + 139, + 141, + 142, + 143, + 144, + 146, + 147, + 148, + 149, + 150, + 152, + 153, + 154, + 156, + 158, + 159, + 167, + 170, + 172, + 175, + 177, + 179, + 183, + 185, + 193, + 194, + 195, + 196, + 197, + 198, + 199, + 200, + 201 + ] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 35, + "optional": false + }, + { + "name": "kind", + "type": 36, + "optional": false + }, + { + "name": "sections", + "type": 37, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "snapshot" + }, + { + "kind": "literal", + "value": "tool-goal" + }, + { + "kind": "array", + "element": 38 + }, + { + "kind": "object", + "properties": [ + { + "name": "name", + "type": 7, + "optional": false + }, + { + "name": "text", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 35, + "optional": false + }, + { + "name": "kind", + "type": 40, + "optional": false + }, + { + "name": "sections", + "type": 37, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "tool-jobs" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 35, + "optional": false + }, + { + "name": "kind", + "type": 42, + "optional": false + }, + { + "name": "sections", + "type": 37, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "user-approval" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 35, + "optional": false + }, + { + "name": "kind", + "type": 44, + "optional": false + }, + { + "name": "sections", + "type": 37, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "dsh-session-title-llm" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 46, + "optional": false + }, + { + "name": "kind", + "type": 47, + "optional": false + }, + { + "name": "senderSessionId", + "type": 7, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "notice" + }, + { + "kind": "literal", + "value": "subagent-settled" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 49, + "optional": false + }, + { + "name": "kind", + "type": 50, + "optional": false + }, + { + "name": "senderSessionId", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "relay" + }, + { + "kind": "literal", + "value": "agent-message" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 46, + "optional": false + }, + { + "name": "kind", + "type": 52, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "hooks-claude-code" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 46, + "optional": false + }, + { + "name": "kind", + "type": 54, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "hooks-codex" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 46, + "optional": false + }, + { + "name": "kind", + "type": 56, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "model-selection" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 46, + "optional": false + }, + { + "name": "kind", + "type": 58, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "plan-mode" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 46, + "optional": false + }, + { + "name": "kind", + "type": 60, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "repeat-tool-reminder" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 46, + "optional": false + }, + { + "name": "kind", + "type": 62, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "runtime-context" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 46, + "optional": false + }, + { + "name": "kind", + "type": 64, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "schedule" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 46, + "optional": false + }, + { + "name": "kind", + "type": 66, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "time-context" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 46, + "optional": false + }, + { + "name": "kind", + "type": 68, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "tmux-context" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 46, + "optional": false + }, + { + "name": "kind", + "type": 36, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 46, + "optional": false + }, + { + "name": "kind", + "type": 40, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 46, + "optional": false + }, + { + "name": "kind", + "type": 42, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 46, + "optional": false + }, + { + "name": "kind", + "type": 44, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 74, + "optional": false + }, + { + "name": "kind", + "type": 52, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "instructions" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 74, + "optional": false + }, + { + "name": "kind", + "type": 54, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 74, + "optional": false + }, + { + "name": "kind", + "type": 56, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 74, + "optional": false + }, + { + "name": "kind", + "type": 58, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 74, + "optional": false + }, + { + "name": "kind", + "type": 60, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 74, + "optional": false + }, + { + "name": "kind", + "type": 62, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 74, + "optional": false + }, + { + "name": "kind", + "type": 64, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 74, + "optional": false + }, + { + "name": "kind", + "type": 66, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 74, + "optional": false + }, + { + "name": "kind", + "type": 68, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 74, + "optional": false + }, + { + "name": "kind", + "type": 36, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 74, + "optional": false + }, + { + "name": "kind", + "type": 40, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 74, + "optional": false + }, + { + "name": "kind", + "type": 42, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 74, + "optional": false + }, + { + "name": "kind", + "type": 44, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 88, + "optional": false + }, + { + "name": "kind", + "type": 52, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "recall" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 88, + "optional": false + }, + { + "name": "kind", + "type": 54, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 88, + "optional": false + }, + { + "name": "kind", + "type": 56, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 88, + "optional": false + }, + { + "name": "kind", + "type": 58, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 88, + "optional": false + }, + { + "name": "kind", + "type": 60, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 88, + "optional": false + }, + { + "name": "kind", + "type": 62, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 88, + "optional": false + }, + { + "name": "kind", + "type": 64, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 88, + "optional": false + }, + { + "name": "kind", + "type": 66, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 88, + "optional": false + }, + { + "name": "kind", + "type": 68, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 88, + "optional": false + }, + { + "name": "kind", + "type": 36, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 88, + "optional": false + }, + { + "name": "kind", + "type": 40, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 88, + "optional": false + }, + { + "name": "kind", + "type": 42, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 88, + "optional": false + }, + { + "name": "kind", + "type": 44, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 49, + "optional": false + }, + { + "name": "kind", + "type": 52, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 49, + "optional": false + }, + { + "name": "kind", + "type": 54, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 49, + "optional": false + }, + { + "name": "kind", + "type": 56, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 49, + "optional": false + }, + { + "name": "kind", + "type": 58, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 49, + "optional": false + }, + { + "name": "kind", + "type": 60, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 49, + "optional": false + }, + { + "name": "kind", + "type": 62, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 49, + "optional": false + }, + { + "name": "kind", + "type": 64, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 49, + "optional": false + }, + { + "name": "kind", + "type": 66, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 49, + "optional": false + }, + { + "name": "kind", + "type": 68, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 49, + "optional": false + }, + { + "name": "kind", + "type": 36, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 49, + "optional": false + }, + { + "name": "kind", + "type": 40, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 49, + "optional": false + }, + { + "name": "kind", + "type": 42, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 49, + "optional": false + }, + { + "name": "kind", + "type": 44, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 115, + "optional": false + }, + { + "name": "kind", + "type": 52, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "catalog" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 115, + "optional": false + }, + { + "name": "kind", + "type": 54, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 115, + "optional": false + }, + { + "name": "kind", + "type": 56, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 115, + "optional": false + }, + { + "name": "kind", + "type": 58, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 115, + "optional": false + }, + { + "name": "kind", + "type": 60, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 115, + "optional": false + }, + { + "name": "kind", + "type": 62, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 115, + "optional": false + }, + { + "name": "kind", + "type": 64, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 115, + "optional": false + }, + { + "name": "kind", + "type": 66, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 115, + "optional": false + }, + { + "name": "kind", + "type": 68, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 115, + "optional": false + }, + { + "name": "kind", + "type": 36, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 115, + "optional": false + }, + { + "name": "kind", + "type": 40, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 115, + "optional": false + }, + { + "name": "kind", + "type": 42, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 115, + "optional": false + }, + { + "name": "kind", + "type": 44, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "goalId", + "type": 7, + "optional": false + }, + { + "name": "kind", + "type": 129, + "optional": false + }, + { + "name": "revision", + "type": 20, + "optional": false + }, + { + "name": "round", + "type": 20, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "goal" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 131, + "optional": false + }, + { + "name": "messageId", + "type": 7, + "optional": false + }, + { + "name": "senderId", + "type": 7, + "optional": false + }, + { + "name": "senderName", + "type": 7, + "optional": false + }, + { + "name": "teamId", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "team-message" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 133, + "optional": false + }, + { + "name": "model", + "type": 7, + "optional": false + }, + { + "name": "provider", + "type": 7, + "optional": false + }, + { + "name": "replayState", + "type": 134, + "optional": true + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "model" + }, + { + "kind": "opaque", + "reason": "unknown" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 52, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 54, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 56, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 58, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 140, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "ptc-mode" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 60, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 62, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 64, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 145, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "system-prompt" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 66, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 68, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 36, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 40, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 151, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "tool-registry" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 32, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 42, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 155, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "working-directory" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 157, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "cordis-host-runner" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 44, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "baseline", + "type": 27, + "optional": true + }, + { + "name": "baselineIdentity", + "type": 7, + "optional": true + }, + { + "name": "changes", + "type": 160, + "optional": false + }, + { + "name": "form", + "type": 74, + "optional": false + }, + { + "name": "kind", + "type": 166, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "array", + "element": 161 + }, + { + "kind": "object", + "properties": [ + { + "name": "action", + "type": 162, + "optional": false + }, + { + "name": "digest", + "type": 7, + "optional": true + }, + { + "name": "path", + "type": 7, + "optional": false + }, + { + "name": "scope", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "union", + "types": [ + 163, + 164, + 165 + ] + }, + { + "kind": "literal", + "value": "remove" + }, + { + "kind": "literal", + "value": "replace" + }, + { + "kind": "literal", + "value": "set" + }, + { + "kind": "literal", + "value": "agent-instructions" + }, + { + "kind": "object", + "properties": [ + { + "name": "callId", + "type": 7, + "optional": false + }, + { + "name": "kind", + "type": 168, + "optional": false + }, + { + "name": "outcome", + "type": 169, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "user-question-reply" + }, + { + "kind": "literal", + "value": "answered" + }, + { + "kind": "object", + "properties": [ + { + "name": "callId", + "type": 7, + "optional": false + }, + { + "name": "kind", + "type": 171, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "tool" + }, + { + "kind": "object", + "properties": [ + { + "name": "clientTimeZone", + "type": 7, + "optional": true + }, + { + "name": "kind", + "type": 32, + "optional": false + }, + { + "name": "principal", + "type": 173, + "optional": true + }, + { + "name": "rpcId", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 174, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "owner" + }, + { + "kind": "object", + "properties": [ + { + "name": "compactionId", + "type": 7, + "optional": false + }, + { + "name": "kind", + "type": 176, + "optional": false + }, + { + "name": "sourceCommandId", + "type": 7, + "optional": true + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "compact-checkpoint" + }, + { + "kind": "object", + "properties": [ + { + "name": "deliveryId", + "type": 7, + "optional": false + }, + { + "name": "form", + "type": 46, + "optional": false + }, + { + "name": "kind", + "type": 178, + "optional": false + }, + { + "name": "provider", + "type": 7, + "optional": false + }, + { + "name": "ruleId", + "type": 7, + "optional": false + }, + { + "name": "source", + "type": 7, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "webhook" + }, + { + "kind": "object", + "properties": [ + { + "name": "entries", + "type": 180, + "optional": false + }, + { + "name": "form", + "type": 115, + "optional": false + }, + { + "name": "kind", + "type": 182, + "optional": false + }, + { + "name": "update", + "type": 27, + "optional": true + } + ], + "indices": [] + }, + { + "kind": "array", + "element": 181 + }, + { + "kind": "object", + "properties": [ + { + "name": "description", + "type": 7, + "optional": false + }, + { + "name": "name", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "skill-catalog" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 74, + "optional": false + }, + { + "name": "kind", + "type": 184, + "optional": false + }, + { + "name": "name", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "skill-invocation" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 88, + "optional": false + }, + { + "name": "kind", + "type": 186, + "optional": false + }, + { + "name": "references", + "type": 187, + "optional": false + }, + { + "name": "version", + "type": 192, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "session-reference" + }, + { + "kind": "array", + "element": 188 + }, + { + "kind": "object", + "properties": [ + { + "name": "capturedFormatVersion", + "type": 20, + "optional": true + }, + { + "name": "capturedThroughSeq", + "type": 189, + "optional": false + }, + { + "name": "compacted", + "type": 191, + "optional": false + }, + { + "name": "inputIndex", + "type": 20, + "optional": false + }, + { + "name": "label", + "type": 7, + "optional": false + }, + { + "name": "omittedBytes", + "type": 20, + "optional": false + }, + { + "name": "omittedMessages", + "type": 20, + "optional": false + }, + { + "name": "originalMessages", + "type": 20, + "optional": false + }, + { + "name": "retainedMessages", + "type": 20, + "optional": false + }, + { + "name": "sessionId", + "type": 7, + "optional": false + }, + { + "name": "truncated", + "type": 191, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "union", + "types": [ + 20, + 190 + ] + }, + { + "kind": "primitive", + "type": "null" + }, + { + "kind": "primitive", + "type": "boolean" + }, + { + "kind": "literal", + "value": 1 + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 35, + "optional": false + }, + { + "name": "kind", + "type": 52, + "optional": false + }, + { + "name": "sections", + "type": 37, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 35, + "optional": false + }, + { + "name": "kind", + "type": 54, + "optional": false + }, + { + "name": "sections", + "type": 37, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 35, + "optional": false + }, + { + "name": "kind", + "type": 56, + "optional": false + }, + { + "name": "sections", + "type": 37, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 35, + "optional": false + }, + { + "name": "kind", + "type": 58, + "optional": false + }, + { + "name": "sections", + "type": 37, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 35, + "optional": false + }, + { + "name": "kind", + "type": 60, + "optional": false + }, + { + "name": "sections", + "type": 37, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 35, + "optional": false + }, + { + "name": "kind", + "type": 62, + "optional": false + }, + { + "name": "sections", + "type": 37, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 35, + "optional": false + }, + { + "name": "kind", + "type": 64, + "optional": false + }, + { + "name": "sections", + "type": 37, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 35, + "optional": false + }, + { + "name": "kind", + "type": 66, + "optional": false + }, + { + "name": "sections", + "type": 37, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 35, + "optional": false + }, + { + "name": "kind", + "type": 68, + "optional": false + }, + { + "name": "sections", + "type": 37, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "canceled" + }, + { + "kind": "union", + "types": [ + 204, + 205 + ] + }, + { + "kind": "literal", + "value": "next-step" + }, + { + "kind": "literal", + "value": "next-turn" + }, + { + "kind": "literal", + "value": "agent/inbox/spliced" + } + ] + } + }, + { + "key": "event:developer/message", + "kind": "event", + "event": "developer/message", + "surface": true, + "digest": "97e0162f983c27456ec71269fb4976b1851588b8c1aeac5d071d43e11f95e03c", + "schema": { + "root": 0, + "nodes": [ + { + "kind": "object", + "properties": [ + { + "name": "data", + "type": 1, + "optional": false + }, + { + "name": "ignorable", + "type": 26, + "optional": true + }, + { + "name": "seq", + "type": 2, + "optional": false + }, + { + "name": "sourceEventSeqs", + "type": 202, + "optional": true + }, + { + "name": "surfaceOp", + "type": 203, + "optional": false + }, + { + "name": "time", + "type": 2, + "optional": false + }, + { + "name": "type", + "type": 206, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "headerSeq", + "type": 2, + "optional": true + }, + { + "name": "message", + "type": 3, + "optional": false + }, + { + "name": "step", + "type": 2, + "optional": false + }, + { + "name": "turn", + "type": 2, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "number" + }, + { + "kind": "object", + "properties": [ + { + "name": "content", + "type": 4, + "optional": false + }, + { + "name": "id", + "type": 7, + "optional": false + }, + { + "name": "role", + "type": 31, + "optional": false + }, + { + "name": "source", + "type": 32, + "optional": false, + "compatibility": { + "version": 1, + "policy": "session-source-attribution", + "binding": "session.developer-message.source", + "discriminator": "kind", + "unknownKinds": "preserve", + "attributionKinds": [ + "tmux-context", + "user-question-reply", + "working-directory" + ] + } + } + ], + "indices": [] + }, + { + "kind": "array", + "element": 5 + }, + { + "kind": "union", + "types": [ + 6, + 9, + 11, + 14, + 16, + 18, + 28 + ] + }, + { + "kind": "object", + "properties": [ + { + "name": "text", + "type": 7, + "optional": false + }, + { + "name": "type", + "type": 8, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "string" + }, + { + "kind": "literal", + "value": "reasoning" + }, + { + "kind": "object", + "properties": [ + { + "name": "text", + "type": 7, + "optional": false + }, + { + "name": "type", + "type": 10, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "text" + }, + { + "kind": "object", + "properties": [ + { + "name": "tool", + "type": 12, + "optional": true + }, + { + "name": "toolName", + "type": 7, + "optional": false + }, + { + "name": "type", + "type": 13, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "never" + }, + { + "kind": "literal", + "value": "tool-addition" + }, + { + "kind": "object", + "properties": [ + { + "name": "toolName", + "type": 7, + "optional": false + }, + { + "name": "type", + "type": 15, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "tool-removal" + }, + { + "kind": "object", + "properties": [ + { + "name": "arguments", + "type": 7, + "optional": false + }, + { + "name": "id", + "type": 7, + "optional": false + }, + { + "name": "name", + "type": 7, + "optional": false + }, + { + "name": "type", + "type": 17, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "tool-call" + }, + { + "kind": "object", + "properties": [ + { + "name": "attachment", + "type": 19, + "optional": false + }, + { + "name": "offloaded", + "type": 26, + "optional": true + }, + { + "name": "type", + "type": 27, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "attachmentId", + "type": 7, + "optional": false + }, + { + "name": "bytes", + "type": 2, + "optional": false + }, + { + "name": "height", + "type": 2, + "optional": false + }, + { + "name": "mediaType", + "type": 20, + "optional": false + }, + { + "name": "name", + "type": 7, + "optional": true + }, + { + "name": "originalDimensions", + "type": 25, + "optional": true + }, + { + "name": "width", + "type": 2, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "union", + "types": [ + 21, + 22, + 23, + 24 + ] + }, + { + "kind": "literal", + "value": "image/gif" + }, + { + "kind": "literal", + "value": "image/jpeg" + }, + { + "kind": "literal", + "value": "image/png" + }, + { + "kind": "literal", + "value": "image/webp" + }, + { + "kind": "object", + "properties": [ + { + "name": "height", + "type": 2, + "optional": false + }, + { + "name": "width", + "type": 2, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": true + }, + { + "kind": "literal", + "value": "image" + }, + { + "kind": "object", + "properties": [ + { + "name": "attachment", + "type": 29, + "optional": false + }, + { + "name": "type", + "type": 30, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "attachmentId", + "type": 7, + "optional": false + }, + { + "name": "bytes", + "type": 2, + "optional": false + }, + { + "name": "name", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "file" + }, + { + "kind": "literal", + "value": "developer" + }, + { + "kind": "union", + "types": [ + 33, + 38, + 40, + 42, + 44, + 47, + 50, + 52, + 54, + 56, + 58, + 60, + 62, + 64, + 66, + 68, + 69, + 70, + 71, + 72, + 74, + 75, + 76, + 77, + 78, + 79, + 80, + 81, + 82, + 83, + 84, + 85, + 86, + 88, + 89, + 90, + 91, + 92, + 93, + 94, + 95, + 96, + 97, + 98, + 99, + 100, + 101, + 102, + 103, + 104, + 105, + 106, + 107, + 108, + 109, + 110, + 111, + 112, + 113, + 115, + 116, + 117, + 118, + 119, + 120, + 121, + 122, + 123, + 124, + 125, + 126, + 127, + 129, + 131, + 134, + 135, + 136, + 137, + 138, + 139, + 141, + 142, + 143, + 144, + 146, + 147, + 148, + 149, + 150, + 152, + 154, + 155, + 157, + 159, + 167, + 170, + 172, + 175, + 177, + 179, + 183, + 185, + 193, + 194, + 195, + 196, + 197, + 198, + 199, + 200, + 201 + ] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 34, + "optional": false + }, + { + "name": "kind", + "type": 35, + "optional": false + }, + { + "name": "sections", + "type": 36, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "snapshot" + }, + { + "kind": "literal", + "value": "tmux-context" + }, + { + "kind": "array", + "element": 37 + }, + { + "kind": "object", + "properties": [ + { + "name": "name", + "type": 7, + "optional": false + }, + { + "name": "text", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 34, + "optional": false + }, + { + "name": "kind", + "type": 39, + "optional": false + }, + { + "name": "sections", + "type": 36, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "tool-goal" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 34, + "optional": false + }, + { + "name": "kind", + "type": 41, + "optional": false + }, + { + "name": "sections", + "type": 36, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "tool-jobs" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 34, + "optional": false + }, + { + "name": "kind", + "type": 43, + "optional": false + }, + { + "name": "sections", + "type": 36, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "user-approval" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 45, + "optional": false + }, + { + "name": "kind", + "type": 46, + "optional": false + }, + { + "name": "senderSessionId", + "type": 7, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "notice" + }, + { + "kind": "literal", + "value": "subagent-settled" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 48, + "optional": false + }, + { + "name": "kind", + "type": 49, + "optional": false + }, + { + "name": "senderSessionId", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "relay" + }, + { + "kind": "literal", + "value": "agent-message" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 45, + "optional": false + }, + { + "name": "kind", + "type": 51, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "dsh-session-title-llm" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 45, + "optional": false + }, + { + "name": "kind", + "type": 53, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "hooks-claude-code" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 45, + "optional": false + }, + { + "name": "kind", + "type": 55, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "hooks-codex" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 45, + "optional": false + }, + { + "name": "kind", + "type": 57, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "model-selection" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 45, + "optional": false + }, + { + "name": "kind", + "type": 59, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "plan-mode" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 45, + "optional": false + }, + { + "name": "kind", + "type": 61, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "repeat-tool-reminder" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 45, + "optional": false + }, + { + "name": "kind", + "type": 63, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "runtime-context" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 45, + "optional": false + }, + { + "name": "kind", + "type": 65, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "schedule" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 45, + "optional": false + }, + { + "name": "kind", + "type": 67, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "time-context" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 45, + "optional": false + }, + { + "name": "kind", + "type": 35, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 45, + "optional": false + }, + { + "name": "kind", + "type": 39, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 45, + "optional": false + }, + { + "name": "kind", + "type": 41, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 45, + "optional": false + }, + { + "name": "kind", + "type": 43, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 73, + "optional": false + }, + { + "name": "kind", + "type": 51, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "instructions" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 73, + "optional": false + }, + { + "name": "kind", + "type": 53, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 73, + "optional": false + }, + { + "name": "kind", + "type": 55, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 73, + "optional": false + }, + { + "name": "kind", + "type": 57, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 73, + "optional": false + }, + { + "name": "kind", + "type": 59, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 73, + "optional": false + }, + { + "name": "kind", + "type": 61, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 73, + "optional": false + }, + { + "name": "kind", + "type": 63, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 73, + "optional": false + }, + { + "name": "kind", + "type": 65, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 73, + "optional": false + }, + { + "name": "kind", + "type": 67, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 73, + "optional": false + }, + { + "name": "kind", + "type": 35, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 73, + "optional": false + }, + { + "name": "kind", + "type": 39, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 73, + "optional": false + }, + { + "name": "kind", + "type": 41, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 73, + "optional": false + }, + { + "name": "kind", + "type": 43, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 87, + "optional": false + }, + { + "name": "kind", + "type": 51, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "recall" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 87, + "optional": false + }, + { + "name": "kind", + "type": 53, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 87, + "optional": false + }, + { + "name": "kind", + "type": 55, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 87, + "optional": false + }, + { + "name": "kind", + "type": 57, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 87, + "optional": false + }, + { + "name": "kind", + "type": 59, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 87, + "optional": false + }, + { + "name": "kind", + "type": 61, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 87, + "optional": false + }, + { + "name": "kind", + "type": 63, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 87, + "optional": false + }, + { + "name": "kind", + "type": 65, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 87, + "optional": false + }, + { + "name": "kind", + "type": 67, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 87, + "optional": false + }, + { + "name": "kind", + "type": 35, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 87, + "optional": false + }, + { + "name": "kind", + "type": 39, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 87, + "optional": false + }, + { + "name": "kind", + "type": 41, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 87, + "optional": false + }, + { + "name": "kind", + "type": 43, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 48, + "optional": false + }, + { + "name": "kind", + "type": 51, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 48, + "optional": false + }, + { + "name": "kind", + "type": 53, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 48, + "optional": false + }, + { + "name": "kind", + "type": 55, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 48, + "optional": false + }, + { + "name": "kind", + "type": 57, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 48, + "optional": false + }, + { + "name": "kind", + "type": 59, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 48, + "optional": false + }, + { + "name": "kind", + "type": 61, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 48, + "optional": false + }, + { + "name": "kind", + "type": 63, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 48, + "optional": false + }, + { + "name": "kind", + "type": 65, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 48, + "optional": false + }, + { + "name": "kind", + "type": 67, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 48, + "optional": false + }, + { + "name": "kind", + "type": 35, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 48, + "optional": false + }, + { + "name": "kind", + "type": 39, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 48, + "optional": false + }, + { + "name": "kind", + "type": 41, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 48, + "optional": false + }, + { + "name": "kind", + "type": 43, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 114, + "optional": false + }, + { + "name": "kind", + "type": 51, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "catalog" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 114, + "optional": false + }, + { + "name": "kind", + "type": 53, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 114, + "optional": false + }, + { + "name": "kind", + "type": 55, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 114, + "optional": false + }, + { + "name": "kind", + "type": 57, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 114, + "optional": false + }, + { + "name": "kind", + "type": 59, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 114, + "optional": false + }, + { + "name": "kind", + "type": 61, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 114, + "optional": false + }, + { + "name": "kind", + "type": 63, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 114, + "optional": false + }, + { + "name": "kind", + "type": 65, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 114, + "optional": false + }, + { + "name": "kind", + "type": 67, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 114, + "optional": false + }, + { + "name": "kind", + "type": 35, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 114, + "optional": false + }, + { + "name": "kind", + "type": 39, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 114, + "optional": false + }, + { + "name": "kind", + "type": 41, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 114, + "optional": false + }, + { + "name": "kind", + "type": 43, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "goalId", + "type": 7, + "optional": false + }, + { + "name": "kind", + "type": 128, + "optional": false + }, + { + "name": "revision", + "type": 2, + "optional": false + }, + { + "name": "round", + "type": 2, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "goal" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 130, + "optional": false + }, + { + "name": "messageId", + "type": 7, + "optional": false + }, + { + "name": "senderId", + "type": 7, + "optional": false + }, + { + "name": "senderName", + "type": 7, + "optional": false + }, + { + "name": "teamId", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "team-message" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 132, + "optional": false + }, + { + "name": "model", + "type": 7, + "optional": false + }, + { + "name": "provider", + "type": 7, + "optional": false + }, + { + "name": "replayState", + "type": 133, + "optional": true + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "model" + }, + { + "kind": "opaque", + "reason": "unknown" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 51, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 53, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 55, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 57, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 59, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 140, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "ptc-mode" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 61, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 63, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 65, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 145, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "system-prompt" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 67, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 35, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 39, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 41, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 151, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "tool-registry" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 153, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "user" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 43, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 156, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "working-directory" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 158, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "cordis-host-runner" + }, + { + "kind": "object", + "properties": [ + { + "name": "baseline", + "type": 26, + "optional": true + }, + { + "name": "baselineIdentity", + "type": 7, + "optional": true + }, + { + "name": "changes", + "type": 160, + "optional": false + }, + { + "name": "form", + "type": 73, + "optional": false + }, + { + "name": "kind", + "type": 166, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "array", + "element": 161 + }, + { + "kind": "object", + "properties": [ + { + "name": "action", + "type": 162, + "optional": false + }, + { + "name": "digest", + "type": 7, + "optional": true + }, + { + "name": "path", + "type": 7, + "optional": false + }, + { + "name": "scope", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "union", + "types": [ + 163, + 164, + 165 + ] + }, + { + "kind": "literal", + "value": "remove" + }, + { + "kind": "literal", + "value": "replace" + }, + { + "kind": "literal", + "value": "set" + }, + { + "kind": "literal", + "value": "agent-instructions" + }, + { + "kind": "object", + "properties": [ + { + "name": "callId", + "type": 7, + "optional": false + }, + { + "name": "kind", + "type": 168, + "optional": false + }, + { + "name": "outcome", + "type": 169, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "user-question-reply" + }, + { + "kind": "literal", + "value": "answered" + }, + { + "kind": "object", + "properties": [ + { + "name": "callId", + "type": 7, + "optional": false + }, + { + "name": "kind", + "type": 171, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "tool" + }, + { + "kind": "object", + "properties": [ + { + "name": "clientTimeZone", + "type": 7, + "optional": true + }, + { + "name": "kind", + "type": 153, + "optional": false + }, + { + "name": "principal", + "type": 173, + "optional": true + }, + { + "name": "rpcId", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 174, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "owner" + }, + { + "kind": "object", + "properties": [ + { + "name": "compactionId", + "type": 7, + "optional": false + }, + { + "name": "kind", + "type": 176, + "optional": false + }, + { + "name": "sourceCommandId", + "type": 7, + "optional": true + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "compact-checkpoint" + }, + { + "kind": "object", + "properties": [ + { + "name": "deliveryId", + "type": 7, + "optional": false + }, + { + "name": "form", + "type": 45, + "optional": false + }, + { + "name": "kind", + "type": 178, + "optional": false + }, + { + "name": "provider", + "type": 7, + "optional": false + }, + { + "name": "ruleId", + "type": 7, + "optional": false + }, + { + "name": "source", + "type": 7, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "webhook" + }, + { + "kind": "object", + "properties": [ + { + "name": "entries", + "type": 180, + "optional": false + }, + { + "name": "form", + "type": 114, + "optional": false + }, + { + "name": "kind", + "type": 182, + "optional": false + }, + { + "name": "update", + "type": 26, + "optional": true + } + ], + "indices": [] + }, + { + "kind": "array", + "element": 181 + }, + { + "kind": "object", + "properties": [ + { + "name": "description", + "type": 7, + "optional": false + }, + { + "name": "name", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "skill-catalog" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 73, + "optional": false + }, + { + "name": "kind", + "type": 184, + "optional": false + }, + { + "name": "name", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "skill-invocation" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 87, + "optional": false + }, + { + "name": "kind", + "type": 186, + "optional": false + }, + { + "name": "references", + "type": 187, + "optional": false + }, + { + "name": "version", + "type": 192, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "session-reference" + }, + { + "kind": "array", + "element": 188 + }, + { + "kind": "object", + "properties": [ + { + "name": "capturedFormatVersion", + "type": 2, + "optional": true + }, + { + "name": "capturedThroughSeq", + "type": 189, + "optional": false + }, + { + "name": "compacted", + "type": 191, + "optional": false + }, + { + "name": "inputIndex", + "type": 2, + "optional": false + }, + { + "name": "label", + "type": 7, + "optional": false + }, + { + "name": "omittedBytes", + "type": 2, + "optional": false + }, + { + "name": "omittedMessages", + "type": 2, + "optional": false + }, + { + "name": "originalMessages", + "type": 2, + "optional": false + }, + { + "name": "retainedMessages", + "type": 2, + "optional": false + }, + { + "name": "sessionId", + "type": 7, + "optional": false + }, + { + "name": "truncated", + "type": 191, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "union", + "types": [ + 2, + 190 + ] + }, + { + "kind": "primitive", + "type": "null" + }, + { + "kind": "primitive", + "type": "boolean" + }, + { + "kind": "literal", + "value": 1 + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 34, + "optional": false + }, + { + "name": "kind", + "type": 51, + "optional": false + }, + { + "name": "sections", + "type": 36, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 34, + "optional": false + }, + { + "name": "kind", + "type": 53, + "optional": false + }, + { + "name": "sections", + "type": 36, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 34, + "optional": false + }, + { + "name": "kind", + "type": 55, + "optional": false + }, + { + "name": "sections", + "type": 36, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 34, + "optional": false + }, + { + "name": "kind", + "type": 57, + "optional": false + }, + { + "name": "sections", + "type": 36, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 34, + "optional": false + }, + { + "name": "kind", + "type": 59, + "optional": false + }, + { + "name": "sections", + "type": 36, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 34, + "optional": false + }, + { + "name": "kind", + "type": 61, + "optional": false + }, + { + "name": "sections", + "type": 36, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 34, + "optional": false + }, + { + "name": "kind", + "type": 63, + "optional": false + }, + { + "name": "sections", + "type": 36, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 34, + "optional": false + }, + { + "name": "kind", + "type": 65, + "optional": false + }, + { + "name": "sections", + "type": 36, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 34, + "optional": false + }, + { + "name": "kind", + "type": 67, + "optional": false + }, + { + "name": "sections", + "type": 36, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "array", + "element": 2 + }, + { + "kind": "union", + "types": [ + 204, + 205 + ] + }, + { + "kind": "literal", + "value": "append" + }, + { + "kind": "object", + "properties": [ + { + "name": "endSeq", + "type": 2, + "optional": false + }, + { + "name": "op", + "type": 164, + "optional": false + }, + { + "name": "startSeq", + "type": 2, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "developer/message" + } + ] + } + }, + { + "key": "event:session/title-llm-request", + "kind": "event", + "event": "session/title-llm-request", + "surface": false, + "digest": "79a4c87f8bfb4d6d05cd9952b99210d6a7db3b478c4a01d94b4c50602308c056", + "schema": { + "root": 0, + "nodes": [ + { + "kind": "object", + "properties": [ + { + "name": "data", + "type": 1, + "optional": false + }, + { + "name": "ignorable", + "type": 29, + "optional": true + }, + { + "name": "seq", + "type": 2, + "optional": false + }, + { + "name": "time", + "type": 2, + "optional": false + }, + { + "name": "type", + "type": 212, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "maxTokens", + "type": 2, + "optional": false + }, + { + "name": "messageSeqs", + "type": 3, + "optional": false + }, + { + "name": "messages", + "type": 4, + "optional": false + }, + { + "name": "reasoningEffort", + "type": 10, + "optional": true + }, + { + "name": "route", + "type": 211, + "optional": false + }, + { + "name": "system", + "type": 10, + "optional": false + }, + { + "name": "titleProvider", + "type": 10, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "number" + }, + { + "kind": "array", + "element": 2 + }, + { + "kind": "array", + "element": 5 + }, + { + "kind": "union", + "types": [ + 6, + 37, + 206, + 207, + 209 + ] + }, + { + "kind": "object", + "properties": [ + { + "name": "content", + "type": 7, + "optional": false + }, + { + "name": "id", + "type": 10, + "optional": false + }, + { + "name": "isError", + "type": 34, + "optional": true + }, + { + "name": "role", + "type": 35, + "optional": false + }, + { + "name": "source", + "type": 36, + "optional": false + }, + { + "name": "toolCallId", + "type": 10, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "array", + "element": 8 + }, + { + "kind": "union", + "types": [ + 9, + 12, + 14, + 17, + 19, + 21, + 31 + ] + }, + { + "kind": "object", + "properties": [ + { + "name": "text", + "type": 10, + "optional": false + }, + { + "name": "type", + "type": 11, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "string" + }, + { + "kind": "literal", + "value": "reasoning" + }, + { + "kind": "object", + "properties": [ + { + "name": "text", + "type": 10, + "optional": false + }, + { + "name": "type", + "type": 13, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "text" + }, + { + "kind": "object", + "properties": [ + { + "name": "tool", + "type": 15, + "optional": true + }, + { + "name": "toolName", + "type": 10, + "optional": false + }, + { + "name": "type", + "type": 16, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "never" + }, + { + "kind": "literal", + "value": "tool-addition" + }, + { + "kind": "object", + "properties": [ + { + "name": "toolName", + "type": 10, + "optional": false + }, + { + "name": "type", + "type": 18, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "tool-removal" + }, + { + "kind": "object", + "properties": [ + { + "name": "arguments", + "type": 10, + "optional": false + }, + { + "name": "id", + "type": 10, + "optional": false + }, + { + "name": "name", + "type": 10, + "optional": false + }, + { + "name": "type", + "type": 20, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "tool-call" + }, + { + "kind": "object", + "properties": [ + { + "name": "attachment", + "type": 22, + "optional": false + }, + { + "name": "offloaded", + "type": 29, + "optional": true + }, + { + "name": "type", + "type": 30, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "attachmentId", + "type": 10, + "optional": false + }, + { + "name": "bytes", + "type": 2, + "optional": false + }, + { + "name": "height", + "type": 2, + "optional": false + }, + { + "name": "mediaType", + "type": 23, + "optional": false + }, + { + "name": "name", + "type": 10, + "optional": true + }, + { + "name": "originalDimensions", + "type": 28, + "optional": true + }, + { + "name": "width", + "type": 2, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "union", + "types": [ + 24, + 25, + 26, + 27 + ] + }, + { + "kind": "literal", + "value": "image/gif" + }, + { + "kind": "literal", + "value": "image/jpeg" + }, + { + "kind": "literal", + "value": "image/png" + }, + { + "kind": "literal", + "value": "image/webp" + }, + { + "kind": "object", + "properties": [ + { + "name": "height", + "type": 2, + "optional": false + }, + { + "name": "width", + "type": 2, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": true + }, + { + "kind": "literal", + "value": "image" + }, + { + "kind": "object", + "properties": [ + { + "name": "attachment", + "type": 32, + "optional": false + }, + { + "name": "type", + "type": 33, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "attachmentId", + "type": 10, + "optional": false + }, + { + "name": "bytes", + "type": 2, + "optional": false + }, + { + "name": "name", + "type": 10, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "file" + }, + { + "kind": "primitive", + "type": "boolean" + }, + { + "kind": "literal", + "value": "tool" + }, + { + "kind": "object", + "properties": [ + { + "name": "callId", + "type": 10, + "optional": false + }, + { + "name": "kind", + "type": 35, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "content", + "type": 7, + "optional": false + }, + { + "name": "id", + "type": 10, + "optional": false + }, + { + "name": "role", + "type": 38, + "optional": false + }, + { + "name": "source", + "type": 39, + "optional": false, + "compatibility": { + "version": 1, + "policy": "session-source-attribution", + "binding": "session.developer-message.source", + "discriminator": "kind", + "unknownKinds": "preserve", + "attributionKinds": [ + "tmux-context", + "user-question-reply", + "working-directory" + ] + } + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "developer" + }, + { + "kind": "union", + "types": [ + 36, + 40, + 45, + 47, + 49, + 51, + 53, + 55, + 57, + 58, + 60, + 62, + 63, + 65, + 67, + 69, + 71, + 73, + 75, + 77, + 78, + 79, + 80, + 81, + 84, + 87, + 89, + 98, + 101, + 104, + 106, + 108, + 110, + 115, + 117, + 119, + 127, + 128, + 129, + 130, + 131, + 132, + 133, + 134, + 135, + 136, + 137, + 138, + 139, + 140, + 141, + 142, + 143, + 144, + 145, + 146, + 147, + 148, + 149, + 150, + 151, + 152, + 153, + 154, + 155, + 156, + 157, + 158, + 159, + 160, + 161, + 162, + 163, + 164, + 165, + 166, + 167, + 168, + 169, + 170, + 171, + 172, + 173, + 174, + 175, + 176, + 177, + 178, + 179, + 180, + 181, + 182, + 183, + 184, + 185, + 186, + 187, + 188, + 189, + 190, + 191, + 192, + 193, + 194, + 195, + 196, + 198, + 200, + 203, + 204, + 205 + ] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 41, + "optional": false + }, + { + "name": "kind", + "type": 42, + "optional": false + }, + { + "name": "sections", + "type": 43, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "snapshot" + }, + { + "kind": "literal", + "value": "time-context" + }, + { + "kind": "array", + "element": 44 + }, + { + "kind": "object", + "properties": [ + { + "name": "name", + "type": 10, + "optional": false + }, + { + "name": "text", + "type": 10, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 46, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "plan-mode" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 48, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "ptc-mode" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 50, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "repeat-tool-reminder" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 52, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "runtime-context" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 54, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "schedule" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 56, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "system-prompt" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 42, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 59, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "tmux-context" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 61, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "tool-goal" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 41, + "optional": false + }, + { + "name": "kind", + "type": 59, + "optional": false + }, + { + "name": "sections", + "type": 43, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 64, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "tool-jobs" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 66, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "tool-registry" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 68, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "user" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 70, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "user-approval" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 72, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "working-directory" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 74, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "cordis-host-runner" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 76, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "dsh-session-title-llm" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 41, + "optional": false + }, + { + "name": "kind", + "type": 61, + "optional": false + }, + { + "name": "sections", + "type": 43, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 41, + "optional": false + }, + { + "name": "kind", + "type": 64, + "optional": false + }, + { + "name": "sections", + "type": 43, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 41, + "optional": false + }, + { + "name": "kind", + "type": 70, + "optional": false + }, + { + "name": "sections", + "type": 43, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 41, + "optional": false + }, + { + "name": "kind", + "type": 76, + "optional": false + }, + { + "name": "sections", + "type": 43, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 82, + "optional": false + }, + { + "name": "kind", + "type": 83, + "optional": false + }, + { + "name": "senderSessionId", + "type": 10, + "optional": false + }, + { + "name": "summary", + "type": 10, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "notice" + }, + { + "kind": "literal", + "value": "subagent-settled" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 85, + "optional": false + }, + { + "name": "kind", + "type": 86, + "optional": false + }, + { + "name": "senderSessionId", + "type": 10, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "relay" + }, + { + "kind": "literal", + "value": "agent-message" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 82, + "optional": false + }, + { + "name": "kind", + "type": 88, + "optional": false + }, + { + "name": "summary", + "type": 10, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "hooks-claude-code" + }, + { + "kind": "object", + "properties": [ + { + "name": "baseline", + "type": 29, + "optional": true + }, + { + "name": "baselineIdentity", + "type": 10, + "optional": true + }, + { + "name": "changes", + "type": 90, + "optional": false + }, + { + "name": "form", + "type": 96, + "optional": false + }, + { + "name": "kind", + "type": 97, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "array", + "element": 91 + }, + { + "kind": "object", + "properties": [ + { + "name": "action", + "type": 92, + "optional": false + }, + { + "name": "digest", + "type": 10, + "optional": true + }, + { + "name": "path", + "type": 10, + "optional": false + }, + { + "name": "scope", + "type": 10, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "union", + "types": [ + 93, + 94, + 95 + ] + }, + { + "kind": "literal", + "value": "remove" + }, + { + "kind": "literal", + "value": "replace" + }, + { + "kind": "literal", + "value": "set" + }, + { + "kind": "literal", + "value": "instructions" + }, + { + "kind": "literal", + "value": "agent-instructions" + }, + { + "kind": "object", + "properties": [ + { + "name": "callId", + "type": 10, + "optional": false + }, + { + "name": "kind", + "type": 99, + "optional": false + }, + { + "name": "outcome", + "type": 100, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "user-question-reply" + }, + { + "kind": "literal", + "value": "answered" + }, + { + "kind": "object", + "properties": [ + { + "name": "clientTimeZone", + "type": 10, + "optional": true + }, + { + "name": "kind", + "type": 68, + "optional": false + }, + { + "name": "principal", + "type": 102, + "optional": true + }, + { + "name": "rpcId", + "type": 10, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 103, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "owner" + }, + { + "kind": "object", + "properties": [ + { + "name": "compactionId", + "type": 10, + "optional": false + }, + { + "name": "kind", + "type": 105, + "optional": false + }, + { + "name": "sourceCommandId", + "type": 10, + "optional": true + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "compact-checkpoint" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 82, + "optional": false + }, + { + "name": "kind", + "type": 107, + "optional": false + }, + { + "name": "summary", + "type": 10, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "hooks-codex" + }, + { + "kind": "object", + "properties": [ + { + "name": "deliveryId", + "type": 10, + "optional": false + }, + { + "name": "form", + "type": 82, + "optional": false + }, + { + "name": "kind", + "type": 109, + "optional": false + }, + { + "name": "provider", + "type": 10, + "optional": false + }, + { + "name": "ruleId", + "type": 10, + "optional": false + }, + { + "name": "source", + "type": 10, + "optional": false + }, + { + "name": "summary", + "type": 10, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "webhook" + }, + { + "kind": "object", + "properties": [ + { + "name": "entries", + "type": 111, + "optional": false + }, + { + "name": "form", + "type": 113, + "optional": false + }, + { + "name": "kind", + "type": 114, + "optional": false + }, + { + "name": "update", + "type": 29, + "optional": true + } + ], + "indices": [] + }, + { + "kind": "array", + "element": 112 + }, + { + "kind": "object", + "properties": [ + { + "name": "description", + "type": 10, + "optional": false + }, + { + "name": "name", + "type": 10, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "catalog" + }, + { + "kind": "literal", + "value": "skill-catalog" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 82, + "optional": false + }, + { + "name": "kind", + "type": 116, + "optional": false + }, + { + "name": "summary", + "type": 10, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "model-selection" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 96, + "optional": false + }, + { + "name": "kind", + "type": 118, + "optional": false + }, + { + "name": "name", + "type": 10, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "skill-invocation" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 120, + "optional": false + }, + { + "name": "kind", + "type": 121, + "optional": false + }, + { + "name": "references", + "type": 122, + "optional": false + }, + { + "name": "version", + "type": 126, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "recall" + }, + { + "kind": "literal", + "value": "session-reference" + }, + { + "kind": "array", + "element": 123 + }, + { + "kind": "object", + "properties": [ + { + "name": "capturedFormatVersion", + "type": 2, + "optional": true + }, + { + "name": "capturedThroughSeq", + "type": 124, + "optional": false + }, + { + "name": "compacted", + "type": 34, + "optional": false + }, + { + "name": "inputIndex", + "type": 2, + "optional": false + }, + { + "name": "label", + "type": 10, + "optional": false + }, + { + "name": "omittedBytes", + "type": 2, + "optional": false + }, + { + "name": "omittedMessages", + "type": 2, + "optional": false + }, + { + "name": "originalMessages", + "type": 2, + "optional": false + }, + { + "name": "retainedMessages", + "type": 2, + "optional": false + }, + { + "name": "sessionId", + "type": 10, + "optional": false + }, + { + "name": "truncated", + "type": 34, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "union", + "types": [ + 2, + 125 + ] + }, + { + "kind": "primitive", + "type": "null" + }, + { + "kind": "literal", + "value": 1 + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 41, + "optional": false + }, + { + "name": "kind", + "type": 88, + "optional": false + }, + { + "name": "sections", + "type": 43, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 82, + "optional": false + }, + { + "name": "kind", + "type": 46, + "optional": false + }, + { + "name": "summary", + "type": 10, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 82, + "optional": false + }, + { + "name": "kind", + "type": 50, + "optional": false + }, + { + "name": "summary", + "type": 10, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 82, + "optional": false + }, + { + "name": "kind", + "type": 52, + "optional": false + }, + { + "name": "summary", + "type": 10, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 82, + "optional": false + }, + { + "name": "kind", + "type": 54, + "optional": false + }, + { + "name": "summary", + "type": 10, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 82, + "optional": false + }, + { + "name": "kind", + "type": 42, + "optional": false + }, + { + "name": "summary", + "type": 10, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 82, + "optional": false + }, + { + "name": "kind", + "type": 59, + "optional": false + }, + { + "name": "summary", + "type": 10, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 82, + "optional": false + }, + { + "name": "kind", + "type": 61, + "optional": false + }, + { + "name": "summary", + "type": 10, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 82, + "optional": false + }, + { + "name": "kind", + "type": 64, + "optional": false + }, + { + "name": "summary", + "type": 10, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 82, + "optional": false + }, + { + "name": "kind", + "type": 70, + "optional": false + }, + { + "name": "summary", + "type": 10, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 82, + "optional": false + }, + { + "name": "kind", + "type": 76, + "optional": false + }, + { + "name": "summary", + "type": 10, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 96, + "optional": false + }, + { + "name": "kind", + "type": 88, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 96, + "optional": false + }, + { + "name": "kind", + "type": 107, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 96, + "optional": false + }, + { + "name": "kind", + "type": 116, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 96, + "optional": false + }, + { + "name": "kind", + "type": 46, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 96, + "optional": false + }, + { + "name": "kind", + "type": 50, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 96, + "optional": false + }, + { + "name": "kind", + "type": 52, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 41, + "optional": false + }, + { + "name": "kind", + "type": 107, + "optional": false + }, + { + "name": "sections", + "type": 43, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 96, + "optional": false + }, + { + "name": "kind", + "type": 54, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 96, + "optional": false + }, + { + "name": "kind", + "type": 42, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 96, + "optional": false + }, + { + "name": "kind", + "type": 59, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 96, + "optional": false + }, + { + "name": "kind", + "type": 61, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 96, + "optional": false + }, + { + "name": "kind", + "type": 64, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 96, + "optional": false + }, + { + "name": "kind", + "type": 70, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 96, + "optional": false + }, + { + "name": "kind", + "type": 76, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 120, + "optional": false + }, + { + "name": "kind", + "type": 88, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 120, + "optional": false + }, + { + "name": "kind", + "type": 107, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 41, + "optional": false + }, + { + "name": "kind", + "type": 116, + "optional": false + }, + { + "name": "sections", + "type": 43, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 120, + "optional": false + }, + { + "name": "kind", + "type": 116, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 120, + "optional": false + }, + { + "name": "kind", + "type": 46, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 120, + "optional": false + }, + { + "name": "kind", + "type": 50, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 120, + "optional": false + }, + { + "name": "kind", + "type": 52, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 120, + "optional": false + }, + { + "name": "kind", + "type": 54, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 120, + "optional": false + }, + { + "name": "kind", + "type": 42, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 120, + "optional": false + }, + { + "name": "kind", + "type": 59, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 120, + "optional": false + }, + { + "name": "kind", + "type": 61, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 120, + "optional": false + }, + { + "name": "kind", + "type": 64, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 41, + "optional": false + }, + { + "name": "kind", + "type": 46, + "optional": false + }, + { + "name": "sections", + "type": 43, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 120, + "optional": false + }, + { + "name": "kind", + "type": 70, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 120, + "optional": false + }, + { + "name": "kind", + "type": 76, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 85, + "optional": false + }, + { + "name": "kind", + "type": 88, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 85, + "optional": false + }, + { + "name": "kind", + "type": 107, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 85, + "optional": false + }, + { + "name": "kind", + "type": 116, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 85, + "optional": false + }, + { + "name": "kind", + "type": 46, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 85, + "optional": false + }, + { + "name": "kind", + "type": 50, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 85, + "optional": false + }, + { + "name": "kind", + "type": 52, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 85, + "optional": false + }, + { + "name": "kind", + "type": 54, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 41, + "optional": false + }, + { + "name": "kind", + "type": 50, + "optional": false + }, + { + "name": "sections", + "type": 43, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 85, + "optional": false + }, + { + "name": "kind", + "type": 42, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 85, + "optional": false + }, + { + "name": "kind", + "type": 59, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 85, + "optional": false + }, + { + "name": "kind", + "type": 61, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 85, + "optional": false + }, + { + "name": "kind", + "type": 64, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 85, + "optional": false + }, + { + "name": "kind", + "type": 70, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 85, + "optional": false + }, + { + "name": "kind", + "type": 76, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 113, + "optional": false + }, + { + "name": "kind", + "type": 88, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 113, + "optional": false + }, + { + "name": "kind", + "type": 107, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 113, + "optional": false + }, + { + "name": "kind", + "type": 116, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 113, + "optional": false + }, + { + "name": "kind", + "type": 46, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 41, + "optional": false + }, + { + "name": "kind", + "type": 52, + "optional": false + }, + { + "name": "sections", + "type": 43, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 113, + "optional": false + }, + { + "name": "kind", + "type": 50, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 113, + "optional": false + }, + { + "name": "kind", + "type": 52, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 113, + "optional": false + }, + { + "name": "kind", + "type": 54, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 113, + "optional": false + }, + { + "name": "kind", + "type": 42, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 113, + "optional": false + }, + { + "name": "kind", + "type": 59, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 113, + "optional": false + }, + { + "name": "kind", + "type": 61, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 113, + "optional": false + }, + { + "name": "kind", + "type": 64, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 113, + "optional": false + }, + { + "name": "kind", + "type": 70, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 113, + "optional": false + }, + { + "name": "kind", + "type": 76, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 41, + "optional": false + }, + { + "name": "kind", + "type": 54, + "optional": false + }, + { + "name": "sections", + "type": 43, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "goalId", + "type": 10, + "optional": false + }, + { + "name": "kind", + "type": 197, + "optional": false + }, + { + "name": "revision", + "type": 2, + "optional": false + }, + { + "name": "round", + "type": 2, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "goal" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 199, + "optional": false + }, + { + "name": "messageId", + "type": 10, + "optional": false + }, + { + "name": "senderId", + "type": 10, + "optional": false + }, + { + "name": "senderName", + "type": 10, + "optional": false + }, + { + "name": "teamId", + "type": 10, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "team-message" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 201, + "optional": false + }, + { + "name": "model", + "type": 10, + "optional": false + }, + { + "name": "provider", + "type": 10, + "optional": false + }, + { + "name": "replayState", + "type": 202, + "optional": true + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "model" + }, + { + "kind": "opaque", + "reason": "unknown" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 88, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 107, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 116, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "content", + "type": 7, + "optional": false + }, + { + "name": "id", + "type": 10, + "optional": false + }, + { + "name": "role", + "type": 68, + "optional": false + }, + { + "name": "source", + "type": 39, + "optional": false, + "compatibility": { + "version": 1, + "policy": "session-source-attribution", + "binding": "session.user-message.source", + "discriminator": "kind", + "unknownKinds": "preserve", + "attributionKinds": [ + "tmux-context", + "user-question-reply", + "working-directory" + ] + } + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "content", + "type": 7, + "optional": false + }, + { + "name": "id", + "type": 10, + "optional": false + }, + { + "name": "role", + "type": 208, + "optional": false + }, + { + "name": "source", + "type": 200, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "assistant" + }, + { + "kind": "object", + "properties": [ + { + "name": "content", + "type": 7, + "optional": false + }, + { + "name": "id", + "type": 10, + "optional": false + }, + { + "name": "role", + "type": 210, + "optional": false + }, + { + "name": "source", + "type": 55, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "system" + }, + { + "kind": "object", + "properties": [ + { + "name": "model", + "type": 10, + "optional": false + }, + { + "name": "provider", + "type": 10, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "session/title-llm-request" + } + ] + } + }, + { + "key": "event:user/message", + "kind": "event", + "event": "user/message", + "surface": true, + "digest": "2ae8594a998c9862a597129e782277bcc518266712a703b5c3161bdf552d13b8", + "schema": { + "root": 0, + "nodes": [ + { + "kind": "object", + "properties": [ + { + "name": "data", + "type": 1, + "optional": false + }, + { + "name": "ignorable", + "type": 25, + "optional": true + }, + { + "name": "seq", + "type": 18, + "optional": false + }, + { + "name": "sourceEventSeqs", + "type": 200, + "optional": true + }, + { + "name": "surfaceOp", + "type": 201, + "optional": false + }, + { + "name": "time", + "type": 18, + "optional": false + }, + { + "name": "type", + "type": 204, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "content", + "type": 2, + "optional": false + }, + { + "name": "id", + "type": 5, + "optional": false + }, + { + "name": "role", + "type": 30, + "optional": false + }, + { + "name": "source", + "type": 31, + "optional": false, + "compatibility": { + "version": 1, + "policy": "session-source-attribution", + "binding": "session.user-message.source", + "discriminator": "kind", + "unknownKinds": "preserve", + "attributionKinds": [ + "tmux-context", + "user-question-reply", + "working-directory" + ] + } + } + ], + "indices": [] + }, + { + "kind": "array", + "element": 3 + }, + { + "kind": "union", + "types": [ + 4, + 7, + 9, + 12, + 14, + 16, + 27 + ] + }, + { + "kind": "object", + "properties": [ + { + "name": "text", + "type": 5, + "optional": false + }, + { + "name": "type", + "type": 6, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "string" + }, + { + "kind": "literal", + "value": "reasoning" + }, + { + "kind": "object", + "properties": [ + { + "name": "text", + "type": 5, + "optional": false + }, + { + "name": "type", + "type": 8, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "text" + }, + { + "kind": "object", + "properties": [ + { + "name": "tool", + "type": 10, + "optional": true + }, + { + "name": "toolName", + "type": 5, + "optional": false + }, + { + "name": "type", + "type": 11, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "never" + }, + { + "kind": "literal", + "value": "tool-addition" + }, + { + "kind": "object", + "properties": [ + { + "name": "toolName", + "type": 5, + "optional": false + }, + { + "name": "type", + "type": 13, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "tool-removal" + }, + { + "kind": "object", + "properties": [ + { + "name": "arguments", + "type": 5, + "optional": false + }, + { + "name": "id", + "type": 5, + "optional": false + }, + { + "name": "name", + "type": 5, + "optional": false + }, + { + "name": "type", + "type": 15, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "tool-call" + }, + { + "kind": "object", + "properties": [ + { + "name": "attachment", + "type": 17, + "optional": false + }, + { + "name": "offloaded", + "type": 25, + "optional": true + }, + { + "name": "type", + "type": 26, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "attachmentId", + "type": 5, + "optional": false + }, + { + "name": "bytes", + "type": 18, + "optional": false + }, + { + "name": "height", + "type": 18, + "optional": false + }, + { + "name": "mediaType", + "type": 19, + "optional": false + }, + { + "name": "name", + "type": 5, + "optional": true + }, + { + "name": "originalDimensions", + "type": 24, + "optional": true + }, + { + "name": "width", + "type": 18, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "number" + }, + { + "kind": "union", + "types": [ + 20, + 21, + 22, + 23 + ] + }, + { + "kind": "literal", + "value": "image/gif" + }, + { + "kind": "literal", + "value": "image/jpeg" + }, + { + "kind": "literal", + "value": "image/png" + }, + { + "kind": "literal", + "value": "image/webp" + }, + { + "kind": "object", + "properties": [ + { + "name": "height", + "type": 18, + "optional": false + }, + { + "name": "width", + "type": 18, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": true + }, + { + "kind": "literal", + "value": "image" + }, + { + "kind": "object", + "properties": [ + { + "name": "attachment", + "type": 28, + "optional": false + }, + { + "name": "type", + "type": 29, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "attachmentId", + "type": 5, + "optional": false + }, + { + "name": "bytes", + "type": 18, + "optional": false + }, + { + "name": "name", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "file" + }, + { + "kind": "literal", + "value": "user" + }, + { + "kind": "union", + "types": [ + 32, + 35, + 37, + 39, + 41, + 43, + 45, + 47, + 49, + 51, + 53, + 55, + 58, + 60, + 61, + 62, + 63, + 64, + 65, + 66, + 67, + 68, + 69, + 70, + 71, + 73, + 74, + 75, + 76, + 77, + 78, + 79, + 80, + 81, + 82, + 83, + 84, + 85, + 87, + 88, + 89, + 90, + 91, + 92, + 93, + 94, + 95, + 96, + 97, + 98, + 99, + 101, + 102, + 103, + 104, + 105, + 106, + 107, + 108, + 109, + 110, + 111, + 112, + 113, + 115, + 117, + 120, + 121, + 122, + 123, + 124, + 126, + 127, + 128, + 129, + 131, + 132, + 133, + 134, + 135, + 137, + 138, + 139, + 141, + 143, + 144, + 152, + 155, + 157, + 160, + 162, + 164, + 168, + 170, + 178, + 182, + 183, + 184, + 185, + 186, + 187, + 188, + 189, + 190, + 191, + 192, + 193, + 194, + 196, + 198, + 199 + ] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 33, + "optional": false + }, + { + "name": "kind", + "type": 34, + "optional": false + }, + { + "name": "summary", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "notice" + }, + { + "kind": "literal", + "value": "model-selection" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 33, + "optional": false + }, + { + "name": "kind", + "type": 36, + "optional": false + }, + { + "name": "summary", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "plan-mode" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 33, + "optional": false + }, + { + "name": "kind", + "type": 38, + "optional": false + }, + { + "name": "summary", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "repeat-tool-reminder" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 33, + "optional": false + }, + { + "name": "kind", + "type": 40, + "optional": false + }, + { + "name": "summary", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "runtime-context" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 33, + "optional": false + }, + { + "name": "kind", + "type": 42, + "optional": false + }, + { + "name": "summary", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "schedule" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 33, + "optional": false + }, + { + "name": "kind", + "type": 44, + "optional": false + }, + { + "name": "summary", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "time-context" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 33, + "optional": false + }, + { + "name": "kind", + "type": 46, + "optional": false + }, + { + "name": "summary", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "tmux-context" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 33, + "optional": false + }, + { + "name": "kind", + "type": 48, + "optional": false + }, + { + "name": "summary", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "tool-goal" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 33, + "optional": false + }, + { + "name": "kind", + "type": 50, + "optional": false + }, + { + "name": "summary", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "tool-jobs" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 33, + "optional": false + }, + { + "name": "kind", + "type": 52, + "optional": false + }, + { + "name": "summary", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "user-approval" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 33, + "optional": false + }, + { + "name": "kind", + "type": 54, + "optional": false + }, + { + "name": "summary", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "dsh-session-title-llm" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 56, + "optional": false + }, + { + "name": "kind", + "type": 57, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "instructions" + }, + { + "kind": "literal", + "value": "hooks-claude-code" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 56, + "optional": false + }, + { + "name": "kind", + "type": 59, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "hooks-codex" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 56, + "optional": false + }, + { + "name": "kind", + "type": 34, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 56, + "optional": false + }, + { + "name": "kind", + "type": 36, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 56, + "optional": false + }, + { + "name": "kind", + "type": 38, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 56, + "optional": false + }, + { + "name": "kind", + "type": 40, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 56, + "optional": false + }, + { + "name": "kind", + "type": 42, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 56, + "optional": false + }, + { + "name": "kind", + "type": 44, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 56, + "optional": false + }, + { + "name": "kind", + "type": 46, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 56, + "optional": false + }, + { + "name": "kind", + "type": 48, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 56, + "optional": false + }, + { + "name": "kind", + "type": 50, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 56, + "optional": false + }, + { + "name": "kind", + "type": 52, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 56, + "optional": false + }, + { + "name": "kind", + "type": 54, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 72, + "optional": false + }, + { + "name": "kind", + "type": 57, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "recall" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 72, + "optional": false + }, + { + "name": "kind", + "type": 59, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 72, + "optional": false + }, + { + "name": "kind", + "type": 34, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 72, + "optional": false + }, + { + "name": "kind", + "type": 36, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 72, + "optional": false + }, + { + "name": "kind", + "type": 38, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 72, + "optional": false + }, + { + "name": "kind", + "type": 40, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 72, + "optional": false + }, + { + "name": "kind", + "type": 42, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 72, + "optional": false + }, + { + "name": "kind", + "type": 44, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 72, + "optional": false + }, + { + "name": "kind", + "type": 46, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 72, + "optional": false + }, + { + "name": "kind", + "type": 48, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 72, + "optional": false + }, + { + "name": "kind", + "type": 50, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 72, + "optional": false + }, + { + "name": "kind", + "type": 52, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 72, + "optional": false + }, + { + "name": "kind", + "type": 54, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 86, + "optional": false + }, + { + "name": "kind", + "type": 57, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "relay" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 86, + "optional": false + }, + { + "name": "kind", + "type": 59, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 86, + "optional": false + }, + { + "name": "kind", + "type": 34, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 86, + "optional": false + }, + { + "name": "kind", + "type": 36, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 86, + "optional": false + }, + { + "name": "kind", + "type": 38, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 86, + "optional": false + }, + { + "name": "kind", + "type": 40, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 86, + "optional": false + }, + { + "name": "kind", + "type": 42, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 86, + "optional": false + }, + { + "name": "kind", + "type": 44, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 86, + "optional": false + }, + { + "name": "kind", + "type": 46, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 86, + "optional": false + }, + { + "name": "kind", + "type": 48, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 86, + "optional": false + }, + { + "name": "kind", + "type": 50, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 86, + "optional": false + }, + { + "name": "kind", + "type": 52, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 86, + "optional": false + }, + { + "name": "kind", + "type": 54, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 100, + "optional": false + }, + { + "name": "kind", + "type": 57, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "catalog" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 100, + "optional": false + }, + { + "name": "kind", + "type": 59, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 100, + "optional": false + }, + { + "name": "kind", + "type": 34, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 100, + "optional": false + }, + { + "name": "kind", + "type": 36, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 100, + "optional": false + }, + { + "name": "kind", + "type": 38, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 100, + "optional": false + }, + { + "name": "kind", + "type": 40, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 100, + "optional": false + }, + { + "name": "kind", + "type": 42, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 100, + "optional": false + }, + { + "name": "kind", + "type": 44, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 100, + "optional": false + }, + { + "name": "kind", + "type": 46, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 100, + "optional": false + }, + { + "name": "kind", + "type": 48, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 100, + "optional": false + }, + { + "name": "kind", + "type": 50, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 100, + "optional": false + }, + { + "name": "kind", + "type": 52, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 100, + "optional": false + }, + { + "name": "kind", + "type": 54, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "goalId", + "type": 5, + "optional": false + }, + { + "name": "kind", + "type": 114, + "optional": false + }, + { + "name": "revision", + "type": 18, + "optional": false + }, + { + "name": "round", + "type": 18, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "goal" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 116, + "optional": false + }, + { + "name": "messageId", + "type": 5, + "optional": false + }, + { + "name": "senderId", + "type": 5, + "optional": false + }, + { + "name": "senderName", + "type": 5, + "optional": false + }, + { + "name": "teamId", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "team-message" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 118, + "optional": false + }, + { + "name": "model", + "type": 5, + "optional": false + }, + { + "name": "provider", + "type": 5, + "optional": false + }, + { + "name": "replayState", + "type": 119, + "optional": true + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "model" + }, + { + "kind": "opaque", + "reason": "unknown" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 57, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 59, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 34, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 36, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 125, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "ptc-mode" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 38, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 40, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 42, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 130, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "system-prompt" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 44, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 46, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 48, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 50, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 136, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "tool-registry" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 30, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 52, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 140, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "working-directory" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 142, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "cordis-host-runner" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 54, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "baseline", + "type": 25, + "optional": true + }, + { + "name": "baselineIdentity", + "type": 5, + "optional": true + }, + { + "name": "changes", + "type": 145, + "optional": false + }, + { + "name": "form", + "type": 56, + "optional": false + }, + { + "name": "kind", + "type": 151, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "array", + "element": 146 + }, + { + "kind": "object", + "properties": [ + { + "name": "action", + "type": 147, + "optional": false + }, + { + "name": "digest", + "type": 5, + "optional": true + }, + { + "name": "path", + "type": 5, + "optional": false + }, + { + "name": "scope", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "union", + "types": [ + 148, + 149, + 150 + ] + }, + { + "kind": "literal", + "value": "remove" + }, + { + "kind": "literal", + "value": "replace" + }, + { + "kind": "literal", + "value": "set" + }, + { + "kind": "literal", + "value": "agent-instructions" + }, + { + "kind": "object", + "properties": [ + { + "name": "callId", + "type": 5, + "optional": false + }, + { + "name": "kind", + "type": 153, + "optional": false + }, + { + "name": "outcome", + "type": 154, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "user-question-reply" + }, + { + "kind": "literal", + "value": "answered" + }, + { + "kind": "object", + "properties": [ + { + "name": "callId", + "type": 5, + "optional": false + }, + { + "name": "kind", + "type": 156, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "tool" + }, + { + "kind": "object", + "properties": [ + { + "name": "clientTimeZone", + "type": 5, + "optional": true + }, + { + "name": "kind", + "type": 30, + "optional": false + }, + { + "name": "principal", + "type": 158, + "optional": true + }, + { + "name": "rpcId", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 159, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "owner" + }, + { + "kind": "object", + "properties": [ + { + "name": "compactionId", + "type": 5, + "optional": false + }, + { + "name": "kind", + "type": 161, + "optional": false + }, + { + "name": "sourceCommandId", + "type": 5, + "optional": true + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "compact-checkpoint" + }, + { + "kind": "object", + "properties": [ + { + "name": "deliveryId", + "type": 5, + "optional": false + }, + { + "name": "form", + "type": 33, + "optional": false + }, + { + "name": "kind", + "type": 163, + "optional": false + }, + { + "name": "provider", + "type": 5, + "optional": false + }, + { + "name": "ruleId", + "type": 5, + "optional": false + }, + { + "name": "source", + "type": 5, + "optional": false + }, + { + "name": "summary", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "webhook" + }, + { + "kind": "object", + "properties": [ + { + "name": "entries", + "type": 165, + "optional": false + }, + { + "name": "form", + "type": 100, + "optional": false + }, + { + "name": "kind", + "type": 167, + "optional": false + }, + { + "name": "update", + "type": 25, + "optional": true + } + ], + "indices": [] + }, + { + "kind": "array", + "element": 166 + }, + { + "kind": "object", + "properties": [ + { + "name": "description", + "type": 5, + "optional": false + }, + { + "name": "name", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "skill-catalog" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 56, + "optional": false + }, + { + "name": "kind", + "type": 169, + "optional": false + }, + { + "name": "name", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "skill-invocation" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 72, + "optional": false + }, + { + "name": "kind", + "type": 171, + "optional": false + }, + { + "name": "references", + "type": 172, + "optional": false + }, + { + "name": "version", + "type": 177, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "session-reference" + }, + { + "kind": "array", + "element": 173 + }, + { + "kind": "object", + "properties": [ + { + "name": "capturedFormatVersion", + "type": 18, + "optional": true + }, + { + "name": "capturedThroughSeq", + "type": 174, + "optional": false + }, + { + "name": "compacted", + "type": 176, + "optional": false + }, + { + "name": "inputIndex", + "type": 18, + "optional": false + }, + { + "name": "label", + "type": 5, + "optional": false + }, + { + "name": "omittedBytes", + "type": 18, + "optional": false + }, + { + "name": "omittedMessages", + "type": 18, + "optional": false + }, + { + "name": "originalMessages", + "type": 18, + "optional": false + }, + { + "name": "retainedMessages", + "type": 18, + "optional": false + }, + { + "name": "sessionId", + "type": 5, + "optional": false + }, + { + "name": "truncated", + "type": 176, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "union", + "types": [ + 18, + 175 + ] + }, + { + "kind": "primitive", + "type": "null" + }, + { + "kind": "primitive", + "type": "boolean" + }, + { + "kind": "literal", + "value": 1 + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 179, + "optional": false + }, + { + "name": "kind", + "type": 57, + "optional": false + }, + { + "name": "sections", + "type": 180, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "snapshot" + }, + { + "kind": "array", + "element": 181 + }, + { + "kind": "object", + "properties": [ + { + "name": "name", + "type": 5, + "optional": false + }, + { + "name": "text", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 179, + "optional": false + }, + { + "name": "kind", + "type": 59, + "optional": false + }, + { + "name": "sections", + "type": 180, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 179, + "optional": false + }, + { + "name": "kind", + "type": 34, + "optional": false + }, + { + "name": "sections", + "type": 180, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 179, + "optional": false + }, + { + "name": "kind", + "type": 36, + "optional": false + }, + { + "name": "sections", + "type": 180, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 179, + "optional": false + }, + { + "name": "kind", + "type": 38, + "optional": false + }, + { + "name": "sections", + "type": 180, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 179, + "optional": false + }, + { + "name": "kind", + "type": 40, + "optional": false + }, + { + "name": "sections", + "type": 180, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 179, + "optional": false + }, + { + "name": "kind", + "type": 42, + "optional": false + }, + { + "name": "sections", + "type": 180, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 179, + "optional": false + }, + { + "name": "kind", + "type": 44, + "optional": false + }, + { + "name": "sections", + "type": 180, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 179, + "optional": false + }, + { + "name": "kind", + "type": 46, + "optional": false + }, + { + "name": "sections", + "type": 180, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 179, + "optional": false + }, + { + "name": "kind", + "type": 48, + "optional": false + }, + { + "name": "sections", + "type": 180, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 179, + "optional": false + }, + { + "name": "kind", + "type": 50, + "optional": false + }, + { + "name": "sections", + "type": 180, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 179, + "optional": false + }, + { + "name": "kind", + "type": 52, + "optional": false + }, + { + "name": "sections", + "type": 180, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 179, + "optional": false + }, + { + "name": "kind", + "type": 54, + "optional": false + }, + { + "name": "sections", + "type": 180, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 33, + "optional": false + }, + { + "name": "kind", + "type": 195, + "optional": false + }, + { + "name": "senderSessionId", + "type": 5, + "optional": false + }, + { + "name": "summary", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "subagent-settled" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 86, + "optional": false + }, + { + "name": "kind", + "type": 197, + "optional": false + }, + { + "name": "senderSessionId", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "agent-message" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 33, + "optional": false + }, + { + "name": "kind", + "type": 57, + "optional": false + }, + { + "name": "summary", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 33, + "optional": false + }, + { + "name": "kind", + "type": 59, + "optional": false + }, + { + "name": "summary", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "array", + "element": 18 + }, + { + "kind": "union", + "types": [ + 202, + 203 + ] + }, + { + "kind": "literal", + "value": "append" + }, + { + "kind": "object", + "properties": [ + { + "name": "endSeq", + "type": 18, + "optional": false + }, + { + "name": "op", + "type": 149, + "optional": false + }, + { + "name": "startSeq", + "type": 18, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "user/message" + } + ] + } + } + ], + "types": [] +} diff --git a/docs/persistence-changes/2026-10-13-user-rpc-principal.zh.md b/docs/persistence-changes/2026-10-13-user-rpc-principal.zh.md new file mode 100644 index 0000000000..5dca79c486 --- /dev/null +++ b/docs/persistence-changes/2026-10-13-user-rpc-principal.zh.md @@ -0,0 +1,60 @@ +--- +description: "记录持久化类型更改及其兼容性确认。" +kind: persistence-change +--- + +# 2026-10-13-user-rpc-principal + +[English](2026-10-13-user-rpc-principal.md) | 中文 + +## 概述 + +为 owner 以外的调用方提交的 prompt,在 user-rpc 消息来源上增加可选的 principal。 + +## 目录 + +- [声明](#declaration) +- [兼容性](#compatibility) +- [验证](#verification) +- [开发备注](#dev-note) + + +## 声明 + +```yaml persistence-change +schemaVersion: 1 +id: 2026-10-13-user-rpc-principal +baseline: false +changes: + - root: "event:agent/inbox/spliced" + previous: "2026-10-05-working-directory-attribution" + after: "0755b7d1128319ba2e389619cbbf2c961ad648882b4e91da7f3e9582cc8a2034" + decision: same-version + - root: "event:developer/message" + previous: "2026-10-05-working-directory-attribution" + after: "97e0162f983c27456ec71269fb4976b1851588b8c1aeac5d071d43e11f95e03c" + decision: same-version + - root: "event:session/title-llm-request" + previous: "2026-10-07-title-reasoning-effort" + after: "79a4c87f8bfb4d6d05cd9952b99210d6a7db3b478c4a01d94b4c50602308c056" + decision: same-version + - root: "event:user/message" + previous: "2026-10-05-working-directory-attribution" + after: "2ae8594a998c9862a597129e782277bcc518266712a703b5c3161bdf552d13b8" + decision: same-version +``` + + +## 兼容性 + +owner 的 prompt 不写该字段,因此其来源与所有既有 Session 日志都不变;缺少 principal 即为 owner。早于该字段的读取方会忽略它,并因 kind 仍为 'user' 而把消息当作普通用户消息。当前读取方读取旧日志时找不到 principal,把每条消息都视为 owner 的消息。该字段不进入任何模型请求:provider 适配器只读取模型可见的消息内容。SESSION_FORMAT_VERSION 不变。 + + +## 验证 + +pnpm exec vitest run packages/api/session-controller/tests/remote-access.host.spec.ts packages/subagent/subagent/tests/control.spec.ts packages/api/session-controller/tests/session-cold.host.spec.ts:非 owner 的 prompt 在 agent/inbox/spliced 中持久化 source.principal,owner 与既有时区来源保持逐字节不变;全部测试通过。 + + +## 开发备注 + +无。 diff --git a/docs/persistence-changes/historical-formats/README.i18n.yaml b/docs/persistence-changes/historical-formats/README.i18n.yaml index 1835c6fe96..e24f17e991 100644 --- a/docs/persistence-changes/historical-formats/README.i18n.yaml +++ b/docs/persistence-changes/historical-formats/README.i18n.yaml @@ -15,8 +15,8 @@ en: 14c57f30e676f6e3 zh: b1a0ccf26fab3727 /session-persistence-formats/format-references: - en: 73b4caa341293dda - zh: 305a83174f20c790 + en: 43a4a6600fa37a1a + zh: 72174fab8355c344 /session-persistence-formats/scope-and-evidence: en: b220062139ddac1e zh: 7c4c0efe05dafa9c diff --git a/docs/persistence-changes/historical-formats/README.md b/docs/persistence-changes/historical-formats/README.md index 1f0274c047..2bc0daa565 100644 --- a/docs/persistence-changes/historical-formats/README.md +++ b/docs/persistence-changes/historical-formats/README.md @@ -32,7 +32,7 @@ The index is generated from validated snapshots and the current writer constant. | 1 | PR #3349 | [V1](v1.md) | [JSON](v1.schema.json) | 54 / 415 | | 2 | `dsh-v0.1.3-alpha.2` | [V2](v2.md) | [JSON](v2.schema.json) | 56 / 435 | | 3 | PR #4320 | [V3](v3.md) | [JSON](v3.schema.json) | 60 / 467 | -| 4 | Current checkout | [Current catalog](../../persistence-catalog.md) | [JSON](../../persistence-schema.json) | 65 / 610 | +| 4 | Current checkout | [Current catalog](../../persistence-catalog.md) | [JSON](../../persistence-schema.json) | 65 / 612 | diff --git a/docs/persistence-changes/historical-formats/README.zh.md b/docs/persistence-changes/historical-formats/README.zh.md index 1dd81855c5..15b08f318d 100644 --- a/docs/persistence-changes/historical-formats/README.zh.md +++ b/docs/persistence-changes/historical-formats/README.zh.md @@ -32,7 +32,7 @@ description: "查找从 V0 到工作区写入器版本的每个 Session 格式 | 1 | PR #3349 | [V1](v1.zh.md) | [JSON](v1.schema.json) | 54 / 415 | | 2 | `dsh-v0.1.3-alpha.2` | [V2](v2.zh.md) | [JSON](v2.schema.json) | 56 / 435 | | 3 | PR #4320 | [V3](v3.zh.md) | [JSON](v3.schema.json) | 60 / 467 | -| 4 | 当前工作树 | [当前目录](../../persistence-catalog.zh.md) | [JSON](../../persistence-schema.json) | 65 / 610 | +| 4 | 当前工作树 | [当前目录](../../persistence-catalog.zh.md) | [JSON](../../persistence-schema.json) | 65 / 612 | diff --git a/docs/persistence-schema.json b/docs/persistence-schema.json index 97bc1ffe41..04344d9b87 100644 --- a/docs/persistence-schema.json +++ b/docs/persistence-schema.json @@ -406,7 +406,7 @@ "kind": "event", "event": "agent/inbox/spliced", "surface": false, - "digest": "9a36727716d0e73f681f3b725e7f28f89417c68174c8af0ff2b1ef52b08326a0", + "digest": "0755b7d1128319ba2e389619cbbf2c961ad648882b4e91da7f3e9582cc8a2034", "schema": { "root": 0, "nodes": [ @@ -435,7 +435,7 @@ }, { "name": "type", - "type": 204, + "type": 206, "optional": false } ], @@ -451,7 +451,7 @@ }, { "name": "outcome", - "type": 200, + "type": 202, "optional": true }, { @@ -466,7 +466,7 @@ }, { "name": "target", - "type": 201, + "type": 203, "optional": false } ], @@ -818,10 +818,10 @@ "types": [ 34, 39, - 42, + 41, + 43, 45, - 47, - 49, + 48, 51, 53, 55, @@ -833,9 +833,9 @@ 67, 69, 70, + 71, 72, 73, - 74, 75, 76, 77, @@ -846,9 +846,9 @@ 82, 83, 84, + 85, 86, 87, - 88, 89, 90, 91, @@ -872,9 +872,9 @@ 109, 110, 111, + 112, 113, 114, - 115, 116, 117, 118, @@ -885,48 +885,48 @@ 123, 124, 125, + 126, 127, - 129, + 128, + 130, 132, - 133, - 134, 135, 136, + 137, 138, 139, - 140, 141, + 142, 143, 144, - 145, 146, 147, + 148, 149, 150, - 151, + 152, 153, - 155, + 154, 156, - 164, + 158, + 159, 167, - 169, 170, 172, - 174, - 178, - 180, - 188, - 189, - 190, - 191, - 192, + 175, + 177, + 179, + 183, + 185, 193, 194, 195, 196, 197, 198, - 199 + 199, + 200, + 201 ] }, { @@ -956,7 +956,7 @@ }, { "kind": "literal", - "value": "dsh-session-title-llm" + "value": "tool-goal" }, { "kind": "array", @@ -983,12 +983,87 @@ "properties": [ { "name": "form", - "type": 40, + "type": 35, "optional": false }, { "name": "kind", - "type": 41, + "type": 40, + "optional": false + }, + { + "name": "sections", + "type": 37, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "tool-jobs" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 35, + "optional": false + }, + { + "name": "kind", + "type": 42, + "optional": false + }, + { + "name": "sections", + "type": 37, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "user-approval" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 35, + "optional": false + }, + { + "name": "kind", + "type": 44, + "optional": false + }, + { + "name": "sections", + "type": 37, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "dsh-session-title-llm" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 46, + "optional": false + }, + { + "name": "kind", + "type": 47, "optional": false }, { @@ -1017,12 +1092,12 @@ "properties": [ { "name": "form", - "type": 43, + "type": 49, "optional": false }, { "name": "kind", - "type": 44, + "type": 50, "optional": false }, { @@ -1046,12 +1121,12 @@ "properties": [ { "name": "form", - "type": 40, + "type": 46, "optional": false }, { "name": "kind", - "type": 46, + "type": 52, "optional": false }, { @@ -1071,12 +1146,12 @@ "properties": [ { "name": "form", - "type": 40, + "type": 46, "optional": false }, { "name": "kind", - "type": 48, + "type": 54, "optional": false }, { @@ -1096,12 +1171,12 @@ "properties": [ { "name": "form", - "type": 40, + "type": 46, "optional": false }, { "name": "kind", - "type": 50, + "type": 56, "optional": false }, { @@ -1121,12 +1196,12 @@ "properties": [ { "name": "form", - "type": 40, + "type": 46, "optional": false }, { "name": "kind", - "type": 52, + "type": 58, "optional": false }, { @@ -1146,12 +1221,12 @@ "properties": [ { "name": "form", - "type": 40, + "type": 46, "optional": false }, { "name": "kind", - "type": 54, + "type": 60, "optional": false }, { @@ -1171,12 +1246,12 @@ "properties": [ { "name": "form", - "type": 40, + "type": 46, "optional": false }, { "name": "kind", - "type": 56, + "type": 62, "optional": false }, { @@ -1196,12 +1271,12 @@ "properties": [ { "name": "form", - "type": 40, + "type": 46, "optional": false }, { "name": "kind", - "type": 58, + "type": 64, "optional": false }, { @@ -1221,12 +1296,12 @@ "properties": [ { "name": "form", - "type": 40, + "type": 46, "optional": false }, { "name": "kind", - "type": 60, + "type": 66, "optional": false }, { @@ -1246,12 +1321,12 @@ "properties": [ { "name": "form", - "type": 40, + "type": 46, "optional": false }, { "name": "kind", - "type": 62, + "type": 68, "optional": false }, { @@ -1271,82 +1346,7 @@ "properties": [ { "name": "form", - "type": 40, - "optional": false - }, - { - "name": "kind", - "type": 64, - "optional": false - }, - { - "name": "summary", - "type": 7, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "literal", - "value": "tool-goal" - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 40, - "optional": false - }, - { - "name": "kind", - "type": 66, - "optional": false - }, - { - "name": "summary", - "type": 7, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "literal", - "value": "tool-jobs" - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 40, - "optional": false - }, - { - "name": "kind", - "type": 68, - "optional": false - }, - { - "name": "summary", - "type": 7, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "literal", - "value": "user-approval" - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 40, + "type": 46, "optional": false }, { @@ -1367,13 +1367,76 @@ "properties": [ { "name": "form", - "type": 71, + "type": 46, "optional": false }, { "name": "kind", + "type": 40, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", "type": 46, "optional": false + }, + { + "name": "kind", + "type": 42, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 46, + "optional": false + }, + { + "name": "kind", + "type": 44, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 74, + "optional": false + }, + { + "name": "kind", + "type": 52, + "optional": false } ], "indices": [] @@ -1387,55 +1450,7 @@ "properties": [ { "name": "form", - "type": 71, - "optional": false - }, - { - "name": "kind", - "type": 48, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 71, - "optional": false - }, - { - "name": "kind", - "type": 50, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 71, - "optional": false - }, - { - "name": "kind", - "type": 52, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 71, + "type": 74, "optional": false }, { @@ -1451,7 +1466,7 @@ "properties": [ { "name": "form", - "type": 71, + "type": 74, "optional": false }, { @@ -1467,7 +1482,7 @@ "properties": [ { "name": "form", - "type": 71, + "type": 74, "optional": false }, { @@ -1483,7 +1498,7 @@ "properties": [ { "name": "form", - "type": 71, + "type": 74, "optional": false }, { @@ -1499,7 +1514,7 @@ "properties": [ { "name": "form", - "type": 71, + "type": 74, "optional": false }, { @@ -1515,7 +1530,7 @@ "properties": [ { "name": "form", - "type": 71, + "type": 74, "optional": false }, { @@ -1531,7 +1546,7 @@ "properties": [ { "name": "form", - "type": 71, + "type": 74, "optional": false }, { @@ -1547,7 +1562,7 @@ "properties": [ { "name": "form", - "type": 71, + "type": 74, "optional": false }, { @@ -1563,7 +1578,7 @@ "properties": [ { "name": "form", - "type": 71, + "type": 74, "optional": false }, { @@ -1579,12 +1594,60 @@ "properties": [ { "name": "form", - "type": 85, + "type": 74, "optional": false }, { "name": "kind", - "type": 46, + "type": 40, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 74, + "optional": false + }, + { + "name": "kind", + "type": 42, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 74, + "optional": false + }, + { + "name": "kind", + "type": 44, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 88, + "optional": false + }, + { + "name": "kind", + "type": 52, "optional": false } ], @@ -1599,55 +1662,7 @@ "properties": [ { "name": "form", - "type": 85, - "optional": false - }, - { - "name": "kind", - "type": 48, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 85, - "optional": false - }, - { - "name": "kind", - "type": 50, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 85, - "optional": false - }, - { - "name": "kind", - "type": 52, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 85, + "type": 88, "optional": false }, { @@ -1663,7 +1678,7 @@ "properties": [ { "name": "form", - "type": 85, + "type": 88, "optional": false }, { @@ -1679,7 +1694,7 @@ "properties": [ { "name": "form", - "type": 85, + "type": 88, "optional": false }, { @@ -1695,7 +1710,7 @@ "properties": [ { "name": "form", - "type": 85, + "type": 88, "optional": false }, { @@ -1711,7 +1726,7 @@ "properties": [ { "name": "form", - "type": 85, + "type": 88, "optional": false }, { @@ -1727,7 +1742,7 @@ "properties": [ { "name": "form", - "type": 85, + "type": 88, "optional": false }, { @@ -1743,7 +1758,7 @@ "properties": [ { "name": "form", - "type": 85, + "type": 88, "optional": false }, { @@ -1759,7 +1774,7 @@ "properties": [ { "name": "form", - "type": 85, + "type": 88, "optional": false }, { @@ -1775,7 +1790,7 @@ "properties": [ { "name": "form", - "type": 85, + "type": 88, "optional": false }, { @@ -1791,12 +1806,12 @@ "properties": [ { "name": "form", - "type": 43, + "type": 88, "optional": false }, { "name": "kind", - "type": 46, + "type": 40, "optional": false } ], @@ -1807,12 +1822,12 @@ "properties": [ { "name": "form", - "type": 43, + "type": 88, "optional": false }, { "name": "kind", - "type": 48, + "type": 42, "optional": false } ], @@ -1823,12 +1838,12 @@ "properties": [ { "name": "form", - "type": 43, + "type": 88, "optional": false }, { "name": "kind", - "type": 50, + "type": 44, "optional": false } ], @@ -1839,7 +1854,7 @@ "properties": [ { "name": "form", - "type": 43, + "type": 49, "optional": false }, { @@ -1855,7 +1870,7 @@ "properties": [ { "name": "form", - "type": 43, + "type": 49, "optional": false }, { @@ -1871,7 +1886,7 @@ "properties": [ { "name": "form", - "type": 43, + "type": 49, "optional": false }, { @@ -1887,7 +1902,7 @@ "properties": [ { "name": "form", - "type": 43, + "type": 49, "optional": false }, { @@ -1903,7 +1918,7 @@ "properties": [ { "name": "form", - "type": 43, + "type": 49, "optional": false }, { @@ -1919,7 +1934,7 @@ "properties": [ { "name": "form", - "type": 43, + "type": 49, "optional": false }, { @@ -1935,7 +1950,7 @@ "properties": [ { "name": "form", - "type": 43, + "type": 49, "optional": false }, { @@ -1951,7 +1966,7 @@ "properties": [ { "name": "form", - "type": 43, + "type": 49, "optional": false }, { @@ -1967,7 +1982,7 @@ "properties": [ { "name": "form", - "type": 43, + "type": 49, "optional": false }, { @@ -1983,7 +1998,7 @@ "properties": [ { "name": "form", - "type": 43, + "type": 49, "optional": false }, { @@ -1999,12 +2014,60 @@ "properties": [ { "name": "form", - "type": 112, + "type": 49, "optional": false }, { "name": "kind", - "type": 46, + "type": 40, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 49, + "optional": false + }, + { + "name": "kind", + "type": 42, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 49, + "optional": false + }, + { + "name": "kind", + "type": 44, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 115, + "optional": false + }, + { + "name": "kind", + "type": 52, "optional": false } ], @@ -2019,55 +2082,7 @@ "properties": [ { "name": "form", - "type": 112, - "optional": false - }, - { - "name": "kind", - "type": 48, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 112, - "optional": false - }, - { - "name": "kind", - "type": 50, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 112, - "optional": false - }, - { - "name": "kind", - "type": 52, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 112, + "type": 115, "optional": false }, { @@ -2083,7 +2098,7 @@ "properties": [ { "name": "form", - "type": 112, + "type": 115, "optional": false }, { @@ -2099,7 +2114,7 @@ "properties": [ { "name": "form", - "type": 112, + "type": 115, "optional": false }, { @@ -2115,7 +2130,7 @@ "properties": [ { "name": "form", - "type": 112, + "type": 115, "optional": false }, { @@ -2131,7 +2146,7 @@ "properties": [ { "name": "form", - "type": 112, + "type": 115, "optional": false }, { @@ -2147,7 +2162,7 @@ "properties": [ { "name": "form", - "type": 112, + "type": 115, "optional": false }, { @@ -2163,7 +2178,7 @@ "properties": [ { "name": "form", - "type": 112, + "type": 115, "optional": false }, { @@ -2179,7 +2194,7 @@ "properties": [ { "name": "form", - "type": 112, + "type": 115, "optional": false }, { @@ -2195,7 +2210,7 @@ "properties": [ { "name": "form", - "type": 112, + "type": 115, "optional": false }, { @@ -2206,6 +2221,54 @@ ], "indices": [] }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 115, + "optional": false + }, + { + "name": "kind", + "type": 40, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 115, + "optional": false + }, + { + "name": "kind", + "type": 42, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 115, + "optional": false + }, + { + "name": "kind", + "type": 44, + "optional": false + } + ], + "indices": [] + }, { "kind": "object", "properties": [ @@ -2216,7 +2279,7 @@ }, { "name": "kind", - "type": 126, + "type": 129, "optional": false }, { @@ -2241,7 +2304,7 @@ "properties": [ { "name": "kind", - "type": 128, + "type": 131, "optional": false }, { @@ -2276,7 +2339,7 @@ "properties": [ { "name": "kind", - "type": 130, + "type": 133, "optional": false }, { @@ -2291,7 +2354,7 @@ }, { "name": "replayState", - "type": 131, + "type": 134, "optional": true } ], @@ -2305,39 +2368,6 @@ "kind": "opaque", "reason": "unknown" }, - { - "kind": "object", - "properties": [ - { - "name": "kind", - "type": 46, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "kind", - "type": 48, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "kind", - "type": 50, - "optional": false - } - ], - "indices": [] - }, { "kind": "object", "properties": [ @@ -2349,21 +2379,6 @@ ], "indices": [] }, - { - "kind": "object", - "properties": [ - { - "name": "kind", - "type": 137, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "literal", - "value": "ptc-mode" - }, { "kind": "object", "properties": [ @@ -2402,7 +2417,7 @@ "properties": [ { "name": "kind", - "type": 142, + "type": 140, "optional": false } ], @@ -2410,7 +2425,7 @@ }, { "kind": "literal", - "value": "system-prompt" + "value": "ptc-mode" }, { "kind": "object", @@ -2445,6 +2460,21 @@ ], "indices": [] }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 145, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "system-prompt" + }, { "kind": "object", "properties": [ @@ -2461,7 +2491,40 @@ "properties": [ { "name": "kind", - "type": 148, + "type": 68, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 36, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 40, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 151, "optional": false } ], @@ -2487,7 +2550,7 @@ "properties": [ { "name": "kind", - "type": 68, + "type": 42, "optional": false } ], @@ -2498,7 +2561,7 @@ "properties": [ { "name": "kind", - "type": 152, + "type": 155, "optional": false } ], @@ -2513,7 +2576,7 @@ "properties": [ { "name": "kind", - "type": 154, + "type": 157, "optional": false } ], @@ -2528,7 +2591,7 @@ "properties": [ { "name": "kind", - "type": 36, + "type": 44, "optional": false } ], @@ -2549,17 +2612,17 @@ }, { "name": "changes", - "type": 157, + "type": 160, "optional": false }, { "name": "form", - "type": 71, + "type": 74, "optional": false }, { "name": "kind", - "type": 163, + "type": 166, "optional": false } ], @@ -2567,14 +2630,14 @@ }, { "kind": "array", - "element": 158 + "element": 161 }, { "kind": "object", "properties": [ { "name": "action", - "type": 159, + "type": 162, "optional": false }, { @@ -2598,9 +2661,9 @@ { "kind": "union", "types": [ - 160, - 161, - 162 + 163, + 164, + 165 ] }, { @@ -2629,12 +2692,12 @@ }, { "name": "kind", - "type": 165, + "type": 168, "optional": false }, { "name": "outcome", - "type": 166, + "type": 169, "optional": false } ], @@ -2658,7 +2721,7 @@ }, { "name": "kind", - "type": 168, + "type": 171, "optional": false } ], @@ -2681,6 +2744,11 @@ "type": 32, "optional": false }, + { + "name": "principal", + "type": 173, + "optional": true + }, { "name": "rpcId", "type": 7, @@ -2689,6 +2757,21 @@ ], "indices": [] }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 174, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "owner" + }, { "kind": "object", "properties": [ @@ -2699,7 +2782,7 @@ }, { "name": "kind", - "type": 171, + "type": 176, "optional": false }, { @@ -2724,12 +2807,12 @@ }, { "name": "form", - "type": 40, + "type": 46, "optional": false }, { "name": "kind", - "type": 173, + "type": 178, "optional": false }, { @@ -2764,17 +2847,17 @@ "properties": [ { "name": "entries", - "type": 175, + "type": 180, "optional": false }, { "name": "form", - "type": 112, + "type": 115, "optional": false }, { "name": "kind", - "type": 177, + "type": 182, "optional": false }, { @@ -2787,7 +2870,7 @@ }, { "kind": "array", - "element": 176 + "element": 181 }, { "kind": "object", @@ -2814,12 +2897,12 @@ "properties": [ { "name": "form", - "type": 71, + "type": 74, "optional": false }, { "name": "kind", - "type": 179, + "type": 184, "optional": false }, { @@ -2839,22 +2922,22 @@ "properties": [ { "name": "form", - "type": 85, + "type": 88, "optional": false }, { "name": "kind", - "type": 181, + "type": 186, "optional": false }, { "name": "references", - "type": 182, + "type": 187, "optional": false }, { "name": "version", - "type": 187, + "type": 192, "optional": false } ], @@ -2866,7 +2949,7 @@ }, { "kind": "array", - "element": 183 + "element": 188 }, { "kind": "object", @@ -2878,12 +2961,12 @@ }, { "name": "capturedThroughSeq", - "type": 184, + "type": 189, "optional": false }, { "name": "compacted", - "type": 186, + "type": 191, "optional": false }, { @@ -2923,7 +3006,7 @@ }, { "name": "truncated", - "type": 186, + "type": 191, "optional": false } ], @@ -2933,7 +3016,7 @@ "kind": "union", "types": [ 20, - 185 + 190 ] }, { @@ -2948,69 +3031,6 @@ "kind": "literal", "value": 1 }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 35, - "optional": false - }, - { - "name": "kind", - "type": 46, - "optional": false - }, - { - "name": "sections", - "type": 37, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 35, - "optional": false - }, - { - "name": "kind", - "type": 48, - "optional": false - }, - { - "name": "sections", - "type": 37, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 35, - "optional": false - }, - { - "name": "kind", - "type": 50, - "optional": false - }, - { - "name": "sections", - "type": 37, - "optional": false - } - ], - "indices": [] - }, { "kind": "object", "properties": [ @@ -3207,8 +3227,8 @@ { "kind": "union", "types": [ - 202, - 203 + 204, + 205 ] }, { @@ -6571,7 +6591,7 @@ "kind": "event", "event": "developer/message", "surface": true, - "digest": "ba5b10ea77d68f4a29439a9bd2a14f6a560acf55731749d57237757cc72fbc06", + "digest": "97e0162f983c27456ec71269fb4976b1851588b8c1aeac5d071d43e11f95e03c", "schema": { "root": 0, "nodes": [ @@ -6595,12 +6615,12 @@ }, { "name": "sourceEventSeqs", - "type": 200, + "type": 202, "optional": true }, { "name": "surfaceOp", - "type": 201, + "type": 203, "optional": false }, { @@ -6610,7 +6630,7 @@ }, { "name": "type", - "type": 204, + "type": 206, "optional": false } ], @@ -6984,10 +7004,10 @@ "types": [ 33, 38, - 41, + 40, + 42, 44, - 46, - 48, + 47, 50, 52, 54, @@ -6999,9 +7019,9 @@ 66, 68, 69, + 70, 71, 72, - 73, 74, 75, 76, @@ -7012,9 +7032,9 @@ 81, 82, 83, + 84, 85, 86, - 87, 88, 89, 90, @@ -7038,9 +7058,9 @@ 108, 109, 110, + 111, 112, 113, - 114, 115, 116, 117, @@ -7051,48 +7071,48 @@ 122, 123, 124, + 125, 126, - 128, + 127, + 129, 131, - 132, - 133, 134, 135, 136, + 137, 138, 139, - 140, 141, + 142, 143, 144, - 145, 146, 147, + 148, 149, - 151, + 150, 152, 154, - 156, - 164, + 155, + 157, + 159, 167, - 169, 170, 172, - 174, - 178, - 180, - 188, - 189, - 190, - 191, - 192, + 175, + 177, + 179, + 183, + 185, 193, 194, 195, 196, 197, 198, - 199 + 199, + 200, + 201 ] }, { @@ -7122,7 +7142,7 @@ }, { "kind": "literal", - "value": "user-approval" + "value": "tmux-context" }, { "kind": "array", @@ -7149,12 +7169,87 @@ "properties": [ { "name": "form", - "type": 39, + "type": 34, "optional": false }, { "name": "kind", - "type": 40, + "type": 39, + "optional": false + }, + { + "name": "sections", + "type": 36, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "tool-goal" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 34, + "optional": false + }, + { + "name": "kind", + "type": 41, + "optional": false + }, + { + "name": "sections", + "type": 36, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "tool-jobs" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 34, + "optional": false + }, + { + "name": "kind", + "type": 43, + "optional": false + }, + { + "name": "sections", + "type": 36, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "user-approval" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 45, + "optional": false + }, + { + "name": "kind", + "type": 46, "optional": false }, { @@ -7183,12 +7278,12 @@ "properties": [ { "name": "form", - "type": 42, + "type": 48, "optional": false }, { "name": "kind", - "type": 43, + "type": 49, "optional": false }, { @@ -7212,12 +7307,12 @@ "properties": [ { "name": "form", - "type": 39, + "type": 45, "optional": false }, { "name": "kind", - "type": 45, + "type": 51, "optional": false }, { @@ -7237,12 +7332,12 @@ "properties": [ { "name": "form", - "type": 39, + "type": 45, "optional": false }, { "name": "kind", - "type": 47, + "type": 53, "optional": false }, { @@ -7262,12 +7357,12 @@ "properties": [ { "name": "form", - "type": 39, + "type": 45, "optional": false }, { "name": "kind", - "type": 49, + "type": 55, "optional": false }, { @@ -7287,12 +7382,12 @@ "properties": [ { "name": "form", - "type": 39, + "type": 45, "optional": false }, { "name": "kind", - "type": 51, + "type": 57, "optional": false }, { @@ -7312,12 +7407,12 @@ "properties": [ { "name": "form", - "type": 39, + "type": 45, "optional": false }, { "name": "kind", - "type": 53, + "type": 59, "optional": false }, { @@ -7337,12 +7432,12 @@ "properties": [ { "name": "form", - "type": 39, + "type": 45, "optional": false }, { "name": "kind", - "type": 55, + "type": 61, "optional": false }, { @@ -7362,12 +7457,12 @@ "properties": [ { "name": "form", - "type": 39, + "type": 45, "optional": false }, { "name": "kind", - "type": 57, + "type": 63, "optional": false }, { @@ -7387,12 +7482,12 @@ "properties": [ { "name": "form", - "type": 39, + "type": 45, "optional": false }, { "name": "kind", - "type": 59, + "type": 65, "optional": false }, { @@ -7412,12 +7507,12 @@ "properties": [ { "name": "form", - "type": 39, + "type": 45, "optional": false }, { "name": "kind", - "type": 61, + "type": 67, "optional": false }, { @@ -7437,82 +7532,7 @@ "properties": [ { "name": "form", - "type": 39, - "optional": false - }, - { - "name": "kind", - "type": 63, - "optional": false - }, - { - "name": "summary", - "type": 7, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "literal", - "value": "tmux-context" - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 39, - "optional": false - }, - { - "name": "kind", - "type": 65, - "optional": false - }, - { - "name": "summary", - "type": 7, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "literal", - "value": "tool-goal" - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 39, - "optional": false - }, - { - "name": "kind", - "type": 67, - "optional": false - }, - { - "name": "summary", - "type": 7, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "literal", - "value": "tool-jobs" - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 39, + "type": 45, "optional": false }, { @@ -7533,13 +7553,76 @@ "properties": [ { "name": "form", - "type": 70, + "type": 45, "optional": false }, { "name": "kind", + "type": 39, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", "type": 45, "optional": false + }, + { + "name": "kind", + "type": 41, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 45, + "optional": false + }, + { + "name": "kind", + "type": 43, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 73, + "optional": false + }, + { + "name": "kind", + "type": 51, + "optional": false } ], "indices": [] @@ -7553,55 +7636,7 @@ "properties": [ { "name": "form", - "type": 70, - "optional": false - }, - { - "name": "kind", - "type": 47, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 70, - "optional": false - }, - { - "name": "kind", - "type": 49, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 70, - "optional": false - }, - { - "name": "kind", - "type": 51, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 70, + "type": 73, "optional": false }, { @@ -7617,7 +7652,7 @@ "properties": [ { "name": "form", - "type": 70, + "type": 73, "optional": false }, { @@ -7633,7 +7668,7 @@ "properties": [ { "name": "form", - "type": 70, + "type": 73, "optional": false }, { @@ -7649,7 +7684,7 @@ "properties": [ { "name": "form", - "type": 70, + "type": 73, "optional": false }, { @@ -7665,7 +7700,7 @@ "properties": [ { "name": "form", - "type": 70, + "type": 73, "optional": false }, { @@ -7681,7 +7716,7 @@ "properties": [ { "name": "form", - "type": 70, + "type": 73, "optional": false }, { @@ -7697,7 +7732,7 @@ "properties": [ { "name": "form", - "type": 70, + "type": 73, "optional": false }, { @@ -7713,7 +7748,7 @@ "properties": [ { "name": "form", - "type": 70, + "type": 73, "optional": false }, { @@ -7729,7 +7764,7 @@ "properties": [ { "name": "form", - "type": 70, + "type": 73, "optional": false }, { @@ -7745,12 +7780,60 @@ "properties": [ { "name": "form", - "type": 84, + "type": 73, "optional": false }, { "name": "kind", - "type": 45, + "type": 39, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 73, + "optional": false + }, + { + "name": "kind", + "type": 41, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 73, + "optional": false + }, + { + "name": "kind", + "type": 43, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 87, + "optional": false + }, + { + "name": "kind", + "type": 51, "optional": false } ], @@ -7765,55 +7848,7 @@ "properties": [ { "name": "form", - "type": 84, - "optional": false - }, - { - "name": "kind", - "type": 47, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 84, - "optional": false - }, - { - "name": "kind", - "type": 49, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 84, - "optional": false - }, - { - "name": "kind", - "type": 51, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 84, + "type": 87, "optional": false }, { @@ -7829,7 +7864,7 @@ "properties": [ { "name": "form", - "type": 84, + "type": 87, "optional": false }, { @@ -7845,7 +7880,7 @@ "properties": [ { "name": "form", - "type": 84, + "type": 87, "optional": false }, { @@ -7861,7 +7896,7 @@ "properties": [ { "name": "form", - "type": 84, + "type": 87, "optional": false }, { @@ -7877,7 +7912,7 @@ "properties": [ { "name": "form", - "type": 84, + "type": 87, "optional": false }, { @@ -7893,7 +7928,7 @@ "properties": [ { "name": "form", - "type": 84, + "type": 87, "optional": false }, { @@ -7909,7 +7944,7 @@ "properties": [ { "name": "form", - "type": 84, + "type": 87, "optional": false }, { @@ -7925,7 +7960,7 @@ "properties": [ { "name": "form", - "type": 84, + "type": 87, "optional": false }, { @@ -7941,7 +7976,7 @@ "properties": [ { "name": "form", - "type": 84, + "type": 87, "optional": false }, { @@ -7957,12 +7992,12 @@ "properties": [ { "name": "form", - "type": 42, + "type": 87, "optional": false }, { "name": "kind", - "type": 45, + "type": 39, "optional": false } ], @@ -7973,12 +8008,12 @@ "properties": [ { "name": "form", - "type": 42, + "type": 87, "optional": false }, { "name": "kind", - "type": 47, + "type": 41, "optional": false } ], @@ -7989,12 +8024,12 @@ "properties": [ { "name": "form", - "type": 42, + "type": 87, "optional": false }, { "name": "kind", - "type": 49, + "type": 43, "optional": false } ], @@ -8005,7 +8040,7 @@ "properties": [ { "name": "form", - "type": 42, + "type": 48, "optional": false }, { @@ -8021,7 +8056,7 @@ "properties": [ { "name": "form", - "type": 42, + "type": 48, "optional": false }, { @@ -8037,7 +8072,7 @@ "properties": [ { "name": "form", - "type": 42, + "type": 48, "optional": false }, { @@ -8053,7 +8088,7 @@ "properties": [ { "name": "form", - "type": 42, + "type": 48, "optional": false }, { @@ -8069,7 +8104,7 @@ "properties": [ { "name": "form", - "type": 42, + "type": 48, "optional": false }, { @@ -8085,7 +8120,7 @@ "properties": [ { "name": "form", - "type": 42, + "type": 48, "optional": false }, { @@ -8101,7 +8136,7 @@ "properties": [ { "name": "form", - "type": 42, + "type": 48, "optional": false }, { @@ -8117,7 +8152,7 @@ "properties": [ { "name": "form", - "type": 42, + "type": 48, "optional": false }, { @@ -8133,7 +8168,7 @@ "properties": [ { "name": "form", - "type": 42, + "type": 48, "optional": false }, { @@ -8149,7 +8184,7 @@ "properties": [ { "name": "form", - "type": 42, + "type": 48, "optional": false }, { @@ -8165,12 +8200,60 @@ "properties": [ { "name": "form", - "type": 111, + "type": 48, "optional": false }, { "name": "kind", - "type": 45, + "type": 39, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 48, + "optional": false + }, + { + "name": "kind", + "type": 41, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 48, + "optional": false + }, + { + "name": "kind", + "type": 43, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 114, + "optional": false + }, + { + "name": "kind", + "type": 51, "optional": false } ], @@ -8185,55 +8268,7 @@ "properties": [ { "name": "form", - "type": 111, - "optional": false - }, - { - "name": "kind", - "type": 47, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 111, - "optional": false - }, - { - "name": "kind", - "type": 49, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 111, - "optional": false - }, - { - "name": "kind", - "type": 51, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 111, + "type": 114, "optional": false }, { @@ -8249,7 +8284,7 @@ "properties": [ { "name": "form", - "type": 111, + "type": 114, "optional": false }, { @@ -8265,7 +8300,7 @@ "properties": [ { "name": "form", - "type": 111, + "type": 114, "optional": false }, { @@ -8281,7 +8316,7 @@ "properties": [ { "name": "form", - "type": 111, + "type": 114, "optional": false }, { @@ -8297,7 +8332,7 @@ "properties": [ { "name": "form", - "type": 111, + "type": 114, "optional": false }, { @@ -8313,7 +8348,7 @@ "properties": [ { "name": "form", - "type": 111, + "type": 114, "optional": false }, { @@ -8329,7 +8364,7 @@ "properties": [ { "name": "form", - "type": 111, + "type": 114, "optional": false }, { @@ -8345,7 +8380,7 @@ "properties": [ { "name": "form", - "type": 111, + "type": 114, "optional": false }, { @@ -8361,7 +8396,7 @@ "properties": [ { "name": "form", - "type": 111, + "type": 114, "optional": false }, { @@ -8372,6 +8407,54 @@ ], "indices": [] }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 114, + "optional": false + }, + { + "name": "kind", + "type": 39, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 114, + "optional": false + }, + { + "name": "kind", + "type": 41, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 114, + "optional": false + }, + { + "name": "kind", + "type": 43, + "optional": false + } + ], + "indices": [] + }, { "kind": "object", "properties": [ @@ -8382,7 +8465,7 @@ }, { "name": "kind", - "type": 125, + "type": 128, "optional": false }, { @@ -8407,7 +8490,7 @@ "properties": [ { "name": "kind", - "type": 127, + "type": 130, "optional": false }, { @@ -8442,7 +8525,7 @@ "properties": [ { "name": "kind", - "type": 129, + "type": 132, "optional": false }, { @@ -8457,7 +8540,7 @@ }, { "name": "replayState", - "type": 130, + "type": 133, "optional": true } ], @@ -8471,39 +8554,6 @@ "kind": "opaque", "reason": "unknown" }, - { - "kind": "object", - "properties": [ - { - "name": "kind", - "type": 45, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "kind", - "type": 47, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "kind", - "type": 49, - "optional": false - } - ], - "indices": [] - }, { "kind": "object", "properties": [ @@ -8526,21 +8576,6 @@ ], "indices": [] }, - { - "kind": "object", - "properties": [ - { - "name": "kind", - "type": 137, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "literal", - "value": "ptc-mode" - }, { "kind": "object", "properties": [ @@ -8579,7 +8614,7 @@ "properties": [ { "name": "kind", - "type": 142, + "type": 140, "optional": false } ], @@ -8587,7 +8622,7 @@ }, { "kind": "literal", - "value": "system-prompt" + "value": "ptc-mode" }, { "kind": "object", @@ -8622,6 +8657,21 @@ ], "indices": [] }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 145, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "system-prompt" + }, { "kind": "object", "properties": [ @@ -8633,36 +8683,6 @@ ], "indices": [] }, - { - "kind": "object", - "properties": [ - { - "name": "kind", - "type": 148, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "literal", - "value": "tool-registry" - }, - { - "kind": "object", - "properties": [ - { - "name": "kind", - "type": 150, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "literal", - "value": "user" - }, { "kind": "object", "properties": [ @@ -8674,6 +8694,43 @@ ], "indices": [] }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 39, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 41, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 151, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "tool-registry" + }, { "kind": "object", "properties": [ @@ -8685,6 +8742,32 @@ ], "indices": [] }, + { + "kind": "literal", + "value": "user" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 43, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 156, + "optional": false + } + ], + "indices": [] + }, { "kind": "literal", "value": "working-directory" @@ -8694,7 +8777,7 @@ "properties": [ { "name": "kind", - "type": 155, + "type": 158, "optional": false } ], @@ -8719,17 +8802,17 @@ }, { "name": "changes", - "type": 157, + "type": 160, "optional": false }, { "name": "form", - "type": 70, + "type": 73, "optional": false }, { "name": "kind", - "type": 163, + "type": 166, "optional": false } ], @@ -8737,14 +8820,14 @@ }, { "kind": "array", - "element": 158 + "element": 161 }, { "kind": "object", "properties": [ { "name": "action", - "type": 159, + "type": 162, "optional": false }, { @@ -8768,9 +8851,9 @@ { "kind": "union", "types": [ - 160, - 161, - 162 + 163, + 164, + 165 ] }, { @@ -8799,12 +8882,12 @@ }, { "name": "kind", - "type": 165, + "type": 168, "optional": false }, { "name": "outcome", - "type": 166, + "type": 169, "optional": false } ], @@ -8828,7 +8911,7 @@ }, { "name": "kind", - "type": 168, + "type": 171, "optional": false } ], @@ -8848,9 +8931,14 @@ }, { "name": "kind", - "type": 150, + "type": 153, "optional": false }, + { + "name": "principal", + "type": 173, + "optional": true + }, { "name": "rpcId", "type": 7, @@ -8859,6 +8947,21 @@ ], "indices": [] }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 174, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "owner" + }, { "kind": "object", "properties": [ @@ -8869,7 +8972,7 @@ }, { "name": "kind", - "type": 171, + "type": 176, "optional": false }, { @@ -8894,12 +8997,12 @@ }, { "name": "form", - "type": 39, + "type": 45, "optional": false }, { "name": "kind", - "type": 173, + "type": 178, "optional": false }, { @@ -8934,17 +9037,17 @@ "properties": [ { "name": "entries", - "type": 175, + "type": 180, "optional": false }, { "name": "form", - "type": 111, + "type": 114, "optional": false }, { "name": "kind", - "type": 177, + "type": 182, "optional": false }, { @@ -8957,7 +9060,7 @@ }, { "kind": "array", - "element": 176 + "element": 181 }, { "kind": "object", @@ -8984,12 +9087,12 @@ "properties": [ { "name": "form", - "type": 70, + "type": 73, "optional": false }, { "name": "kind", - "type": 179, + "type": 184, "optional": false }, { @@ -9009,22 +9112,22 @@ "properties": [ { "name": "form", - "type": 84, + "type": 87, "optional": false }, { "name": "kind", - "type": 181, + "type": 186, "optional": false }, { "name": "references", - "type": 182, + "type": 187, "optional": false }, { "name": "version", - "type": 187, + "type": 192, "optional": false } ], @@ -9036,7 +9139,7 @@ }, { "kind": "array", - "element": 183 + "element": 188 }, { "kind": "object", @@ -9048,12 +9151,12 @@ }, { "name": "capturedThroughSeq", - "type": 184, + "type": 189, "optional": false }, { "name": "compacted", - "type": 186, + "type": 191, "optional": false }, { @@ -9093,7 +9196,7 @@ }, { "name": "truncated", - "type": 186, + "type": 191, "optional": false } ], @@ -9103,7 +9206,7 @@ "kind": "union", "types": [ 2, - 185 + 190 ] }, { @@ -9118,69 +9221,6 @@ "kind": "literal", "value": 1 }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 34, - "optional": false - }, - { - "name": "kind", - "type": 45, - "optional": false - }, - { - "name": "sections", - "type": 36, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 34, - "optional": false - }, - { - "name": "kind", - "type": 47, - "optional": false - }, - { - "name": "sections", - "type": 36, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 34, - "optional": false - }, - { - "name": "kind", - "type": 49, - "optional": false - }, - { - "name": "sections", - "type": 36, - "optional": false - } - ], - "indices": [] - }, { "kind": "object", "properties": [ @@ -9377,8 +9417,8 @@ { "kind": "union", "types": [ - 202, - 203 + 204, + 205 ] }, { @@ -9395,7 +9435,7 @@ }, { "name": "op", - "type": 161, + "type": 164, "optional": false }, { @@ -12181,7 +12221,7 @@ "kind": "event", "event": "session/title-llm-request", "surface": false, - "digest": "b33545b3244861bcbf73b407d24b53402710f5acacab7ae939537e5504a68dc6", + "digest": "79a4c87f8bfb4d6d05cd9952b99210d6a7db3b478c4a01d94b4c50602308c056", "schema": { "root": 0, "nodes": [ @@ -12210,7 +12250,7 @@ }, { "name": "type", - "type": 210, + "type": 212, "optional": false } ], @@ -12241,7 +12281,7 @@ }, { "name": "route", - "type": 209, + "type": 211, "optional": false }, { @@ -12274,9 +12314,9 @@ "types": [ 6, 37, - 204, - 205, - 207 + 206, + 207, + 209 ] }, { @@ -12683,7 +12723,7 @@ 51, 53, 55, - 56, + 57, 58, 60, 62, @@ -12692,28 +12732,25 @@ 67, 69, 71, - 72, 73, - 76, + 75, + 77, + 78, 79, + 80, 81, - 83, - 92, - 95, - 97, + 84, + 87, + 89, 98, - 100, - 102, + 101, 104, - 109, - 111, + 106, + 108, + 110, + 115, + 117, 119, - 120, - 121, - 123, - 124, - 125, - 126, 127, 128, 129, @@ -12777,15 +12814,18 @@ 187, 188, 189, + 190, 191, 192, + 193, 194, - 197, + 195, + 196, 198, - 199, 200, - 201, - 203 + 203, + 204, + 205 ] }, { @@ -12815,7 +12855,7 @@ }, { "kind": "literal", - "value": "tool-goal" + "value": "time-context" }, { "kind": "array", @@ -12850,7 +12890,7 @@ }, { "kind": "literal", - "value": "runtime-context" + "value": "plan-mode" }, { "kind": "object", @@ -12865,7 +12905,7 @@ }, { "kind": "literal", - "value": "schedule" + "value": "ptc-mode" }, { "kind": "object", @@ -12880,7 +12920,7 @@ }, { "kind": "literal", - "value": "system-prompt" + "value": "repeat-tool-reminder" }, { "kind": "object", @@ -12895,7 +12935,7 @@ }, { "kind": "literal", - "value": "time-context" + "value": "runtime-context" }, { "kind": "object", @@ -12910,7 +12950,22 @@ }, { "kind": "literal", - "value": "tmux-context" + "value": "schedule" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 56, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "system-prompt" }, { "kind": "object", @@ -12923,21 +12978,6 @@ ], "indices": [] }, - { - "kind": "object", - "properties": [ - { - "name": "kind", - "type": 57, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "literal", - "value": "tool-jobs" - }, { "kind": "object", "properties": [ @@ -12951,7 +12991,7 @@ }, { "kind": "literal", - "value": "tool-registry" + "value": "tmux-context" }, { "kind": "object", @@ -12966,7 +13006,7 @@ }, { "kind": "literal", - "value": "user" + "value": "tool-goal" }, { "kind": "object", @@ -12978,7 +13018,7 @@ }, { "name": "kind", - "type": 57, + "type": 59, "optional": false }, { @@ -13002,7 +13042,7 @@ }, { "kind": "literal", - "value": "user-approval" + "value": "tool-jobs" }, { "kind": "object", @@ -13017,7 +13057,7 @@ }, { "kind": "literal", - "value": "working-directory" + "value": "tool-registry" }, { "kind": "object", @@ -13032,7 +13072,7 @@ }, { "kind": "literal", - "value": "cordis-host-runner" + "value": "user" }, { "kind": "object", @@ -13045,10 +13085,76 @@ ], "indices": [] }, + { + "kind": "literal", + "value": "user-approval" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 72, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "working-directory" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 74, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "cordis-host-runner" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 76, + "optional": false + } + ], + "indices": [] + }, { "kind": "literal", "value": "dsh-session-title-llm" }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 41, + "optional": false + }, + { + "name": "kind", + "type": 61, + "optional": false + }, + { + "name": "sections", + "type": 43, + "optional": false + } + ], + "indices": [] + }, { "kind": "object", "properties": [ @@ -13096,12 +13202,33 @@ "properties": [ { "name": "form", - "type": 74, + "type": 41, "optional": false }, { "name": "kind", - "type": 75, + "type": 76, + "optional": false + }, + { + "name": "sections", + "type": 43, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 82, + "optional": false + }, + { + "name": "kind", + "type": 83, "optional": false }, { @@ -13130,12 +13257,12 @@ "properties": [ { "name": "form", - "type": 77, + "type": 85, "optional": false }, { "name": "kind", - "type": 78, + "type": 86, "optional": false }, { @@ -13159,12 +13286,12 @@ "properties": [ { "name": "form", - "type": 74, + "type": 82, "optional": false }, { "name": "kind", - "type": 80, + "type": 88, "optional": false }, { @@ -13179,31 +13306,6 @@ "kind": "literal", "value": "hooks-claude-code" }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 74, - "optional": false - }, - { - "name": "kind", - "type": 82, - "optional": false - }, - { - "name": "summary", - "type": 10, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "literal", - "value": "hooks-codex" - }, { "kind": "object", "properties": [ @@ -13219,17 +13321,17 @@ }, { "name": "changes", - "type": 84, - "optional": false - }, - { - "name": "form", "type": 90, "optional": false }, + { + "name": "form", + "type": 96, + "optional": false + }, { "name": "kind", - "type": 91, + "type": 97, "optional": false } ], @@ -13237,14 +13339,14 @@ }, { "kind": "array", - "element": 85 + "element": 91 }, { "kind": "object", "properties": [ { "name": "action", - "type": 86, + "type": 92, "optional": false }, { @@ -13268,9 +13370,9 @@ { "kind": "union", "types": [ - 87, - 88, - 89 + 93, + 94, + 95 ] }, { @@ -13303,12 +13405,12 @@ }, { "name": "kind", - "type": 93, + "type": 99, "optional": false }, { "name": "outcome", - "type": 94, + "type": 100, "optional": false } ], @@ -13322,31 +13424,6 @@ "kind": "literal", "value": "answered" }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 74, - "optional": false - }, - { - "name": "kind", - "type": 96, - "optional": false - }, - { - "name": "summary", - "type": 10, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "literal", - "value": "model-selection" - }, { "kind": "object", "properties": [ @@ -13357,9 +13434,14 @@ }, { "name": "kind", - "type": 61, + "type": 68, "optional": false }, + { + "name": "principal", + "type": 102, + "optional": true + }, { "name": "rpcId", "type": 10, @@ -13368,6 +13450,21 @@ ], "indices": [] }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 103, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "owner" + }, { "kind": "object", "properties": [ @@ -13378,7 +13475,7 @@ }, { "name": "kind", - "type": 99, + "type": 105, "optional": false }, { @@ -13398,12 +13495,12 @@ "properties": [ { "name": "form", - "type": 74, + "type": 82, "optional": false }, { "name": "kind", - "type": 101, + "type": 107, "optional": false }, { @@ -13416,7 +13513,7 @@ }, { "kind": "literal", - "value": "plan-mode" + "value": "hooks-codex" }, { "kind": "object", @@ -13428,12 +13525,12 @@ }, { "name": "form", - "type": 74, + "type": 82, "optional": false }, { "name": "kind", - "type": 103, + "type": 109, "optional": false }, { @@ -13468,17 +13565,17 @@ "properties": [ { "name": "entries", - "type": 105, + "type": 111, "optional": false }, { "name": "form", - "type": 107, + "type": 113, "optional": false }, { "name": "kind", - "type": 108, + "type": 114, "optional": false }, { @@ -13491,7 +13588,7 @@ }, { "kind": "array", - "element": 106 + "element": 112 }, { "kind": "object", @@ -13522,12 +13619,37 @@ "properties": [ { "name": "form", - "type": 90, + "type": 82, "optional": false }, { "name": "kind", - "type": 110, + "type": 116, + "optional": false + }, + { + "name": "summary", + "type": 10, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "model-selection" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 96, + "optional": false + }, + { + "name": "kind", + "type": 118, "optional": false }, { @@ -13547,22 +13669,22 @@ "properties": [ { "name": "form", - "type": 112, + "type": 120, "optional": false }, { "name": "kind", - "type": 113, + "type": 121, "optional": false }, { "name": "references", - "type": 114, + "type": 122, "optional": false }, { "name": "version", - "type": 118, + "type": 126, "optional": false } ], @@ -13578,7 +13700,7 @@ }, { "kind": "array", - "element": 115 + "element": 123 }, { "kind": "object", @@ -13590,7 +13712,7 @@ }, { "name": "capturedThroughSeq", - "type": 116, + "type": 124, "optional": false }, { @@ -13645,7 +13767,7 @@ "kind": "union", "types": [ 2, - 117 + 125 ] }, { @@ -13666,7 +13788,7 @@ }, { "name": "kind", - "type": 80, + "type": 88, "optional": false }, { @@ -13682,76 +13804,9 @@ "properties": [ { "name": "form", - "type": 41, - "optional": false - }, - { - "name": "kind", "type": 82, "optional": false }, - { - "name": "sections", - "type": 43, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 74, - "optional": false - }, - { - "name": "kind", - "type": 122, - "optional": false - }, - { - "name": "summary", - "type": 10, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "literal", - "value": "repeat-tool-reminder" - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 41, - "optional": false - }, - { - "name": "kind", - "type": 96, - "optional": false - }, - { - "name": "sections", - "type": 43, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 74, - "optional": false - }, { "name": "kind", "type": 46, @@ -13770,12 +13825,12 @@ "properties": [ { "name": "form", - "type": 74, + "type": 82, "optional": false }, { "name": "kind", - "type": 48, + "type": 50, "optional": false }, { @@ -13791,7 +13846,7 @@ "properties": [ { "name": "form", - "type": 74, + "type": 82, "optional": false }, { @@ -13812,7 +13867,7 @@ "properties": [ { "name": "form", - "type": 74, + "type": 82, "optional": false }, { @@ -13833,7 +13888,7 @@ "properties": [ { "name": "form", - "type": 74, + "type": 82, "optional": false }, { @@ -13854,12 +13909,12 @@ "properties": [ { "name": "form", - "type": 74, + "type": 82, "optional": false }, { "name": "kind", - "type": 57, + "type": 59, "optional": false }, { @@ -13875,7 +13930,28 @@ "properties": [ { "name": "form", - "type": 74, + "type": 82, + "optional": false + }, + { + "name": "kind", + "type": 61, + "optional": false + }, + { + "name": "summary", + "type": 10, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 82, "optional": false }, { @@ -13896,7 +13972,7 @@ "properties": [ { "name": "form", - "type": 74, + "type": 82, "optional": false }, { @@ -13917,29 +13993,18 @@ "properties": [ { "name": "form", - "type": 90, - "optional": false - }, - { - "name": "kind", - "type": 80, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 90, - "optional": false - }, - { - "name": "kind", "type": 82, "optional": false + }, + { + "name": "kind", + "type": 76, + "optional": false + }, + { + "name": "summary", + "type": 10, + "optional": false } ], "indices": [] @@ -13949,13 +14014,13 @@ "properties": [ { "name": "form", - "type": 90, - "optional": false - }, - { - "name": "kind", "type": 96, "optional": false + }, + { + "name": "kind", + "type": 88, + "optional": false } ], "indices": [] @@ -13965,12 +14030,12 @@ "properties": [ { "name": "form", - "type": 90, + "type": 96, "optional": false }, { "name": "kind", - "type": 101, + "type": 107, "optional": false } ], @@ -13981,12 +14046,12 @@ "properties": [ { "name": "form", - "type": 90, + "type": 96, "optional": false }, { "name": "kind", - "type": 122, + "type": 116, "optional": false } ], @@ -13997,7 +14062,7 @@ "properties": [ { "name": "form", - "type": 90, + "type": 96, "optional": false }, { @@ -14013,12 +14078,12 @@ "properties": [ { "name": "form", - "type": 90, + "type": 96, "optional": false }, { "name": "kind", - "type": 48, + "type": 50, "optional": false } ], @@ -14029,7 +14094,7 @@ "properties": [ { "name": "form", - "type": 90, + "type": 96, "optional": false }, { @@ -14050,7 +14115,7 @@ }, { "name": "kind", - "type": 101, + "type": 107, "optional": false }, { @@ -14066,7 +14131,7 @@ "properties": [ { "name": "form", - "type": 90, + "type": 96, "optional": false }, { @@ -14082,7 +14147,7 @@ "properties": [ { "name": "form", - "type": 90, + "type": 96, "optional": false }, { @@ -14098,12 +14163,12 @@ "properties": [ { "name": "form", - "type": 90, + "type": 96, "optional": false }, { "name": "kind", - "type": 57, + "type": 59, "optional": false } ], @@ -14114,7 +14179,23 @@ "properties": [ { "name": "form", - "type": 90, + "type": 96, + "optional": false + }, + { + "name": "kind", + "type": 61, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 96, "optional": false }, { @@ -14130,7 +14211,7 @@ "properties": [ { "name": "form", - "type": 90, + "type": 96, "optional": false }, { @@ -14146,45 +14227,13 @@ "properties": [ { "name": "form", - "type": 112, - "optional": false - }, - { - "name": "kind", - "type": 80, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 112, - "optional": false - }, - { - "name": "kind", - "type": 82, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 112, - "optional": false - }, - { - "name": "kind", "type": 96, "optional": false + }, + { + "name": "kind", + "type": 76, + "optional": false } ], "indices": [] @@ -14194,12 +14243,28 @@ "properties": [ { "name": "form", - "type": 112, + "type": 120, "optional": false }, { "name": "kind", - "type": 101, + "type": 88, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 120, + "optional": false + }, + { + "name": "kind", + "type": 107, "optional": false } ], @@ -14215,7 +14280,7 @@ }, { "name": "kind", - "type": 122, + "type": 116, "optional": false }, { @@ -14231,12 +14296,12 @@ "properties": [ { "name": "form", - "type": 112, + "type": 120, "optional": false }, { "name": "kind", - "type": 122, + "type": 116, "optional": false } ], @@ -14247,7 +14312,7 @@ "properties": [ { "name": "form", - "type": 112, + "type": 120, "optional": false }, { @@ -14263,12 +14328,12 @@ "properties": [ { "name": "form", - "type": 112, + "type": 120, "optional": false }, { "name": "kind", - "type": 48, + "type": 50, "optional": false } ], @@ -14279,7 +14344,7 @@ "properties": [ { "name": "form", - "type": 112, + "type": 120, "optional": false }, { @@ -14295,7 +14360,7 @@ "properties": [ { "name": "form", - "type": 112, + "type": 120, "optional": false }, { @@ -14311,7 +14376,7 @@ "properties": [ { "name": "form", - "type": 112, + "type": 120, "optional": false }, { @@ -14327,12 +14392,12 @@ "properties": [ { "name": "form", - "type": 112, + "type": 120, "optional": false }, { "name": "kind", - "type": 57, + "type": 59, "optional": false } ], @@ -14343,7 +14408,23 @@ "properties": [ { "name": "form", - "type": 112, + "type": 120, + "optional": false + }, + { + "name": "kind", + "type": 61, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 120, "optional": false }, { @@ -14354,22 +14435,6 @@ ], "indices": [] }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 112, - "optional": false - }, - { - "name": "kind", - "type": 70, - "optional": false - } - ], - "indices": [] - }, { "kind": "object", "properties": [ @@ -14396,12 +14461,12 @@ "properties": [ { "name": "form", - "type": 77, + "type": 120, "optional": false }, { "name": "kind", - "type": 80, + "type": 70, "optional": false } ], @@ -14412,12 +14477,12 @@ "properties": [ { "name": "form", - "type": 77, + "type": 120, "optional": false }, { "name": "kind", - "type": 82, + "type": 76, "optional": false } ], @@ -14428,12 +14493,12 @@ "properties": [ { "name": "form", - "type": 77, + "type": 85, "optional": false }, { "name": "kind", - "type": 96, + "type": 88, "optional": false } ], @@ -14444,12 +14509,12 @@ "properties": [ { "name": "form", - "type": 77, + "type": 85, "optional": false }, { "name": "kind", - "type": 101, + "type": 107, "optional": false } ], @@ -14460,12 +14525,12 @@ "properties": [ { "name": "form", - "type": 77, + "type": 85, "optional": false }, { "name": "kind", - "type": 122, + "type": 116, "optional": false } ], @@ -14476,7 +14541,7 @@ "properties": [ { "name": "form", - "type": 77, + "type": 85, "optional": false }, { @@ -14492,12 +14557,12 @@ "properties": [ { "name": "form", - "type": 77, + "type": 85, "optional": false }, { "name": "kind", - "type": 48, + "type": 50, "optional": false } ], @@ -14508,7 +14573,7 @@ "properties": [ { "name": "form", - "type": 77, + "type": 85, "optional": false }, { @@ -14524,7 +14589,7 @@ "properties": [ { "name": "form", - "type": 77, + "type": 85, "optional": false }, { @@ -14545,7 +14610,7 @@ }, { "name": "kind", - "type": 48, + "type": 50, "optional": false }, { @@ -14561,7 +14626,7 @@ "properties": [ { "name": "form", - "type": 77, + "type": 85, "optional": false }, { @@ -14577,12 +14642,12 @@ "properties": [ { "name": "form", - "type": 77, + "type": 85, "optional": false }, { "name": "kind", - "type": 57, + "type": 59, "optional": false } ], @@ -14593,7 +14658,23 @@ "properties": [ { "name": "form", - "type": 77, + "type": 85, + "optional": false + }, + { + "name": "kind", + "type": 61, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 85, "optional": false }, { @@ -14609,7 +14690,7 @@ "properties": [ { "name": "form", - "type": 77, + "type": 85, "optional": false }, { @@ -14625,12 +14706,12 @@ "properties": [ { "name": "form", - "type": 107, + "type": 85, "optional": false }, { "name": "kind", - "type": 80, + "type": 76, "optional": false } ], @@ -14641,12 +14722,12 @@ "properties": [ { "name": "form", - "type": 107, + "type": 113, "optional": false }, { "name": "kind", - "type": 82, + "type": 88, "optional": false } ], @@ -14657,12 +14738,12 @@ "properties": [ { "name": "form", - "type": 107, + "type": 113, "optional": false }, { "name": "kind", - "type": 96, + "type": 107, "optional": false } ], @@ -14673,12 +14754,12 @@ "properties": [ { "name": "form", - "type": 107, + "type": 113, "optional": false }, { "name": "kind", - "type": 101, + "type": 116, "optional": false } ], @@ -14689,23 +14770,7 @@ "properties": [ { "name": "form", - "type": 107, - "optional": false - }, - { - "name": "kind", - "type": 122, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 107, + "type": 113, "optional": false }, { @@ -14742,12 +14807,12 @@ "properties": [ { "name": "form", - "type": 107, + "type": 113, "optional": false }, { "name": "kind", - "type": 48, + "type": 50, "optional": false } ], @@ -14758,7 +14823,7 @@ "properties": [ { "name": "form", - "type": 107, + "type": 113, "optional": false }, { @@ -14774,7 +14839,7 @@ "properties": [ { "name": "form", - "type": 107, + "type": 113, "optional": false }, { @@ -14790,7 +14855,7 @@ "properties": [ { "name": "form", - "type": 107, + "type": 113, "optional": false }, { @@ -14806,12 +14871,12 @@ "properties": [ { "name": "form", - "type": 107, + "type": 113, "optional": false }, { "name": "kind", - "type": 57, + "type": 59, "optional": false } ], @@ -14822,7 +14887,23 @@ "properties": [ { "name": "form", - "type": 107, + "type": 113, + "optional": false + }, + { + "name": "kind", + "type": 61, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 113, "optional": false }, { @@ -14838,7 +14919,7 @@ "properties": [ { "name": "form", - "type": 107, + "type": 113, "optional": false }, { @@ -14849,6 +14930,43 @@ ], "indices": [] }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 113, + "optional": false + }, + { + "name": "kind", + "type": 76, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 41, + "optional": false + }, + { + "name": "kind", + "type": 54, + "optional": false + }, + { + "name": "sections", + "type": 43, + "optional": false + } + ], + "indices": [] + }, { "kind": "object", "properties": [ @@ -14859,7 +14977,7 @@ }, { "name": "kind", - "type": 190, + "type": 197, "optional": false }, { @@ -14882,30 +15000,9 @@ { "kind": "object", "properties": [ - { - "name": "form", - "type": 41, - "optional": false - }, { "name": "kind", - "type": 54, - "optional": false - }, - { - "name": "sections", - "type": 43, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "kind", - "type": 193, + "type": 199, "optional": false }, { @@ -14940,7 +15037,7 @@ "properties": [ { "name": "kind", - "type": 195, + "type": 201, "optional": false }, { @@ -14955,7 +15052,7 @@ }, { "name": "replayState", - "type": 196, + "type": 202, "optional": true } ], @@ -14974,7 +15071,7 @@ "properties": [ { "name": "kind", - "type": 80, + "type": 88, "optional": false } ], @@ -14985,7 +15082,7 @@ "properties": [ { "name": "kind", - "type": 82, + "type": 107, "optional": false } ], @@ -14996,44 +15093,7 @@ "properties": [ { "name": "kind", - "type": 96, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "kind", - "type": 101, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "kind", - "type": 202, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "literal", - "value": "ptc-mode" - }, - { - "kind": "object", - "properties": [ - { - "name": "kind", - "type": 122, + "type": 116, "optional": false } ], @@ -15054,7 +15114,7 @@ }, { "name": "role", - "type": 61, + "type": 68, "optional": false }, { @@ -15092,12 +15152,12 @@ }, { "name": "role", - "type": 206, + "type": 208, "optional": false }, { "name": "source", - "type": 194, + "type": 200, "optional": false } ], @@ -15122,12 +15182,12 @@ }, { "name": "role", - "type": 208, + "type": 210, "optional": false }, { "name": "source", - "type": 49, + "type": 55, "optional": false } ], @@ -19148,7 +19208,7 @@ "kind": "event", "event": "user/message", "surface": true, - "digest": "2ace3b8059b82b6d9837fed518ca97a99eb37c4482df7aad0e71c85489428ade", + "digest": "2ae8594a998c9862a597129e782277bcc518266712a703b5c3161bdf552d13b8", "schema": { "root": 0, "nodes": [ @@ -19172,12 +19232,12 @@ }, { "name": "sourceEventSeqs", - "type": 198, + "type": 200, "optional": true }, { "name": "surfaceOp", - "type": 199, + "type": 201, "optional": false }, { @@ -19187,7 +19247,7 @@ }, { "name": "type", - "type": 202, + "type": 204, "optional": false } ], @@ -19544,7 +19604,7 @@ 49, 51, 53, - 56, + 55, 58, 60, 61, @@ -19557,7 +19617,7 @@ 68, 69, 70, - 72, + 71, 73, 74, 75, @@ -19570,7 +19630,7 @@ 82, 83, 84, - 86, + 85, 87, 88, 89, @@ -19583,7 +19643,7 @@ 96, 97, 98, - 100, + 99, 101, 102, 103, @@ -19596,40 +19656,38 @@ 110, 111, 112, - 114, - 116, - 119, + 113, + 115, + 117, 120, 121, 122, 123, - 125, + 124, 126, 127, 128, - 130, + 129, 131, 132, 133, 134, - 136, + 135, 137, 138, - 140, - 142, + 139, + 141, 143, - 151, - 154, - 156, + 144, + 152, + 155, 157, - 159, - 161, - 165, - 167, - 175, - 179, - 180, - 181, + 160, + 162, + 164, + 168, + 170, + 178, 182, 183, 184, @@ -19640,10 +19698,12 @@ 189, 190, 191, + 192, 193, - 195, + 194, 196, - 197 + 198, + 199 ] }, { @@ -19673,7 +19733,7 @@ }, { "kind": "literal", - "value": "plan-mode" + "value": "model-selection" }, { "kind": "object", @@ -19696,6 +19756,31 @@ ], "indices": [] }, + { + "kind": "literal", + "value": "plan-mode" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 33, + "optional": false + }, + { + "name": "kind", + "type": 38, + "optional": false + }, + { + "name": "summary", + "type": 5, + "optional": false + } + ], + "indices": [] + }, { "kind": "literal", "value": "repeat-tool-reminder" @@ -19710,7 +19795,7 @@ }, { "name": "kind", - "type": 38, + "type": 40, "optional": false }, { @@ -19735,7 +19820,7 @@ }, { "name": "kind", - "type": 40, + "type": 42, "optional": false }, { @@ -19760,7 +19845,7 @@ }, { "name": "kind", - "type": 42, + "type": 44, "optional": false }, { @@ -19785,7 +19870,7 @@ }, { "name": "kind", - "type": 44, + "type": 46, "optional": false }, { @@ -19810,7 +19895,7 @@ }, { "name": "kind", - "type": 46, + "type": 48, "optional": false }, { @@ -19835,7 +19920,7 @@ }, { "name": "kind", - "type": 48, + "type": 50, "optional": false }, { @@ -19860,7 +19945,7 @@ }, { "name": "kind", - "type": 50, + "type": 52, "optional": false }, { @@ -19885,7 +19970,7 @@ }, { "name": "kind", - "type": 52, + "type": 54, "optional": false }, { @@ -19905,12 +19990,12 @@ "properties": [ { "name": "form", - "type": 54, + "type": 56, "optional": false }, { "name": "kind", - "type": 55, + "type": 57, "optional": false } ], @@ -19929,12 +20014,12 @@ "properties": [ { "name": "form", - "type": 54, + "type": 56, "optional": false }, { "name": "kind", - "type": 57, + "type": 59, "optional": false } ], @@ -19949,27 +20034,7 @@ "properties": [ { "name": "form", - "type": 54, - "optional": false - }, - { - "name": "kind", - "type": 59, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "literal", - "value": "model-selection" - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 54, + "type": 56, "optional": false }, { @@ -19985,7 +20050,7 @@ "properties": [ { "name": "form", - "type": 54, + "type": 56, "optional": false }, { @@ -20001,7 +20066,7 @@ "properties": [ { "name": "form", - "type": 54, + "type": 56, "optional": false }, { @@ -20017,7 +20082,7 @@ "properties": [ { "name": "form", - "type": 54, + "type": 56, "optional": false }, { @@ -20033,7 +20098,7 @@ "properties": [ { "name": "form", - "type": 54, + "type": 56, "optional": false }, { @@ -20049,7 +20114,7 @@ "properties": [ { "name": "form", - "type": 54, + "type": 56, "optional": false }, { @@ -20065,7 +20130,7 @@ "properties": [ { "name": "form", - "type": 54, + "type": 56, "optional": false }, { @@ -20081,7 +20146,7 @@ "properties": [ { "name": "form", - "type": 54, + "type": 56, "optional": false }, { @@ -20097,7 +20162,7 @@ "properties": [ { "name": "form", - "type": 54, + "type": 56, "optional": false }, { @@ -20113,7 +20178,7 @@ "properties": [ { "name": "form", - "type": 54, + "type": 56, "optional": false }, { @@ -20129,12 +20194,28 @@ "properties": [ { "name": "form", - "type": 71, + "type": 56, "optional": false }, { "name": "kind", - "type": 55, + "type": 54, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 72, + "optional": false + }, + { + "name": "kind", + "type": 57, "optional": false } ], @@ -20149,23 +20230,7 @@ "properties": [ { "name": "form", - "type": 71, - "optional": false - }, - { - "name": "kind", - "type": 57, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 71, + "type": 72, "optional": false }, { @@ -20181,7 +20246,7 @@ "properties": [ { "name": "form", - "type": 71, + "type": 72, "optional": false }, { @@ -20197,7 +20262,7 @@ "properties": [ { "name": "form", - "type": 71, + "type": 72, "optional": false }, { @@ -20213,7 +20278,7 @@ "properties": [ { "name": "form", - "type": 71, + "type": 72, "optional": false }, { @@ -20229,7 +20294,7 @@ "properties": [ { "name": "form", - "type": 71, + "type": 72, "optional": false }, { @@ -20245,7 +20310,7 @@ "properties": [ { "name": "form", - "type": 71, + "type": 72, "optional": false }, { @@ -20261,7 +20326,7 @@ "properties": [ { "name": "form", - "type": 71, + "type": 72, "optional": false }, { @@ -20277,7 +20342,7 @@ "properties": [ { "name": "form", - "type": 71, + "type": 72, "optional": false }, { @@ -20293,7 +20358,7 @@ "properties": [ { "name": "form", - "type": 71, + "type": 72, "optional": false }, { @@ -20309,7 +20374,7 @@ "properties": [ { "name": "form", - "type": 71, + "type": 72, "optional": false }, { @@ -20325,7 +20390,7 @@ "properties": [ { "name": "form", - "type": 71, + "type": 72, "optional": false }, { @@ -20341,12 +20406,28 @@ "properties": [ { "name": "form", - "type": 85, + "type": 72, "optional": false }, { "name": "kind", - "type": 55, + "type": 54, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 86, + "optional": false + }, + { + "name": "kind", + "type": 57, "optional": false } ], @@ -20361,23 +20442,7 @@ "properties": [ { "name": "form", - "type": 85, - "optional": false - }, - { - "name": "kind", - "type": 57, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 85, + "type": 86, "optional": false }, { @@ -20393,7 +20458,7 @@ "properties": [ { "name": "form", - "type": 85, + "type": 86, "optional": false }, { @@ -20409,7 +20474,7 @@ "properties": [ { "name": "form", - "type": 85, + "type": 86, "optional": false }, { @@ -20425,7 +20490,7 @@ "properties": [ { "name": "form", - "type": 85, + "type": 86, "optional": false }, { @@ -20441,7 +20506,7 @@ "properties": [ { "name": "form", - "type": 85, + "type": 86, "optional": false }, { @@ -20457,7 +20522,7 @@ "properties": [ { "name": "form", - "type": 85, + "type": 86, "optional": false }, { @@ -20473,7 +20538,7 @@ "properties": [ { "name": "form", - "type": 85, + "type": 86, "optional": false }, { @@ -20489,7 +20554,7 @@ "properties": [ { "name": "form", - "type": 85, + "type": 86, "optional": false }, { @@ -20505,7 +20570,7 @@ "properties": [ { "name": "form", - "type": 85, + "type": 86, "optional": false }, { @@ -20521,7 +20586,7 @@ "properties": [ { "name": "form", - "type": 85, + "type": 86, "optional": false }, { @@ -20537,7 +20602,7 @@ "properties": [ { "name": "form", - "type": 85, + "type": 86, "optional": false }, { @@ -20553,12 +20618,28 @@ "properties": [ { "name": "form", - "type": 99, + "type": 86, "optional": false }, { "name": "kind", - "type": 55, + "type": 54, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 100, + "optional": false + }, + { + "name": "kind", + "type": 57, "optional": false } ], @@ -20573,23 +20654,7 @@ "properties": [ { "name": "form", - "type": 99, - "optional": false - }, - { - "name": "kind", - "type": 57, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 99, + "type": 100, "optional": false }, { @@ -20605,7 +20670,7 @@ "properties": [ { "name": "form", - "type": 99, + "type": 100, "optional": false }, { @@ -20621,7 +20686,7 @@ "properties": [ { "name": "form", - "type": 99, + "type": 100, "optional": false }, { @@ -20637,7 +20702,7 @@ "properties": [ { "name": "form", - "type": 99, + "type": 100, "optional": false }, { @@ -20653,7 +20718,7 @@ "properties": [ { "name": "form", - "type": 99, + "type": 100, "optional": false }, { @@ -20669,7 +20734,7 @@ "properties": [ { "name": "form", - "type": 99, + "type": 100, "optional": false }, { @@ -20685,7 +20750,7 @@ "properties": [ { "name": "form", - "type": 99, + "type": 100, "optional": false }, { @@ -20701,7 +20766,7 @@ "properties": [ { "name": "form", - "type": 99, + "type": 100, "optional": false }, { @@ -20717,7 +20782,7 @@ "properties": [ { "name": "form", - "type": 99, + "type": 100, "optional": false }, { @@ -20733,7 +20798,7 @@ "properties": [ { "name": "form", - "type": 99, + "type": 100, "optional": false }, { @@ -20749,7 +20814,7 @@ "properties": [ { "name": "form", - "type": 99, + "type": 100, "optional": false }, { @@ -20760,6 +20825,22 @@ ], "indices": [] }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 100, + "optional": false + }, + { + "name": "kind", + "type": 54, + "optional": false + } + ], + "indices": [] + }, { "kind": "object", "properties": [ @@ -20770,7 +20851,7 @@ }, { "name": "kind", - "type": 113, + "type": 114, "optional": false }, { @@ -20795,7 +20876,7 @@ "properties": [ { "name": "kind", - "type": 115, + "type": 116, "optional": false }, { @@ -20830,7 +20911,7 @@ "properties": [ { "name": "kind", - "type": 117, + "type": 118, "optional": false }, { @@ -20845,7 +20926,7 @@ }, { "name": "replayState", - "type": 118, + "type": 119, "optional": true } ], @@ -20859,17 +20940,6 @@ "kind": "opaque", "reason": "unknown" }, - { - "kind": "object", - "properties": [ - { - "name": "kind", - "type": 55, - "optional": false - } - ], - "indices": [] - }, { "kind": "object", "properties": [ @@ -20908,7 +20978,18 @@ "properties": [ { "name": "kind", - "type": 124, + "type": 36, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 125, "optional": false } ], @@ -20918,17 +20999,6 @@ "kind": "literal", "value": "ptc-mode" }, - { - "kind": "object", - "properties": [ - { - "name": "kind", - "type": 36, - "optional": false - } - ], - "indices": [] - }, { "kind": "object", "properties": [ @@ -20956,7 +21026,18 @@ "properties": [ { "name": "kind", - "type": 129, + "type": 42, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 130, "optional": false } ], @@ -20966,17 +21047,6 @@ "kind": "literal", "value": "system-prompt" }, - { - "kind": "object", - "properties": [ - { - "name": "kind", - "type": 42, - "optional": false - } - ], - "indices": [] - }, { "kind": "object", "properties": [ @@ -21015,7 +21085,18 @@ "properties": [ { "name": "kind", - "type": 135, + "type": 50, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 136, "optional": false } ], @@ -21041,7 +21122,7 @@ "properties": [ { "name": "kind", - "type": 50, + "type": 52, "optional": false } ], @@ -21052,7 +21133,7 @@ "properties": [ { "name": "kind", - "type": 139, + "type": 140, "optional": false } ], @@ -21067,7 +21148,7 @@ "properties": [ { "name": "kind", - "type": 141, + "type": 142, "optional": false } ], @@ -21082,7 +21163,7 @@ "properties": [ { "name": "kind", - "type": 52, + "type": 54, "optional": false } ], @@ -21103,17 +21184,17 @@ }, { "name": "changes", - "type": 144, + "type": 145, "optional": false }, { "name": "form", - "type": 54, + "type": 56, "optional": false }, { "name": "kind", - "type": 150, + "type": 151, "optional": false } ], @@ -21121,14 +21202,14 @@ }, { "kind": "array", - "element": 145 + "element": 146 }, { "kind": "object", "properties": [ { "name": "action", - "type": 146, + "type": 147, "optional": false }, { @@ -21152,9 +21233,9 @@ { "kind": "union", "types": [ - 147, 148, - 149 + 149, + 150 ] }, { @@ -21183,12 +21264,12 @@ }, { "name": "kind", - "type": 152, + "type": 153, "optional": false }, { "name": "outcome", - "type": 153, + "type": 154, "optional": false } ], @@ -21212,7 +21293,7 @@ }, { "name": "kind", - "type": 155, + "type": 156, "optional": false } ], @@ -21235,6 +21316,11 @@ "type": 30, "optional": false }, + { + "name": "principal", + "type": 158, + "optional": true + }, { "name": "rpcId", "type": 5, @@ -21243,6 +21329,21 @@ ], "indices": [] }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 159, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "owner" + }, { "kind": "object", "properties": [ @@ -21253,7 +21354,7 @@ }, { "name": "kind", - "type": 158, + "type": 161, "optional": false }, { @@ -21283,7 +21384,7 @@ }, { "name": "kind", - "type": 160, + "type": 163, "optional": false }, { @@ -21318,17 +21419,17 @@ "properties": [ { "name": "entries", - "type": 162, + "type": 165, "optional": false }, { "name": "form", - "type": 99, + "type": 100, "optional": false }, { "name": "kind", - "type": 164, + "type": 167, "optional": false }, { @@ -21341,7 +21442,7 @@ }, { "kind": "array", - "element": 163 + "element": 166 }, { "kind": "object", @@ -21368,12 +21469,12 @@ "properties": [ { "name": "form", - "type": 54, + "type": 56, "optional": false }, { "name": "kind", - "type": 166, + "type": 169, "optional": false }, { @@ -21393,22 +21494,22 @@ "properties": [ { "name": "form", - "type": 71, + "type": 72, "optional": false }, { "name": "kind", - "type": 168, + "type": 171, "optional": false }, { "name": "references", - "type": 169, + "type": 172, "optional": false }, { "name": "version", - "type": 174, + "type": 177, "optional": false } ], @@ -21420,7 +21521,7 @@ }, { "kind": "array", - "element": 170 + "element": 173 }, { "kind": "object", @@ -21432,12 +21533,12 @@ }, { "name": "capturedThroughSeq", - "type": 171, + "type": 174, "optional": false }, { "name": "compacted", - "type": 173, + "type": 176, "optional": false }, { @@ -21477,7 +21578,7 @@ }, { "name": "truncated", - "type": 173, + "type": 176, "optional": false } ], @@ -21487,7 +21588,7 @@ "kind": "union", "types": [ 18, - 172 + 175 ] }, { @@ -21507,17 +21608,17 @@ "properties": [ { "name": "form", - "type": 176, + "type": 179, "optional": false }, { "name": "kind", - "type": 55, + "type": 57, "optional": false }, { "name": "sections", - "type": 177, + "type": 180, "optional": false } ], @@ -21529,7 +21630,7 @@ }, { "kind": "array", - "element": 178 + "element": 181 }, { "kind": "object", @@ -21552,28 +21653,7 @@ "properties": [ { "name": "form", - "type": 176, - "optional": false - }, - { - "name": "kind", - "type": 57, - "optional": false - }, - { - "name": "sections", - "type": 177, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 176, + "type": 179, "optional": false }, { @@ -21583,7 +21663,7 @@ }, { "name": "sections", - "type": 177, + "type": 180, "optional": false } ], @@ -21594,7 +21674,7 @@ "properties": [ { "name": "form", - "type": 176, + "type": 179, "optional": false }, { @@ -21604,7 +21684,7 @@ }, { "name": "sections", - "type": 177, + "type": 180, "optional": false } ], @@ -21615,7 +21695,7 @@ "properties": [ { "name": "form", - "type": 176, + "type": 179, "optional": false }, { @@ -21625,7 +21705,7 @@ }, { "name": "sections", - "type": 177, + "type": 180, "optional": false } ], @@ -21636,7 +21716,7 @@ "properties": [ { "name": "form", - "type": 176, + "type": 179, "optional": false }, { @@ -21646,7 +21726,7 @@ }, { "name": "sections", - "type": 177, + "type": 180, "optional": false } ], @@ -21657,7 +21737,7 @@ "properties": [ { "name": "form", - "type": 176, + "type": 179, "optional": false }, { @@ -21667,7 +21747,7 @@ }, { "name": "sections", - "type": 177, + "type": 180, "optional": false } ], @@ -21678,7 +21758,7 @@ "properties": [ { "name": "form", - "type": 176, + "type": 179, "optional": false }, { @@ -21688,7 +21768,7 @@ }, { "name": "sections", - "type": 177, + "type": 180, "optional": false } ], @@ -21699,7 +21779,7 @@ "properties": [ { "name": "form", - "type": 176, + "type": 179, "optional": false }, { @@ -21709,7 +21789,7 @@ }, { "name": "sections", - "type": 177, + "type": 180, "optional": false } ], @@ -21720,7 +21800,7 @@ "properties": [ { "name": "form", - "type": 176, + "type": 179, "optional": false }, { @@ -21730,7 +21810,7 @@ }, { "name": "sections", - "type": 177, + "type": 180, "optional": false } ], @@ -21741,7 +21821,7 @@ "properties": [ { "name": "form", - "type": 176, + "type": 179, "optional": false }, { @@ -21751,7 +21831,7 @@ }, { "name": "sections", - "type": 177, + "type": 180, "optional": false } ], @@ -21762,7 +21842,7 @@ "properties": [ { "name": "form", - "type": 176, + "type": 179, "optional": false }, { @@ -21772,7 +21852,7 @@ }, { "name": "sections", - "type": 177, + "type": 180, "optional": false } ], @@ -21783,7 +21863,7 @@ "properties": [ { "name": "form", - "type": 176, + "type": 179, "optional": false }, { @@ -21793,7 +21873,28 @@ }, { "name": "sections", - "type": 177, + "type": 180, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 179, + "optional": false + }, + { + "name": "kind", + "type": 54, + "optional": false + }, + { + "name": "sections", + "type": 180, "optional": false } ], @@ -21809,7 +21910,7 @@ }, { "name": "kind", - "type": 192, + "type": 195, "optional": false }, { @@ -21834,12 +21935,12 @@ "properties": [ { "name": "form", - "type": 85, + "type": 86, "optional": false }, { "name": "kind", - "type": 194, + "type": 197, "optional": false }, { @@ -21854,27 +21955,6 @@ "kind": "literal", "value": "agent-message" }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 33, - "optional": false - }, - { - "name": "kind", - "type": 55, - "optional": false - }, - { - "name": "summary", - "type": 5, - "optional": false - } - ], - "indices": [] - }, { "kind": "object", "properties": [ @@ -21924,8 +22004,8 @@ { "kind": "union", "types": [ - 200, - 201 + 202, + 203 ] }, { @@ -21942,7 +22022,7 @@ }, { "name": "op", - "type": 148, + "type": 149, "optional": false }, { @@ -22303,6 +22383,15 @@ } ], "types": [ + { + "digest": "00c054d3018c198d92f459563331edd1385e58345c677bb499791df2da0ade9f", + "names": [ + "packages/session/session-title-llm/src/index.ts#SessionTitleLlmRequestEventData" + ], + "sources": [ + "packages/session/session-title-llm/src/index.ts:37" + ] + }, { "digest": "0116b1f89c05221d56b0ba35c83d302bf4857bc7277d247e9bbaf74b27d2c66e", "names": [], @@ -22322,7 +22411,7 @@ "digest": "023a355b2be0d688fb6e4df8388dadb942761d46cfe96d0f499aae9631a159d4", "names": [], "sources": [ - "packages/core/session/src/types.ts:206" + "packages/core/session/src/types.ts:207" ] }, { @@ -22338,12 +22427,12 @@ "sources": [] }, { - "digest": "045926475a46f625f84ae2be292b384817059f16927c69a9adab93717923ac19", + "digest": "040cd65423bd64264d225504f1c572d239eeb1793d0c2856ad8e42bb6e1e148b", "names": [ - "packages/session/session-title-llm/src/index.ts#SessionTitleLlmRequestEventData" + "packages/llm/llm/src/message.ts#Message" ], "sources": [ - "packages/session/session-title-llm/src/index.ts:37" + "packages/llm/llm/src/message.ts:196" ] }, { @@ -22366,9 +22455,14 @@ "packages/core/session/src/types.ts#SessionSeq" ], "sources": [ - "packages/core/session/src/types.ts:32" + "packages/core/session/src/types.ts:33" ] }, + { + "digest": "0755b7d1128319ba2e389619cbbf2c961ad648882b4e91da7f3e9582cc8a2034", + "names": [], + "sources": [] + }, { "digest": "07e1a58c58b593f507b8e7f25723bb0aa42baae6e00f5ecfddf28cb1e64d3235", "names": [ @@ -22403,7 +22497,7 @@ "digest": "0a30c3be336cdd4f8f34e66543a3d46d611c43986edd9b3ce6cbb9b721d3b5d3", "names": [], "sources": [ - "packages/core/session/src/types.ts:204" + "packages/core/session/src/types.ts:205" ] }, { @@ -22499,6 +22593,15 @@ "packages/attachment/attachment/src/types.ts:28" ] }, + { + "digest": "0f6265188dae7ba6faa26830a180bb4deb0760561f97ee0c9d399ccf6c9cf1f7", + "names": [ + "packages/llm/llm/src/message.ts#DeveloperMessage" + ], + "sources": [ + "packages/llm/llm/src/message.ts:157" + ] + }, { "digest": "0f8512903d94f57a4748fa1a2092e64342856796684e6b8343db685b192745ce", "names": [], @@ -22618,7 +22721,7 @@ "digest": "17d1afb770d9941936130996da00dc86782cfef731d8d6526162c301256a4ac3", "names": [], "sources": [ - "packages/core/session/src/types.ts:427" + "packages/core/session/src/types.ts:428" ] }, { @@ -22675,7 +22778,7 @@ "packages/core/session/src/types.ts#SessionHeader" ], "sources": [ - "packages/core/session/src/types.ts:94" + "packages/core/session/src/types.ts:95" ] }, { @@ -22709,7 +22812,7 @@ "digest": "1ccb56061942f5ccb0d30c9e4f2883b9d115ff906560029de637b61d3cde896d", "names": [], "sources": [ - "packages/core/session/src/types.ts:341" + "packages/core/session/src/types.ts:342" ] }, { @@ -22788,16 +22891,7 @@ "packages/core/session/src/types.ts#TurnEndCancelCause" ], "sources": [ - "packages/core/session/src/types.ts:196" - ] - }, - { - "digest": "1fa156c85450ca31063d0e0d2853028e6dac9687f2d3191fa811648210118154", - "names": [ - "packages/llm/llm/src/message.ts#MessageSource" - ], - "sources": [ - "packages/llm/llm/src/message.ts:136" + "packages/core/session/src/types.ts:197" ] }, { @@ -22952,7 +23046,7 @@ ] }, { - "digest": "2ace3b8059b82b6d9837fed518ca97a99eb37c4482df7aad0e71c85489428ade", + "digest": "2ae8594a998c9862a597129e782277bcc518266712a703b5c3161bdf552d13b8", "names": [], "sources": [] }, @@ -22971,6 +23065,15 @@ "names": [], "sources": [] }, + { + "digest": "2cf075b013d363ad743b5a4982900678d108499c721bc11255137402003db711", + "names": [ + "packages/llm/llm/src/message.ts#UserMessage" + ], + "sources": [ + "packages/llm/llm/src/message.ts:162" + ] + }, { "digest": "2d11ca7b0d4493e244b74eba093227866b33249841e59a1e9bf56afed38604c3", "names": [], @@ -23029,11 +23132,6 @@ "packages/core/tools/src/types.ts:12" ] }, - { - "digest": "2fef90def36aa758ca096c5f6ad0e00dbb2101943e19ca54b05618801974894a", - "names": [], - "sources": [] - }, { "digest": "30d5a537b296685770b1241c4b4bbde19d5a31ff81aa23df73fab5e90b8fc65f", "names": [ @@ -23086,7 +23184,7 @@ "packages/core/session/src/types.ts#SurfaceOp" ], "sources": [ - "packages/core/session/src/types.ts:462" + "packages/core/session/src/types.ts:463" ] }, { @@ -23187,7 +23285,7 @@ "packages/core/session/src/types.ts#OptionalSessionSeq" ], "sources": [ - "packages/core/session/src/types.ts:65" + "packages/core/session/src/types.ts:66" ] }, { @@ -23310,6 +23408,11 @@ "names": [], "sources": [] }, + { + "digest": "454a0fa879e38076bdcdbd87ef561cd67d7e43e58a287cf7dbaa7b6cf7847020", + "names": [], + "sources": [] + }, { "digest": "45f2a07ea94573123b6ae14db787a40062dec3834f36d8be92c523d40ba28448", "names": [], @@ -23411,14 +23514,9 @@ "digest": "4bb451330ff699f36b45539ceb0eb2606ead72084e2c5bb637c36cc80e2252db", "names": [], "sources": [ - "packages/core/session/src/types.ts:193" + "packages/core/session/src/types.ts:194" ] }, - { - "digest": "4be8fd28fefa5534675ea9aabd0a24efc2838b989bbc74da20a1efbd0daede8c", - "names": [], - "sources": [] - }, { "digest": "4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b", "names": [ @@ -23448,10 +23546,10 @@ "packages/workflow/workflow/src/types.ts#WorkflowRunId" ], "sources": [ - "packages/api/session-controller/src/types.ts:408", + "packages/api/session-controller/src/types.ts:409", "packages/attachment/attachment/src/brand.ts:6", "packages/compaction/compaction/src/brand.ts:4", - "packages/core/session/src/types.ts:20", + "packages/core/session/src/types.ts:21", "packages/experimental/agent-team/src/types.ts:21", "packages/experimental/agent-team/src/types.ts:33", "packages/experimental/agent-team/src/types.ts:9", @@ -23570,7 +23668,7 @@ "digest": "5302e763151277614b97e77f00e3b326694c6c1247b916a20503c344dc970777", "names": [], "sources": [ - "packages/core/session/src/types.ts:375" + "packages/core/session/src/types.ts:376" ] }, { @@ -23579,7 +23677,7 @@ "packages/core/session/src/types.ts#RequestHeaderReason" ], "sources": [ - "packages/core/session/src/types.ts:273" + "packages/core/session/src/types.ts:274" ] }, { @@ -23596,7 +23694,7 @@ "digest": "54f9276a1617d7c12594722c89cfe864bcbe4517e9a6f9c98bab14f199a135e0", "names": [], "sources": [ - "packages/core/session/src/types.ts:355" + "packages/core/session/src/types.ts:356" ] }, { @@ -23613,7 +23711,7 @@ "digest": "56b7c047694875a843ba020dfea3c8380d54ae4ff88ab349e72004dc21e07c23", "names": [], "sources": [ - "packages/core/session/src/types.ts:221" + "packages/core/session/src/types.ts:222" ] }, { @@ -23675,13 +23773,6 @@ "names": [], "sources": [] }, - { - "digest": "5b55817a403569fbb2d379496df2e3f3f71107f6c577a218028ce187c60b61e6", - "names": [], - "sources": [ - "packages/core/session/src/types.ts:311" - ] - }, { "digest": "5b6fb1f226ff56402db08a6aae82af76100b14056271875f69ec526a1ae05d51", "names": [], @@ -23694,6 +23785,15 @@ "names": [], "sources": [] }, + { + "digest": "5bc2d7f02503fc6b3e1bb7e0b7fa22c0b9e4b9841fa3175b2ad8e607ae3525a8", + "names": [ + "packages/llm/llm/src/message.ts#MessageSource" + ], + "sources": [ + "packages/llm/llm/src/message.ts:136" + ] + }, { "digest": "5bc66269cfaf614c0ecda8ecb469f23101a7609793fc2b693ac23c4aecdb575e", "names": [], @@ -23904,13 +24004,6 @@ "names": [], "sources": [] }, - { - "digest": "6a4f72e2e179e17b922f2a9392c0e1c8f8c707f32494372454850a3eb184a6e7", - "names": [], - "sources": [ - "packages/api/session-controller/src/types.ts:413" - ] - }, { "digest": "6ab45ac76839722df912f3ba50f1c6b41f2d614bde11f1aabfae04117ccdc80d", "names": [], @@ -23934,14 +24027,14 @@ "digest": "6bc8e51124d0874bc89e1d0ceff381a9c3cd0fc240667d69f841ce4f60f65fe5", "names": [], "sources": [ - "packages/core/session/src/types.ts:330" + "packages/core/session/src/types.ts:331" ] }, { "digest": "6c03546143cdf6ae88f1745ac98c5139efa1588d1415249bbc5f3104d32ab800", "names": [], "sources": [ - "packages/core/session/src/types.ts:191" + "packages/core/session/src/types.ts:192" ] }, { @@ -23953,7 +24046,7 @@ "digest": "6c5a37d42ebdb12db4410699259335f1e149e3a0260425684c00b153afc8990d", "names": [], "sources": [ - "packages/core/session/src/types.ts:192" + "packages/core/session/src/types.ts:193" ] }, { @@ -23971,6 +24064,13 @@ "names": [], "sources": [] }, + { + "digest": "6dacb1584f2bfbe209dc6b6f578d51da6d55595f64d0e63c727c98f359278def", + "names": [], + "sources": [ + "packages/api/session-controller/src/types.ts:418" + ] + }, { "digest": "6e52dd55a1d48954ed5464ec46fe34d9eb65f709baabc9561e7a279910b0c406", "names": [], @@ -24105,9 +24205,14 @@ "digest": "79650cfa10aa3c52440328d85e8a25f0ffadc8d7efd784e3c0819d8bb74a1985", "names": [], "sources": [ - "packages/core/session/src/types.ts:212" + "packages/core/session/src/types.ts:213" ] }, + { + "digest": "79a4c87f8bfb4d6d05cd9952b99210d6a7db3b478c4a01d94b4c50602308c056", + "names": [], + "sources": [] + }, { "digest": "79ddc907312f97cf9553d4841e8b3fc937097bd8d97900a790efad3c05255e99", "names": [], @@ -24269,13 +24374,6 @@ "names": [], "sources": [] }, - { - "digest": "83a665476a9ee9af05cb1fdded12dd647d1b97b491200d7f12ef1af7cdac4428", - "names": [], - "sources": [ - "packages/core/agent/src/types.ts:96" - ] - }, { "digest": "83d6916021eede8f2d83a1872716caa1efdec39a3681890996c55147187fdd12", "names": [], @@ -24378,15 +24476,6 @@ "packages/llm/llm/src/types.ts:472" ] }, - { - "digest": "89b2edff547be1ecfcd78ef18aeeb3cf507fcbf7d6093ec04fb111ef4c8e8a97", - "names": [ - "packages/llm/llm/src/message.ts#UserMessage" - ], - "sources": [ - "packages/llm/llm/src/message.ts:162" - ] - }, { "digest": "8a046b52331959aa3394680d6acbe09b8a5447c6b61375f263445bfcef19b7f1", "names": [], @@ -24545,6 +24634,11 @@ "packages/llm/llm/src/types.ts:470" ] }, + { + "digest": "95a3ffccbf3c8afcf9a2f3b355f724da8e07e98dc47ee741166dde7f9ea058bd", + "names": [], + "sources": [] + }, { "digest": "95c0e88fcf0bafb2087f912ad9e2369de6ae6530cc0c4c2772c96205b57f7f60", "names": [], @@ -24569,6 +24663,11 @@ "packages/core/agent/src/types.ts:39" ] }, + { + "digest": "97e0162f983c27456ec71269fb4976b1851588b8c1aeac5d071d43e11f95e03c", + "names": [], + "sources": [] + }, { "digest": "9890113e97dd3541787bfe6704f2b223d3a890b5325ca0acae2b0398909b413e", "names": [], @@ -24608,11 +24707,6 @@ "packages/web/web-search-deepseek/src/provider.ts:64" ] }, - { - "digest": "9a36727716d0e73f681f3b725e7f28f89417c68174c8af0ff2b1ef52b08326a0", - "names": [], - "sources": [] - }, { "digest": "9a410ddf56792b2b7cd4a224a2e54dc4dee30a8e8e6b1c2b9a1d19992b217adc", "names": [ @@ -24628,7 +24722,7 @@ "packages/core/session/src/types.ts#EpochHeader" ], "sources": [ - "packages/core/session/src/types.ts:240" + "packages/core/session/src/types.ts:241" ] }, { @@ -24797,7 +24891,7 @@ "digest": "a81059ca06999da1e712414520c2c65e491d052fcf9640fae20aa38f0a5dc735", "names": [], "sources": [ - "packages/core/session/src/types.ts:383", + "packages/core/session/src/types.ts:384", "packages/core/tools/src/types.ts:27" ] }, @@ -24827,6 +24921,11 @@ "packages/schedule/schedule/src/types.ts:173" ] }, + { + "digest": "a927d444c928ac0efd9dd8b2e7d7d04d711c264cb71578d81e0e2fd143ff669f", + "names": [], + "sources": [] + }, { "digest": "a99ec0993c62c058e50af9f68922202a3b54f5e259bc2abf1915a73381d2316d", "names": [], @@ -24879,7 +24978,7 @@ "digest": "ab7ec5f9eb2c6dfacca5da4c37cf7ba85d04172053ee6b1ebf1709ffcd99c1a7", "names": [], "sources": [ - "packages/core/session/src/types.ts:214", + "packages/core/session/src/types.ts:215", "packages/llm/llm/src/types.ts:159" ] }, @@ -24892,7 +24991,7 @@ "digest": "ad0c38a0adb82a94c925456df41586d9b5f9202fca18593eae2213d4445b4585", "names": [], "sources": [ - "packages/core/session/src/types.ts:390" + "packages/core/session/src/types.ts:391" ] }, { @@ -24916,7 +25015,7 @@ "packages/interaction/commands/src/types.ts#CommandSource" ], "sources": [ - "packages/core/session/src/types.ts:190", + "packages/core/session/src/types.ts:191", "packages/interaction/commands/src/types.ts:75", "packages/interaction/commands/src/types.ts:79", "packages/llm/llm/src/message.ts:111", @@ -24948,15 +25047,6 @@ "names": [], "sources": [] }, - { - "digest": "af99c95d46bed802870f1ebefe0ea11f92a03cb37b8e0cbe3f94b7aaaf509b08", - "names": [ - "packages/llm/llm/src/message.ts#DeveloperMessage" - ], - "sources": [ - "packages/llm/llm/src/message.ts:157" - ] - }, { "digest": "aff60ef947c18b012644852bdd0d5bfe72065e061dd563787c76c646b4a10f6b", "names": [], @@ -24987,7 +25077,7 @@ "packages/core/session/src/types.ts#RequestContext" ], "sources": [ - "packages/core/session/src/types.ts:254" + "packages/core/session/src/types.ts:255" ] }, { @@ -25031,11 +25121,6 @@ "packages/experimental/agent-team/src/types.ts:72" ] }, - { - "digest": "b33545b3244861bcbf73b407d24b53402710f5acacab7ae939537e5504a68dc6", - "names": [], - "sources": [] - }, { "digest": "b3681c026a0b98e98b103f057dfd217fbe904129c090efd668253ec9aea7fece", "names": [], @@ -25103,7 +25188,7 @@ "digest": "b6ef9c6210be8350cd465f485cf520031fffa6bf3809c61a1e713e834c5982d0", "names": [], "sources": [ - "packages/core/session/src/types.ts:202" + "packages/core/session/src/types.ts:203" ] }, { @@ -25181,11 +25266,6 @@ "names": [], "sources": [] }, - { - "digest": "ba5b10ea77d68f4a29439a9bd2a14f6a560acf55731749d57237757cc72fbc06", - "names": [], - "sources": [] - }, { "digest": "ba77b58995e36f14bb13886b0493d0a1a63d5db10b1a6cd9f6ac65a72cf832ec", "names": [], @@ -25225,7 +25305,7 @@ "digest": "bcf0caf62d964b2fcf5404bd5c909cfa9a21c3f3c96e6b7e36d9e33565223825", "names": [], "sources": [ - "packages/core/session/src/types.ts:464" + "packages/core/session/src/types.ts:465" ] }, { @@ -25464,15 +25544,6 @@ "names": [], "sources": [] }, - { - "digest": "d901c4f5e856591c3217af983caa15bba568d580700f0958bab56b88004eced9", - "names": [ - "packages/llm/llm/src/message.ts#Message" - ], - "sources": [ - "packages/llm/llm/src/message.ts:196" - ] - }, { "digest": "d9196086e41e4dd5c2ce87f5c25529c8112a1731d258cf0728747d41a9ee3241", "names": [], @@ -25561,6 +25632,16 @@ "names": [], "sources": [] }, + { + "digest": "deebfc7c62177ea840f23c6362b8615bc78ffa93377dd5d243c04e541c413af5", + "names": [ + "packages/typert/protocol/src/types.ts#Principal" + ], + "sources": [ + "packages/typert/protocol/src/types.ts:457", + "packages/typert/protocol/src/types.ts:461" + ] + }, { "digest": "df5d9709b7db2a72510dc52052a23f0c38f39730af1504bab8bccabc14fae528", "names": [], @@ -25643,14 +25724,14 @@ "digest": "e481c46af3813e0010558e93f578b2986acd658404cec2d1679e3ac2a6c48654", "names": [], "sources": [ - "packages/core/session/src/types.ts:297" + "packages/core/session/src/types.ts:298" ] }, { "digest": "e4c18e294232c3ba6c9f1999f168263cc55956147cfe4121720899be8e52edd1", "names": [], "sources": [ - "packages/core/session/src/types.ts:361" + "packages/core/session/src/types.ts:362" ] }, { @@ -25725,7 +25806,7 @@ "digest": "eaa71be0fbf0f702df8ea6c11d0aa2f9782263d45a10d0fb14eda662f4bfe04c", "names": [], "sources": [ - "packages/core/session/src/types.ts:196" + "packages/core/session/src/types.ts:197" ] }, { @@ -25742,7 +25823,7 @@ "digest": "ec8b5ff7c77ca6893ac7ba5a0c5b2aefeb52f12c64450b7359660c2a7e8a53d7", "names": [], "sources": [ - "packages/core/session/src/types.ts:228" + "packages/core/session/src/types.ts:229" ] }, { @@ -25778,8 +25859,8 @@ "digest": "ef11a3b7fa2033ae4279545ff8a8a2ee4e301b9b73e5b91654c48e6d08283545", "names": [], "sources": [ - "packages/core/session/src/types.ts:299", - "packages/core/session/src/types.ts:301" + "packages/core/session/src/types.ts:300", + "packages/core/session/src/types.ts:302" ] }, { @@ -25803,7 +25884,7 @@ "packages/api/session-controller/src/types.ts#ModelSelection" ], "sources": [ - "packages/api/session-controller/src/types.ts:100" + "packages/api/session-controller/src/types.ts:101" ] }, { @@ -25812,7 +25893,7 @@ "packages/core/session/src/types.ts#TurnEndReason" ], "sources": [ - "packages/core/session/src/types.ts:232" + "packages/core/session/src/types.ts:233" ] }, { @@ -25869,6 +25950,13 @@ "names": [], "sources": [] }, + { + "digest": "f4ed4717b4c259e933b550a8dcd90dc93628cda8a3c7affcaa53b9c13c1d53eb", + "names": [], + "sources": [ + "packages/core/session/src/types.ts:312" + ] + }, { "digest": "f5520c1d261ca86ea03735799c311a0a9ede8babb588cf0a65422576d2ee0ad1", "names": [], @@ -25941,7 +26029,7 @@ "digest": "f90eb4ab2d3897bc20c521a038e3119d581f791ec974b1a0004f63a8ab9e232d", "names": [], "sources": [ - "packages/core/session/src/types.ts:288", + "packages/core/session/src/types.ts:289", "packages/deliverables/workspace-changes/src/types.ts:106" ] }, @@ -25955,6 +26043,13 @@ "names": [], "sources": [] }, + { + "digest": "fa106c5b33b657e8ef16e8cbf39f3b7d44e5cbcdf4f9303d9db6410f28951e42", + "names": [], + "sources": [ + "packages/core/agent/src/types.ts:96" + ] + }, { "digest": "fa87537f4a9c1e25602990a8bf686b383e9e00352ec8946e16ae24ba7c1625be", "names": [ diff --git a/packages/api/session-controller/src/commands.ts b/packages/api/session-controller/src/commands.ts index 5bb0a7039d..8dd38573c8 100644 --- a/packages/api/session-controller/src/commands.ts +++ b/packages/api/session-controller/src/commands.ts @@ -20,7 +20,6 @@ import { SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionHeader, SessionId, UserMessage } from '@deepseek-ai/dsh-session' import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query' import { SessionTitleInvalidError } from '@deepseek-ai/dsh-session-title' -import { canonicalClientTimeZone } from '@deepseek-ai/dsh-util-time' import { assertNever } from '@deepseek-ai/dsh-util-values' import { RemoteError, remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol' import type { Workspace } from '@deepseek-ai/dsh-workspace' @@ -65,7 +64,12 @@ type PromptContentCandidate = | SessionPromptRequest['content'][number] | Extract['content'][number] -function hasPromptContent(content: readonly PromptContentCandidate[]): boolean { +/** + * Whether prompt content carries non-whitespace text or an attachment. + * @param content - submitted or edited prompt parts. + * @returns false for content the Agent must not receive. + */ +export function hasPromptContent(content: readonly PromptContentCandidate[]): boolean { return content.some(part => part.type !== 'text' || part.text.trim().length > 0) } @@ -306,35 +310,15 @@ export class SessionCommandController { } /** - * Reject empty content, then admit one prompt after Agent and attachment validation. - * @param request - Session identity, prompt content, source metadata, and delivery mode. + * Admit one prompt after Agent and attachment validation; the Remote method + * has already validated the content and built the durable message source. + * @param request - Session identity, prompt content, and delivery mode. + * @param source - the `user-rpc` source recorded on the accepted message. * @returns acknowledgement that the Agent accepted the prompt. */ - async prompt(request: SessionPromptRequest): Promise { - if (!hasPromptContent(request.content)) { - throw new RemoteError( - 'gateway/bad-request', - 'prompt content must include non-whitespace text or an attachment', - {}, - ) - } - const clientTimeZone = request.clientTimeZone === undefined - ? undefined - : canonicalClientTimeZone(request.clientTimeZone) - if (request.clientTimeZone !== undefined && clientTimeZone === undefined) { - throw new RemoteError( - 'session/invalid-time-zone', - 'clientTimeZone must be UTC or a valid IANA Area/Location name', - { value: request.clientTimeZone }, - ) - } + async prompt(request: SessionPromptRequest, source: MessageSource): Promise { const agent = await this.resolveAgent(request.sessionId) if (hasPromptRequest(agent, request.requestId)) return { accepted: true } - const source: MessageSource = { - kind: 'user', - rpcId: request.requestId, - ...(clientTimeZone === undefined ? {} : { clientTimeZone }), - } const hasImage = request.content.some(part => part.type === 'image') const admit = async (): Promise => { try { diff --git a/packages/api/session-controller/src/index.ts b/packages/api/session-controller/src/index.ts index b28be14073..d3d5234e34 100644 --- a/packages/api/session-controller/src/index.ts +++ b/packages/api/session-controller/src/index.ts @@ -6,20 +6,21 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-fs' import { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' -import { errorChain, ReasoningEffortId } from '@deepseek-ai/dsh-llm' +import { errorChain, ReasoningEffortId, type MessageSource } from '@deepseek-ai/dsh-llm' import type {} from '@deepseek-ai/dsh-client-file-upload' import { canOpenNativePath, nativeFileManager, nativeFileApplications, openNativeFileApplication, openNativeAssociatedPath, revealNativePath } from '@deepseek-ai/dsh-native-command' import type { SessionId } from '@deepseek-ai/dsh-session' import type { SessionInspection } from '@deepseek-ai/dsh-session-persistence' import type {} from '@deepseek-ai/dsh-session-projection-cache' import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query' -import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' +import { Remote, RemoteError, TypertRemoteService, principalOf } from '@deepseek-ai/dsh-typert-protocol' +import { canonicalClientTimeZone } from '@deepseek-ai/dsh-util-time' import { ApiSessionAgentController, inspectApiSession, type ApiSessionAgentResult, } from './agent.ts' -import { SessionCommandController } from './commands.ts' +import { SessionCommandController, hasPromptContent } from './commands.ts' import { SessionControlController } from './control.ts' import { SessionHistoryController } from './history.ts' import { SessionFileReferences } from './file-references.ts' @@ -439,9 +440,30 @@ export class SessionController extends TypertRemoteService { * @returns acknowledgement that the Agent accepted the prompt. */ @Remote('prompt') - prompt(request: SessionPromptRequest, signal: AbortSignal): Promise { + async prompt(request: SessionPromptRequest, signal: AbortSignal): Promise { signal.throwIfAborted() - return this.commands.prompt(request) + if (!hasPromptContent(request.content)) { + throw new RemoteError('gateway/bad-request', 'prompt content must include non-whitespace text or an attachment', {}) + } + const clientTimeZone = request.clientTimeZone === undefined + ? undefined + : canonicalClientTimeZone(request.clientTimeZone) + if (request.clientTimeZone !== undefined && clientTimeZone === undefined) { + throw new RemoteError( + 'session/invalid-time-zone', + 'clientTimeZone must be UTC or a valid IANA Area/Location name', + { value: request.clientTimeZone }, + ) + } + // The caller is recorded only when it is not the owner, so owner messages keep their exact source. + const principal = principalOf(this.ctx.invocation) + const source: MessageSource = { + kind: 'user', + rpcId: request.requestId, + ...(clientTimeZone === undefined ? {} : { clientTimeZone }), + ...(principal === undefined ? {} : { principal }), + } + return this.commands.prompt(request, source) } /** diff --git a/packages/api/session-controller/src/types.ts b/packages/api/session-controller/src/types.ts index 03878bfae7..c0b2b9a573 100644 --- a/packages/api/session-controller/src/types.ts +++ b/packages/api/session-controller/src/types.ts @@ -9,6 +9,7 @@ import type { LlmAttemptId, MessageId } from '@deepseek-ai/dsh-llm/brand' import type { TextBlock } from '@deepseek-ai/dsh-llm' import type { SessionId, SessionSeqCursor } from '@deepseek-ai/dsh-session/types' import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types' +import type { Principal } from '@deepseek-ai/dsh-typert-protocol' import type { JsonValue } from '@deepseek-ai/dsh-util-values' import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' @@ -409,8 +410,12 @@ export type SessionRequestId = Branded<'session-request-id'> declare module '@deepseek-ai/dsh-llm' { interface MessageSourceMap { - /** Browser prompt correlation and optional Host-validated time zone. */ - 'user-rpc': { kind: 'user'; rpcId: SessionRequestId; clientTimeZone?: string } + /** + * Browser prompt correlation, optional Host-validated time zone, and the + * caller that submitted the prompt. `principal` is written only for a + * caller other than the owner, so an absent `principal` means the owner. + */ + 'user-rpc': { kind: 'user'; rpcId: SessionRequestId; clientTimeZone?: string; principal?: Principal } } } diff --git a/packages/api/session-controller/tests/commands-upload-file.host.spec.ts b/packages/api/session-controller/tests/commands-upload-file.host.spec.ts index 53d1137628..4c7bc2ed5a 100644 --- a/packages/api/session-controller/tests/commands-upload-file.host.spec.ts +++ b/packages/api/session-controller/tests/commands-upload-file.host.spec.ts @@ -7,7 +7,7 @@ import type { FileAttachmentRef, ImageAttachmentRef, SaveFileAttachment, SaveFileStreamAttachment, } from '@deepseek-ai/dsh-attachment' import { createUserMessage } from '@deepseek-ai/dsh-llm' -import type { UserMessage } from '@deepseek-ai/dsh-llm' +import type { MessageSource, UserMessage } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import CommandRuntime from '@deepseek-ai/dsh-commands' import { createScope } from '@deepseek-ai/dsh-scope' @@ -113,6 +113,9 @@ async function uploadHarness(origin?: 'subagent'): Promise<{ } } +/** The owner source `SessionController.prompt()` builds for {@link promptRequest}. */ +const USER_SOURCE: MessageSource = { kind: 'user', rpcId: 'req-1' as SessionRequestId } + function promptRequest(content: Parameters[0]['content']) { return { requestId: 'req-1' as SessionRequestId, @@ -163,7 +166,7 @@ describe('Session file uploads', () => { { type: 'file', receiptId: receipt.receiptId }, { type: 'image', mediaType: 'image/png', data: 'AAAA' }, { type: 'text', text: 'read it' }, - ])) + ]), USER_SOURCE) expect(followup).toHaveBeenCalledTimes(1) const message = followup.mock.calls[0]?.[0] as UserMessage expect(message.content).toEqual([ @@ -190,7 +193,7 @@ describe('Session file uploads', () => { name: 'huge.bin', })) expect(receipt.file).toMatchObject({ name: 'huge.bin', bytes: 5 }) - await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }])) + await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }]), USER_SOURCE) expect((followup.mock.calls[0]?.[0] as UserMessage).content).toEqual([ { type: 'file', attachment: receipt.file }, ]) @@ -217,7 +220,7 @@ describe('Session file uploads', () => { await expect(controller.prompt(promptRequest([ { type: 'image', mediaType: 'image/png', data: 'AAAA' }, { type: 'file', receiptId: 'missing-receipt' as FileUploadReceiptId }, - ]))).rejects.toMatchObject({ code: 'session/attachment-invalid', details: { reason: 'FILE_NOT_STAGED' } }) + ]), USER_SOURCE)).rejects.toMatchObject({ code: 'session/attachment-invalid', details: { reason: 'FILE_NOT_STAGED' } }) expect(followup).not.toHaveBeenCalled() expect(saveImages).not.toHaveBeenCalled() }) @@ -302,7 +305,7 @@ describe('Session file uploads', () => { const { controller, uploads, agent } = await uploadHarness() uploads.retirePrompt(agent, 'not-staged') const receipt = await uploads.upload(agent, { data: 'AAAA' }, new AbortController().signal) - await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }])) + await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }]), USER_SOURCE) expect(uploads.resolve(agent, receipt.receiptId)).toEqual(receipt.file) uploads.retirePrompt(agent, 'other-request') expect(uploads.resolve(agent, receipt.receiptId)).toEqual(receipt.file) @@ -317,7 +320,7 @@ describe('Session file uploads', () => { { data: 'AAAA' }, new AbortController().signal, ) - await first.controller.prompt(promptRequest([{ type: 'file', receiptId: observed.receiptId }])) + await first.controller.prompt(promptRequest([{ type: 'file', receiptId: observed.receiptId }]), USER_SOURCE) first.ctx.emit('session/event', first.agent.session, { type: 'user/message', data: createUserMessage({ @@ -345,9 +348,9 @@ describe('Session file uploads', () => { it('deduplicates a retried rpcId already present in the Agent inbox', async () => { const { controller, agent, followup } = await uploadHarness() const request = promptRequest([{ type: 'text', text: 'once' }]) - await controller.prompt(request) + await controller.prompt(request, USER_SOURCE) agent.inbox.append('next-turn', followup.mock.calls[0]?.[0] as UserMessage) - await expect(controller.prompt(request)).resolves.toEqual({ accepted: true }) + await expect(controller.prompt(request, USER_SOURCE)).resolves.toEqual({ accepted: true }) expect(followup).toHaveBeenCalledOnce() }) @@ -364,7 +367,7 @@ describe('Session file uploads', () => { source: { kind: 'user', rpcId: request.requestId }, }), { surfaceOp: 'append' }) - await expect(controller.prompt(request)).resolves.toEqual({ accepted: true }) + await expect(controller.prompt(request, USER_SOURCE)).resolves.toEqual({ accepted: true }) expect(followup).not.toHaveBeenCalled() }) @@ -374,7 +377,7 @@ describe('Session file uploads', () => { saveImages.mockReturnValueOnce(admitted.promise) const prompting = controller.prompt(promptRequest([ { type: 'image', mediaType: 'image/png', data: 'AAAA' }, - ])) + ]), USER_SOURCE) await vi.waitFor(() => { expect(saveImages).toHaveBeenCalledOnce() }) await disposeAgent() admitted.resolve([{ @@ -386,7 +389,7 @@ describe('Session file uploads', () => { it('rejects when a previously bound receipt retires during image admission', async () => { const { controller, uploads, agent, saveImages } = await uploadHarness() const receipt = await uploads.upload(agent, { data: 'AAAA' }, new AbortController().signal) - await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }])) + await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }]), USER_SOURCE) const admitted = Promise.withResolvers() saveImages.mockReturnValueOnce(admitted.promise) const prompting = controller.prompt({ @@ -395,7 +398,7 @@ describe('Session file uploads', () => { { type: 'image', mediaType: 'image/png', data: 'AAAA' }, ]), requestId: 'req-2' as SessionRequestId, - }) + }, USER_SOURCE) await vi.waitFor(() => { expect(saveImages).toHaveBeenCalledOnce() }) uploads.retirePrompt(agent, 'req-1') admitted.resolve([{ @@ -409,12 +412,12 @@ describe('Session file uploads', () => { it('keeps the prior receipt binding when a later prompt attempt fails', async () => { const { controller, uploads, agent, followup } = await uploadHarness() const receipt = await uploads.upload(agent, { data: 'AAAA' }, new AbortController().signal) - await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }])) + await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }]), USER_SOURCE) followup.mockImplementationOnce(() => { throw new Error('busy') }) await expect(controller.prompt({ ...promptRequest([{ type: 'file', receiptId: receipt.receiptId }]), requestId: 'req-2' as SessionRequestId, - })).rejects.toMatchObject({ code: 'session/agent-busy' }) + }, USER_SOURCE)).rejects.toMatchObject({ code: 'session/agent-busy' }) uploads.retirePrompt(agent, 'req-1') expect(uploads.resolve(agent, receipt.receiptId)).toBeUndefined() }) @@ -425,7 +428,7 @@ describe('Session file uploads', () => { followup.mockImplementationOnce(() => { throw new Error('busy') }) await expect(controller.prompt(promptRequest([ { type: 'file', receiptId: receipt.receiptId }, - ]))).rejects.toMatchObject({ code: 'session/agent-busy' }) + ]), USER_SOURCE)).rejects.toMatchObject({ code: 'session/agent-busy' }) uploads.retirePrompt(agent, 'req-1') expect(uploads.resolve(agent, receipt.receiptId)).toEqual(receipt.file) }) @@ -433,7 +436,7 @@ describe('Session file uploads', () => { it('retires a prompt-bound receipt when its queued occurrence is removed', async () => { const { controller, uploads, agent, followup } = await uploadHarness() const receipt = await uploads.upload(agent, { data: 'AAAA' }, new AbortController().signal) - await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }])) + await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }]), USER_SOURCE) const queued = followup.mock.calls[0]?.[0] as UserMessage agent.inbox.append('next-turn', queued) expect(await controller.updateQueue({ @@ -454,7 +457,7 @@ describe('Session file uploads', () => { await controller.prompt(promptRequest([ { type: 'file', receiptId: first.receiptId }, { type: 'file', receiptId: second.receiptId }, - ])) + ]), USER_SOURCE) const message = followup.mock.calls[0]?.[0] as UserMessage expect(message.content).toEqual([ diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index b536306a07..119cc26a6a 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -40,7 +40,7 @@ import type { ContentBlock, MessageId, MessageSource } from '@deepseek-ai/dsh-ll import type { Agent } from '@deepseek-ai/dsh-agent' import type { SessionId } from '@deepseek-ai/dsh-session' import { canonicalClientTimeZone } from '@deepseek-ai/dsh-util-time' -import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' +import { Remote, RemoteError, TypertRemoteService, principalOf, type Principal } from '@deepseek-ai/dsh-typert-protocol' import { rejectPrompt, validateControlRequest, } from './control.ts' @@ -178,6 +178,8 @@ interface BrowserPromptSource { readonly kind: 'user' readonly rpcId: SubagentPromptRequestId readonly clientTimeZone?: string + /** Caller that submitted this follow-up, written only when it is not the owner; absent means the owner. */ + readonly principal?: Principal } /** Host configuration for continuable subagent capacity. */ @@ -464,10 +466,12 @@ export class SubagentRuntime extends TypertRemoteService { { parentSessionId }, ) } + const principal = principalOf(this.ctx.invocation) const source: BrowserPromptSource = { kind: 'user', rpcId: request.requestId, ...(canonicalTimeZone === undefined ? {} : { clientTimeZone: canonicalTimeZone }), + ...(principal === undefined ? {} : { principal }), } try { // Admission precedes delivery: image parts become durable references diff --git a/packages/subagent/subagent/tests/control.spec.ts b/packages/subagent/subagent/tests/control.spec.ts index b6df7c53ed..20fdc49d6b 100644 --- a/packages/subagent/subagent/tests/control.spec.ts +++ b/packages/subagent/subagent/tests/control.spec.ts @@ -12,11 +12,20 @@ import SubagentRuntime, { type SubagentPromptRequestId, } from '@deepseek-ai/dsh-subagent' import { deliverSubagentPrompt, type HostPromptDeliverer } from '@deepseek-ai/dsh-subagent/internal' +import type { PeerId, Principal, RemoteInvocation } from '@deepseek-ai/dsh-typert-protocol' + +declare module '@deepseek-ai/dsh-typert-protocol' { + interface PrincipalMap { + /** Test-only non-owner caller shared by the access specs. */ + 'test-guest': { readonly kind: 'test-guest'; readonly guest: string } + } +} const PARENT = SessionId('parent') const CHILD = SessionId('child') const PARENT_CREATED_AT = 1 const REQUEST_ID = 'req-1' as SubagentPromptRequestId +const GUEST: Principal = { kind: 'test-guest', guest: 'subagent' } const signal = new AbortController().signal /** Durable-reference base for the fake store; per-test ids and media types override. */ const IMAGE_REF = { attachmentId: 'att', mediaType: 'image/png', bytes: 2, width: 1, height: 1 } @@ -194,6 +203,27 @@ describe('subagent prompt Remote', () => { ) }) + it('records a non-owner caller on the follow-up source and nothing for the owner', async () => { + const { ctx, subagents } = await bench({ [PARENT]: { status: 'idle' } }) + const delivery = promptDelivery(subagents).mockResolvedValue('m-guest' as MessageId) + const as = (principal: Principal) => ctx.extend({ + invocation: { + request: { namespace: 'subagents', method: 'prompt', args: {} }, + service: 'subagents', + peer: { id: 'subagent-peer' as PeerId, ctx, principal, signal, dispose: async () => {} }, + signal, + uplink: (): AsyncIterable => ({ [Symbol.asyncIterator]: () => ({ next: async () => ({ done: true, value: undefined }) }) }), + } satisfies RemoteInvocation, + }).subagents + + await expect(as(GUEST).prompt(promptRequest(), signal)).resolves.toEqual({ messageId: 'm-guest' }) + await expect(as({ kind: 'owner' }).prompt(promptRequest(), signal)).resolves.toEqual({ messageId: 'm-guest' }) + expect(delivery.mock.calls.map(call => call[3])).toEqual([ + { kind: 'user', rpcId: REQUEST_ID, principal: GUEST }, + { kind: 'user', rpcId: REQUEST_ID }, + ]) + }) + it('passes steer delivery through the same admission operation', async () => { const { subagents } = await bench({ [PARENT]: { status: 'running' } }) const delivery = promptDelivery(subagents).mockResolvedValue('m-steer' as MessageId) From 9c6374909bacb5afab6245b77465e97666c69412 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:46:35 +0800 Subject: [PATCH 17/35] feat(typert): declare remote method and event resources --- packages/api/remotes/README.i18n.yaml | 4 +- packages/api/remotes/README.md | 2 + packages/api/remotes/README.zh.md | 2 + packages/api/remotes/package.json | 1 + packages/api/remotes/src/index.ts | 6 +- packages/api/remotes/src/remote-events.ts | 15 +- .../remotes/tests/remote-events.host.spec.ts | 27 ++ packages/api/remotes/tsconfig.client.json | 3 + packages/core/agent/src/index.ts | 2 + packages/core/agent/tests/agent.spec.ts | 2 + packages/core/session/src/index.ts | 1 + packages/core/session/src/types.ts | 6 + packages/core/session/tests/typert.spec.ts | 1 + packages/typert/generator/src/analyzer.ts | 62 ++-- .../remote-model/typert-protocol.d.ts | 7 +- .../generator/tests/remote-model.spec.ts | 63 ++++- packages/typert/protocol/README.i18n.yaml | 19 +- packages/typert/protocol/README.md | 20 +- packages/typert/protocol/README.zh.md | 20 +- packages/typert/protocol/src/index.ts | 152 ++++++++-- packages/typert/protocol/src/remote-access.ts | 38 +++ packages/typert/protocol/src/types.ts | 126 ++++++++- .../typert/protocol/tests/protocol.spec.ts | 29 +- .../protocol/tests/remote-access.spec.ts | 267 ++++++++++++++++++ packages/typert/registry/README.i18n.yaml | 8 +- packages/typert/registry/README.md | 3 + packages/typert/registry/README.zh.md | 3 + packages/typert/registry/src/service.ts | 56 ++++ packages/typert/registry/tests/typert.spec.ts | 75 +++++ packages/workspace/workspace/src/types.ts | 7 +- 30 files changed, 943 insertions(+), 84 deletions(-) create mode 100644 packages/typert/protocol/src/remote-access.ts create mode 100644 packages/typert/protocol/tests/remote-access.spec.ts diff --git a/packages/api/remotes/README.i18n.yaml b/packages/api/remotes/README.i18n.yaml index c8686dd4e2..3339260812 100644 --- a/packages/api/remotes/README.i18n.yaml +++ b/packages/api/remotes/README.i18n.yaml @@ -18,8 +18,8 @@ en: a80d44e0683768a5 zh: 379833ac180e5418 /deepseek-ai-dsh-api-remotes/forwarded-host-events: - en: d6d82bf0e55ae5e9 - zh: c3a0c80f8b115039 + en: 31bb9a2f50094b84 + zh: 226c055739d5def7 /deepseek-ai-dsh-api-remotes/build-boundary: en: 7a9934a3ba5b185f zh: 40457fd5707f57f0 diff --git a/packages/api/remotes/README.md b/packages/api/remotes/README.md index f479777030..bde4e4c93a 100644 --- a/packages/api/remotes/README.md +++ b/packages/api/remotes/README.md @@ -44,6 +44,8 @@ This package owns no physical transport or Host service discovery. It projects t The listener signature is not restated here. Each allowlisted event's Cordis `Events` declaration lives in its owner package's client-safe `./types` export, and both faces of this package pull those declarations in. The Host face additionally asserts every entry against `TypertForwardableEventEntry`: an `emit` entry must be a declared one-way event, while a `waterfall` entry must be a declared Agent-scoped waterfall whose final parameter is its same-result `next()` callback. +An `emit` entry may declare `resources`, readers keyed by `TypertResourceMap` kind and typed by the event's own parameters, such as `resources: { session: summary => summary.sessionId }` on `api-session/added`. The Host entry attaches them to each forwarded frame unchanged, and the Gateway forwards a notification to a non-owner Peer only when access control allows the event on the resources they name; a notification without `resources` touches the built-in `global` resource. `api-session/activity`, `api-session/added`, `api-session/error`, `api-session/removed`, and `api-session/status` declare their Session. A notification names one resource identity or none; collection changes travel on follow streams instead. + The Host entry registers an independent allowlist listener set and queue for each Client stream. It rejects non-JSON ordinary-event arguments before enqueueing. For a waterfall, it projects only the top-level Agent identity and JSON request fields; a Client result must also be lossless JSON, while `next()` delegates to the following Host listener. Each scoped waterfall request must carry its routed Agent directly as `request.agent`; the Host rejects a missing or mismatched identity before forwarding. The source attaches all listeners synchronously before `ctx.typertGateway.registerRemoteEvents()` exposes Gateway's internal `$events` logical stream, so its first `ready` item proves that incremental delivery is active and carries the Host home for Client path display. Withdrawing the registration aborts active streams. The payload-free `schedule/changed` event invalidates Schedule task queries; Clients re-read after a notification and reconnect. The payload-free `permission-presets/catalog-changed` event invalidates the process catalog. A Client subscribes before its first `permissionPresets.catalog()` read and re-reads the complete snapshot after a notification; the event carries no catalog state and changes no Session sequence. diff --git a/packages/api/remotes/README.zh.md b/packages/api/remotes/README.zh.md index b130e28b76..680e41e045 100644 --- a/packages/api/remotes/README.zh.md +++ b/packages/api/remotes/README.zh.md @@ -44,6 +44,8 @@ Client 组合挂载 Office 转换、Commands、凭据、settings、Goal、Schedu 监听器签名不在此处重写。名单内每条事件的 Cordis `Events` 声明都住在其 owner 包 client-safe 的 `./types` 导出,本包两个 face 都把那些声明纳入编译面。Host face 还会把每个条目断言给 `TypertForwardableEventEntry`:`emit` 条目必须是已声明的单向事件,`waterfall` 条目则必须是已声明的 agent-scoped waterfall,且其最后一个参数是返回相同结果类型的 `next()` 回调。 +`emit` 条目可以声明 `resources`:以 `TypertResourceMap` 种类为键、类型由该事件自身参数决定的读取函数,例如 `api-session/added` 上的 `resources: { session: summary => summary.sessionId }`。Host entry 把它们原样附在每个转发帧上;只有访问控制在它们给出的资源上允许该事件时,Gateway 才把通知转发给非 owner Peer,未声明 `resources` 的通知触及内置的 `global` 资源。`api-session/activity`、`api-session/added`、`api-session/error`、`api-session/removed` 与 `api-session/status` 声明其 Session。一个通知只携带一个资源身份或不携带;集合类变化改走 follow 类流。 + Host entry 为每条 Client 流独立注册一组 allowlist listener 和一个队列,并在普通事件入队前拒绝非 JSON 参数。对于 waterfall,它只投影顶层 agent 身份与 JSON 请求字段;Client 结果也必须能无损表示为 JSON,而 `next()` 会委托给后续 Host listener。每个作用域 waterfall 请求都必须以 `request.agent` 直接携带路由所用的 agent;Host 会在转发前拒绝缺失或不匹配的身份。该 source 在 `ctx.typertGateway.registerRemoteEvents()` 暴露 Gateway 内部的 `$events` 逻辑流前同步挂好所有 listener,因此首个 `ready` 项既能证明增量投递已就绪,也会携带供 Client 显示路径的 Host home。撤回注册会中止活动流。 无 payload 的 `schedule/changed` 事件使 Schedule 任务查询失效;Client 在收到通知及重连后重新读取。无 payload 的 `permission-presets/catalog-changed` 事件使进程目录失效。Client 在首次读取 `permissionPresets.catalog()` 前先订阅,并在通知后重新读取完整快照;该事件不携带目录状态,也不改变 Session 序号。 diff --git a/packages/api/remotes/package.json b/packages/api/remotes/package.json index 1ac78ce12f..c4db08b517 100644 --- a/packages/api/remotes/package.json +++ b/packages/api/remotes/package.json @@ -81,6 +81,7 @@ "@deepseek-ai/dsh-permission-presets": "workspace:*", "@deepseek-ai/dsh-schedule": "workspace:*", "@deepseek-ai/dsh-scope": "workspace:*", + "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-session-reference": "workspace:*", "@deepseek-ai/dsh-settings": "workspace:*", "@deepseek-ai/dsh-subagent": "workspace:*", diff --git a/packages/api/remotes/src/index.ts b/packages/api/remotes/src/index.ts index 781ffbb95a..d2fa2989f1 100644 --- a/packages/api/remotes/src/index.ts +++ b/packages/api/remotes/src/index.ts @@ -48,10 +48,12 @@ export function apply(ctx: Context): void { function remoteEventSource(ctx: Context): TypertRemoteEventSource { return (signal) => { const queue = new RemoteEventQueue() - const disposers = API_REMOTE_FORWARDED_EVENTS.map(({ event, mode }) => { + const disposers = API_REMOTE_FORWARDED_EVENTS.map((entry) => { + const { event, mode } = entry if (mode === 'emit') { + const resources = 'resources' in entry ? entry.resources : undefined return ctx.on(event as never, ((...args: unknown[]) => { - queue.push({ event, args: assertJsonArgs(event, args) }) + queue.push({ event, args: assertJsonArgs(event, args), ...resources === undefined ? {} : { resources } }) }) as never) } return ctx.on(event as never, (function ( diff --git a/packages/api/remotes/src/remote-events.ts b/packages/api/remotes/src/remote-events.ts index f57a5c6481..c59f72030c 100644 --- a/packages/api/remotes/src/remote-events.ts +++ b/packages/api/remotes/src/remote-events.ts @@ -7,6 +7,7 @@ */ import type {} from '@deepseek-ai/dsh-api-session-controller/remote-events' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import type {} from '@deepseek-ai/dsh-deepseek-account/types' import type {} from '@deepseek-ai/dsh-permission-presets/types' import type {} from '@deepseek-ai/dsh-plugin-manager/types' @@ -16,15 +17,19 @@ import type { TypertForwardableEventEntry } from '@deepseek-ai/dsh-typert-protoc /** * Host events this application forwards without renaming. The explicit mode is * both the Host dispatch strategy and the legal key set of `ctx.remote.$on`. + * An `emit` entry declares readers of the resources its payload carries, or + * none to touch the built-in `global` resource; it reaches a non-owner Peer + * only when access control allows it. Each notification names one resource + * identity or none, and collection changes travel on follow streams instead. */ export const API_REMOTE_FORWARDED_EVENTS = [ { event: 'agent-preset/selected', mode: 'emit' }, { event: 'approval/request', mode: 'waterfall' }, - { event: 'api-session/activity', mode: 'emit' }, - { event: 'api-session/added', mode: 'emit' }, - { event: 'api-session/error', mode: 'emit' }, - { event: 'api-session/removed', mode: 'emit' }, - { event: 'api-session/status', mode: 'emit' }, + { event: 'api-session/activity', mode: 'emit', resources: { session: (sessionId): SessionId => sessionId } }, + { event: 'api-session/added', mode: 'emit', resources: { session: (summary): SessionId => summary.sessionId } }, + { event: 'api-session/error', mode: 'emit', resources: { session: (sessionId): SessionId => sessionId } }, + { event: 'api-session/removed', mode: 'emit', resources: { session: (sessionId): SessionId => sessionId } }, + { event: 'api-session/status', mode: 'emit', resources: { session: (sessionId): SessionId => sessionId } }, { event: 'commands/change', mode: 'emit' }, { event: 'deepseek-account/session-expired', mode: 'emit' }, { event: 'deepseek-account/model-sign-in-required', mode: 'emit' }, diff --git a/packages/api/remotes/tests/remote-events.host.spec.ts b/packages/api/remotes/tests/remote-events.host.spec.ts index 80841acf6d..977257ee44 100644 --- a/packages/api/remotes/tests/remote-events.host.spec.ts +++ b/packages/api/remotes/tests/remote-events.host.spec.ts @@ -152,6 +152,33 @@ describe('Remote event Host source', () => { await ctx.fiber.dispose() }) + it('attaches each declared resource reader to its forwarded notification', async () => { + const { ctx, gateway, fiber } = await setup() + const abort = new AbortController() + const stream = sourceOf(gateway)(abort.signal)[Symbol.asyncIterator]() + const cases: readonly (readonly [string, readonly unknown[], string])[] = [ + ['api-session/activity', ['s-activity', 1], 's-activity'], + ['api-session/added', [{ sessionId: 's-added' }], 's-added'], + ['api-session/error', ['s-error', 'boom'], 's-error'], + ['api-session/removed', ['s-removed'], 's-removed'], + ['api-session/status', ['s-status', true], 's-status'], + ] + for (const [event, args, sessionId] of cases) { + emitRaw(ctx, event, args) + const next = await stream.next() + if (next.done === true || 'context' in next.value) throw new Error(`${event} was not forwarded as a notification`) + const frame = next.value + expect(frame).toMatchObject({ event, args }) + expect(frame.resources?.['session']?.(...frame.args)).toBe(sessionId) + } + // An event that declares no resources carries no readers, so access control sees the global resource. + emitRaw(ctx, 'commands/change', []) + await expect(stream.next()).resolves.toEqual({ done: false, value: { event: 'commands/change', args: [] } }) + abort.abort(new Error('Client disconnected')) + await fiber.dispose() + await ctx.fiber.dispose() + }) + it('rejects a non-JSON argument without poisoning the stream', async () => { const { ctx, gateway } = await setup() const abort = new AbortController() diff --git a/packages/api/remotes/tsconfig.client.json b/packages/api/remotes/tsconfig.client.json index 279c07025f..729bbd9e6b 100644 --- a/packages/api/remotes/tsconfig.client.json +++ b/packages/api/remotes/tsconfig.client.json @@ -33,6 +33,9 @@ { "path": "../../context/session-reference" }, + { + "path": "../../core/session" + }, { "path": "../../extensions/cordis-host-runner" }, diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index 54dd8a805d..dd8d09e6b7 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -258,6 +258,7 @@ export class AgentRegistry extends Service { typeCtx.typert.lookups.register('agent', { parameter: 'agent', wire: 'agentId', + resource: 'session', hostTypeSymbol: '@deepseek-ai/dsh-agent#Agent', wireTypeSymbol: '@deepseek-ai/dsh-session/types#SessionId', resolve: sessionId => this.get(sessionId), @@ -265,6 +266,7 @@ export class AgentRegistry extends Service { typeCtx.typert.contexts.registerHost('agent', { wire: 'agentId', wireTypeSymbol: '@deepseek-ai/dsh-session/types#SessionId', + resource: 'session', resolve: sessionId => this.get(sessionId)?.ctx, }) }) diff --git a/packages/core/agent/tests/agent.spec.ts b/packages/core/agent/tests/agent.spec.ts index c31acf5c66..21d90151cb 100644 --- a/packages/core/agent/tests/agent.spec.ts +++ b/packages/core/agent/tests/agent.spec.ts @@ -51,11 +51,13 @@ describe('AgentRegistry', () => { expect(lookup).toMatchObject({ parameter: 'agent', wire: 'agentId', + resource: 'session', hostTypeSymbol: '@deepseek-ai/dsh-agent#Agent', wireTypeSymbol: '@deepseek-ai/dsh-session/types#SessionId', }) expect(lookup?.resolve(agent.id)).toBe(agent) const context = ctx.typert.contexts.getHost('agent') + expect(context).toMatchObject({ wire: 'agentId', resource: 'session' }) expect(context?.resolve(agent.id)).toBe(agent.ctx) await disposeAgent() diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index e97585e463..e1ecb920bd 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -1079,6 +1079,7 @@ export class SessionStore extends Service { typeCtx.typert.lookups.register('session', { parameter: 'session', wire: 'sessionId', + resource: 'session', hostTypeSymbol: '@deepseek-ai/dsh-session#Session', wireTypeSymbol: '@deepseek-ai/dsh-session/types#SessionId', resolve: sessionId => this.get(sessionId), diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index fbc9a9cccb..e0931f2240 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -14,6 +14,7 @@ import type { ToolSchema, UserMessage, } from '@deepseek-ai/dsh-llm' +import type { TypertResource } from '@deepseek-ai/dsh-typert-protocol' import type { JsonValue } from '@deepseek-ai/dsh-util-values' /** Identifies one session in the store (and its persistence artifacts). */ @@ -549,6 +550,11 @@ export interface PluginRecord { } declare module '@deepseek-ai/dsh-typert-protocol' { + interface TypertResourceMap { + /** One Session; the Session Controller registers its default `sessionId` wire field. */ + session: TypertResource + } + interface RemoteErrorDetailsMap { /** The named Session does not exist; produced by every layer that resolves a SessionId. */ 'session/not-found': { readonly sessionId: SessionId } diff --git a/packages/core/session/tests/typert.spec.ts b/packages/core/session/tests/typert.spec.ts index 8a6e663ba5..9058f905fb 100644 --- a/packages/core/session/tests/typert.spec.ts +++ b/packages/core/session/tests/typert.spec.ts @@ -15,6 +15,7 @@ describe('Session Typert provider', () => { expect(lookup).toMatchObject({ parameter: 'session', wire: 'sessionId', + resource: 'session', hostTypeSymbol: '@deepseek-ai/dsh-session#Session', wireTypeSymbol: '@deepseek-ai/dsh-session/types#SessionId', }) diff --git a/packages/typert/generator/src/analyzer.ts b/packages/typert/generator/src/analyzer.ts index 90f901316b..e4e4d9f8a2 100644 --- a/packages/typert/generator/src/analyzer.ts +++ b/packages/typert/generator/src/analyzer.ts @@ -1409,36 +1409,27 @@ class FaceAnalyzer { } marker = { kind: 'direct', exportName } } else { - if (!ts.isObjectLiteralExpression(argument) || argument.properties.length === 0) { - this.fail(argument, 'Remote() options require name or mode: "stream"') - } - marker = { kind: 'direct' } - for (const property of argument.properties) { - if (!ts.isPropertyAssignment(property)) this.fail(property, 'Remote() options must use named values') - const name = memberName(property.name) - if (name === 'mode' && stringLiteralValue(property.initializer) === 'stream') marker = { ...marker, mode: 'stream' } - else if (name === 'name') { - const value = stringLiteralValue(property.initializer) - if (value === undefined || !isRemoteSegment(value)) this.fail(property, 'Remote() name must be an endpoint segment') - marker = { ...marker, exportName: value } - } else this.fail(property, 'Remote() options require name or mode: "stream"') - } + marker = { kind: 'direct', ...this.remoteOptions(argument, 'Remote') } } } else if (ts.isCallExpression(expression) && this.isTypeMetaSymbol(expression.expression, 'RemoteScope')) { if (expression.arguments.length < 1 || expression.arguments.length > 2) { - this.fail(expression, 'RemoteScope() requires a Context key and optional exported method name') + this.fail(expression, 'RemoteScope() requires a Context key and an optional exported method name or options object') } const context = stringLiteralValue(expression.arguments[0]) if (context === undefined || !isRemoteSegment(context)) { this.fail(expression.arguments[0] ?? expression, 'RemoteScope() key must be a string literal containing only RPC endpoint segment characters') } - const exportArgument = expression.arguments[1] - const exportName = exportArgument === undefined ? undefined : stringLiteralValue(exportArgument) - if (exportArgument !== undefined && (exportName === undefined || !isRemoteSegment(exportName))) { - this.fail(exportArgument, 'RemoteScope() name must be a string literal containing only RPC endpoint segment characters') + const option = expression.arguments[1] + const exportName = option === undefined ? undefined : stringLiteralValue(option) + if (option === undefined || exportName !== undefined) { + if (exportName !== undefined && !isRemoteSegment(exportName)) { + this.fail(option ?? expression, 'RemoteScope() name must be a string literal containing only RPC endpoint segment characters') + } + marker = { kind: 'context', context, ...exportName === undefined ? {} : { exportName } } + } else { + marker = { kind: 'context', context, ...this.remoteOptions(option, 'RemoteScope') } } - marker = { kind: 'context', context, ...exportName === undefined ? {} : { exportName } } } else { continue } @@ -1448,6 +1439,37 @@ class FaceAnalyzer { return found } + /** + * Read the options object of `@Remote` or `@RemoteScope`. `resources` + * accepts any expression: its readers run only at runtime, where the + * Gateway reads them from the prototype descriptor, so generation records + * nothing for them. + */ + private remoteOptions( + argument: ts.Expression, + decorator: 'Remote' | 'RemoteScope', + ): { readonly exportName?: string; readonly mode?: 'stream' } { + const requirement = decorator === 'Remote' + ? 'Remote() options require name, mode: "stream", or resources' + : 'RemoteScope() options require name or resources' + if (!ts.isObjectLiteralExpression(argument) || argument.properties.length === 0) this.fail(argument, requirement) + let options: { readonly exportName?: string; readonly mode?: 'stream' } = {} + for (const property of argument.properties) { + if (!ts.isPropertyAssignment(property)) this.fail(property, `${decorator}() options must use named values`) + const name = memberName(property.name) + if (decorator === 'Remote' && name === 'mode' && stringLiteralValue(property.initializer) === 'stream') { + options = { ...options, mode: 'stream' } + } else if (name === 'name') { + const value = stringLiteralValue(property.initializer) + if (value === undefined || !isRemoteSegment(value)) this.fail(property, `${decorator}() name must be an endpoint segment`) + options = { ...options, exportName: value } + } else if (name !== 'resources') { + this.fail(property, requirement) + } + } + return options + } + /** * The item types a Remote method's authored return type declares. Unary * methods unwrap `Promise`; stream methods unwrap `Iterable`, 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 1aafd6e2e6..fbfa27235a 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 @@ -139,13 +139,16 @@ declare module '@deepseek-ai/dsh-typert-protocol' { context: ClassMethodDecoratorContext Result>, ): void - export function Remote(option: string | { readonly mode?: 'stream'; readonly name?: string }): + export function Remote(option: string | { readonly mode?: 'stream'; readonly name?: string; readonly resources?: object }): ( method: (this: This, ...args: Args) => Result, context: ClassMethodDecoratorContext Result>, ) => void - export function RemoteScope(key: Extract, exportName?: string): + export function RemoteScope( + key: Extract, + option?: string | { readonly name?: string; readonly resources?: object }, + ): ( method: (this: This, ...args: Args) => Result, context: ClassMethodDecoratorContext Result>, diff --git a/packages/typert/generator/tests/remote-model.spec.ts b/packages/typert/generator/tests/remote-model.spec.ts index cea8dfaa8c..c46e502615 100644 --- a/packages/typert/generator/tests/remote-model.spec.ts +++ b/packages/typert/generator/tests/remote-model.spec.ts @@ -540,7 +540,7 @@ export type {`, name: 'an unknown Remote mode', mode: 'duplex', method: 'async *attach(agent: Agent, signal: AbortSignal): AsyncIterable', - message: 'Remote\\(\\) options require name or mode: "stream"', + message: 'Remote\\(\\) options require name, mode: "stream", or resources', }, ])('rejects $name', ({ mode, method, message }) => { const root = copyFixture() @@ -678,6 +678,67 @@ export interface BoxPayload { assertRemoteConsumerTypechecks(artifact?.remote?.dts, artifact?.remote?.dtsMap, root) }) + it('reads export names from options whose resource readers stay runtime-only', () => { + const root = copyFixture() + editFile(root, 'packages/remote/src/index.ts', source => source + .replace( + ' @Remote\n async create(', + " @Remote({ name: 'createGoal', resources: { session: (agentId: string) => agentId } })\n async create(", + ) + .replace( + " @RemoteScope('agent')\n", + " @RemoteScope('agent', { name: 'renameGoal', resources: { session: (request: RenameGoalRequest) => request.title } })\n", + )) + + const model = remotePackage(root) + expect(model.invocations.map(invocation => [invocation.method, invocation.implementation])).toEqual([ + ['createGoal', 'create'], + ['renameGoal', 'rename'], + ['watch', undefined], + ]) + expect(JSON.stringify(model.invocations)).not.toContain('resources') + }) + + it.each([ + { + name: 'an unknown Remote option', + decorator: '@Remote({ resources: {}, audit: true })\n async create(', + message: 'Remote\\(\\) options require name, mode: "stream", or resources', + }, + { + name: 'a stream option on a Remote Scope', + decorator: "@RemoteScope('agent', { mode: 'stream' })\n rename(", + message: 'RemoteScope\\(\\) options require name or resources', + }, + { + name: 'empty Remote Scope options', + decorator: "@RemoteScope('agent', {})\n rename(", + message: 'RemoteScope\\(\\) options require name or resources', + }, + { + name: 'a computed Remote Scope name', + decorator: "@RemoteScope('agent', { name: `rename${'Goal'}` })\n rename(", + message: 'RemoteScope\\(\\) name must be an endpoint segment', + }, + { + name: 'shorthand Remote Scope options', + decorator: "@RemoteScope('agent', { resources })\n rename(", + message: 'RemoteScope\\(\\) options must use named values', + }, + { + name: 'an untransportable Remote Scope export name', + decorator: "@RemoteScope('agent', 'rename goal')\n rename(", + message: 'RemoteScope\\(\\) name must be a string literal containing only RPC endpoint segment characters', + }, + ])('rejects $name', ({ decorator, message }) => { + const root = copyFixture() + editFile(root, 'packages/remote/src/index.ts', source => source + .replace(decorator.includes('create(') ? '@Remote\n async create(' : "@RemoteScope('agent')\n rename(", decorator) + .replace('export class GoalService', 'const resources = {}\n\nexport class GoalService')) + + expect(() => analyzeRemote(root, false)).toThrow(new RegExp(message)) + }) + it.each(['create#v2', 'create goal', '.', '..'])('rejects untransportable Remote alias %s', (alias) => { const root = copyFixture() editFile(root, 'packages/remote/src/index.ts', source => source.replace( diff --git a/packages/typert/protocol/README.i18n.yaml b/packages/typert/protocol/README.i18n.yaml index 50ec613960..44cc5d7243 100644 --- a/packages/typert/protocol/README.i18n.yaml +++ b/packages/typert/protocol/README.i18n.yaml @@ -18,8 +18,11 @@ en: c479b5b665822198 zh: 1ffb789c18043baf /deepseek-ai-dsh-typert-protocol/use-this-package/exposing-a-host-method: - en: 82a3db72a51105ff - zh: 46848784380732be + en: ca7df7ea76fb09d8 + zh: add884160fbbd5c7 +/deepseek-ai-dsh-typert-protocol/use-this-package/identifying-the-caller-and-admitting-non-owner-callers: + en: 78e08ae732a8f6f4 + zh: be15fb7599994fb9 /deepseek-ai-dsh-typert-protocol/use-this-package/associating-host-objects-and-contexts-with-wire-identities: en: 3db384ae6f215632 zh: 56207184b09d4018 @@ -36,8 +39,8 @@ en: 4f45bb46a1920984 zh: 70408d3b59d2735a /deepseek-ai-dsh-typert-protocol/understand-the-implementation/remote-markers: - en: 93b83a9441a5a3c2 - zh: c4eb143ddd167a27 + en: 082530713c5cea83 + zh: 592fe5d9636ef288 /deepseek-ai-dsh-typert-protocol/understand-the-implementation/protocol-maps-and-descriptors: en: c202904201402211 zh: 3db1d83683b4fd2a @@ -45,8 +48,8 @@ en: aac79a7b4682371e zh: bc9cc513c1ced3e6 /deepseek-ai-dsh-typert-protocol/understand-the-implementation/source-map: - en: 8b878e0e003b86ec - zh: 5d92998ad2964d7c + en: 74fdb78bf4e7753b + zh: dad439ba2a323ff9 /deepseek-ai-dsh-typert-protocol/further-exploration: en: 84191d1b07fb8bb6 zh: 9a5212732ed38ab6 @@ -57,8 +60,8 @@ en: b1c49d5e440d9d41 zh: 87ead78472415189 /deepseek-ai-dsh-typert-protocol/known-limitations-and-deferred-work: - en: 6079caacbcd01f08 - zh: 70295e443f35afe8 + en: 32a7ede4de20ad4e + zh: 57b86c314cd376b8 /deepseek-ai-dsh-typert-protocol/known-limitations-and-deferred-work/dev-note: en: e67f633dc6fe7773 zh: 3bb0c20c93f16f98 diff --git a/packages/typert/protocol/README.md b/packages/typert/protocol/README.md index d0ffd3996a..b771f7b566 100644 --- a/packages/typert/protocol/README.md +++ b/packages/typert/protocol/README.md @@ -46,9 +46,9 @@ Generation turns the method into a wire endpoint under the service's namespace; A unary method can return `Uint8Array` directly or within nested objects, arrays, tuples, optional fields, unions, and recursive types. Generation supplies optional result codec `encode()` and `decode()` functions: encoding visits only subtrees whose types can contain bytes, while decoding validates reconstructed values; Client declarations use `Uint8Array` at every byte position while retaining other field types. Pure JSON results pass through without Host byte detection or Client parsing. Parameters, events, and stream items remain JSON-only; runtime object cycles are unsupported. -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. +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` (the `PeerScope` its carrier names), 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. -`@Remote` options contain only `name` and `mode`; input policy belongs to the consumer, not a method annotation. `method.input(options?: TypertRemoteInputOptions)` returns a cached `TypertRemoteValue`, with `strict?: boolean` as its only option. `method.input()` and `{ strict: false }` retain ordinary Remote handling: declared objects accept and strip unknown fields. `{ strict: true }` rejects undeclared fields recursively, while explicit JSON values and dictionary keys stay open and dictionary values retain their declared validation. Both named-argument envelopes reject extra arguments. Select one view (`const input = method.input({ strict: true })`) for matching `input.create().parse(raw)` and `input.jsonSchema`; this does not change Host dispatch. The [generator](<../generator/README.md#ordinary-remote-metadata>) owns publication and the paired codecs. +`@Remote` options contain only `name`, `mode`, and the `resources` readers described below; input policy belongs to the consumer, not a method annotation. `method.input(options?: TypertRemoteInputOptions)` returns a cached `TypertRemoteValue`, with `strict?: boolean` as its only option. `method.input()` and `{ strict: false }` retain ordinary Remote handling: declared objects accept and strip unknown fields. `{ strict: true }` rejects undeclared fields recursively, while explicit JSON values and dictionary keys stay open and dictionary values retain their declared validation. Both named-argument envelopes reject extra arguments. Select one view (`const input = method.input({ strict: true })`) for matching `input.create().parse(raw)` and `input.jsonSchema`; this does not change Host dispatch. The [generator](<../generator/README.md#ordinary-remote-metadata>) owns publication and the paired codecs. Ordinary `./remote` artifacts export `TYPERT_REMOTE` as a `TypertRemoteInfo`, extending the mountable `TypertRemoteContribution`. Its typed `methods[endpoint]` entries are the same direct descriptor objects found in `descriptors`, with derived `input(options?)` and `invoke(remote, input, signal?)`. Parameter documentation lives in `parameters[].description`; `result` is the single result codec, parser, and optional projection. Unary descriptors omit `mode`; streams use `'stream'`. Context-only receivers remain in `descriptors` and scoped declarations, not `methods`. @@ -56,6 +56,12 @@ Ordinary `./remote` artifacts export `TYPERT_REMOTE` as a `TypertRemoteInfo`, ex The invoker takes a caller-owned, mounted, unscoped Client Remote and resolves its namespace method at call time. It preserves the namespace receiver, optional positional holes, and signal, forwarding the same `Promise>` or complete `RemoteStreamHandle` without an async wrapper. `DecodedOutput` defaults to the authored output; generation supplies `RemoteDecoded` only for byte-bearing unary methods, avoiding recursive remapping of ordinary JSON results; `result.create().parse()` retains the authored output type and does not normalize shared byte buffers. Documentation and parsing remain available when either input variant or the result lacks JSON Schema support; each variant's `jsonSchemaError` explains its absent projection. Registry readers use `isTypertRemoteMethod(descriptor)` from the same pure entry to narrow a base descriptor to these direct capabilities. +### Identifying the caller and admitting non-owner callers + +`PeerScope.principal` is the caller identity that in-process Host code fixes when it creates the Peer; no wire field can set it, and `PeerScope.signal` aborts when the Peer scope ends. `PrincipalMap` is merge-extensible: each driver declares one JSON entry with a unique literal `kind` and no credential, because a principal can be persisted in a Session log; the built-in entry is `owner: { kind: 'owner' }`. A Remote method that records its caller on durable output calls `principalOf(this.ctx.invocation)`, which returns the principal of a non-owner caller and `undefined` for the owner and outside a Remote call, and writes the field only when it is defined. + +Every Remote method is under access control: [`dsh-api-remote-access`](../../api/remote-access/README.md) decides whether a non-owner caller may reach it on the resources the call touches. `TypertResourceMap` is merge-extensible beside `TypertLookupMap`: the package that owns an identity type declares its resource kind as `TypertResource` (`dsh-session` declares `session: TypertResource`, `dsh-workspace` declares `workspace: TypertResource`). A `RemoteAccessResource` is either the built-in identity-less `{ kind: 'global' }` or one declared kind with its identity; resources are independent, so access to `global` implies nothing about any session. At runtime the domain that owns a kind registers its default wire field through `typert.resources.register(kind, { field })`, which rejects `global`; a lookup provider and a Host Context adapter name the kind their wire identity carries through `resource`, so a method with an Agent lookup parameter or an Agent-scoped receiver carries a `session` resource without options. The `resources` option of `@Remote` and `@RemoteScope` maps a kind to a reader that receives the method's wire arguments as its own parameters; their types are inferred from the decorated method, with lookup parameters mapped to their wire types, so `@Remote({ name: 'page', resources: { session: request => request.address.sessionId } })` reads a field the defaults cannot. A call whose readers and defaults name no resource touches `global`; they must name every resource the method accesses, because access control never checks one they omit. A forwarded `emit` entry of `TypertForwardableEventEntry` declares `resources` readers typed by its own event's parameters, and an entry without them touches `global`. Resource resolution belongs to the [API Gateway](../../api/gateway/README.md); decisions and refusal reasons belong to [`dsh-api-remote-access`](../../api/remote-access/README.md). + ### 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. @@ -97,7 +103,9 @@ The package keeps strict reflection in the compiler: decorator initializers reta ### Remote markers -`@Remote` and `@RemoteScope` schedule an initializer that appends the method name, an optional export name, and the invocation mode to the prototype descriptor; `remoteMethods(service)` validates its version and returns a detached declaration-order snapshot that the Gateway's source-mode fallback reads. Markers require public, non-static instance methods with string names, and conflicting markers on one method are rejected. +`@Remote` and `@RemoteScope` schedule an initializer that appends the method name, an optional export name, the invocation mode, and the frozen `resources` readers to the prototype descriptor; `remoteMethods(service)` validates its version and returns a detached declaration-order snapshot that the Gateway's source-mode fallback reads. Markers require public, non-static instance methods with string names, and conflicting markers on one method are rejected. + +Readers are validated when the decorator is created and frozen into one record per decorator application; the Gateway reads them from `remoteMethods(service)` by implementation member name, so another installed copy of the protocol package reads the same record. ### Protocol maps and descriptors @@ -114,10 +122,12 @@ 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/principal.ts`](src/principal.ts) | `principalOf`, the caller principal a Remote-call producer records | +| [`src/remote-access.ts`](src/remote-access.ts) | `RemoteAccessResource`, `RemoteAccessResourceKind`, and `RemoteAccessResourceOf` over `TypertResourceMap` | | [`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/remote-info.ts`](src/remote-info.ts) | Pure lazy info factory and direct-method type guard | -| [`src/types.ts`](src/types.ts) | Protocol maps, `RemoteErrorDetailsMap`, `RemoteResult`, `RemoteStream`, `RemoteStreamHandle`, `PeerScope`, `RemoteInvocation`, `InvocationDescriptor`, codecs, provider contracts, registry interfaces, `TypertClientRemote` | +| [`src/types.ts`](src/types.ts) | Protocol maps, `RemoteErrorDetailsMap`, `RemoteResult`, `RemoteStream`, `RemoteStreamHandle`, `PrincipalMap`, `Principal`, `PeerScope`, `RemoteInvocation`, `InvocationDescriptor`, codecs, provider contracts, registry interfaces, `TypertClientRemote` | @@ -152,7 +162,7 @@ No direct effect; the declared contracts reach a request only when an assembly p These limits define what the declarations can represent; they are current package constraints, not a task backlog. -- **Decorator markers are minimal** — markers contain the method name, optional export alias and stream mode, and direct or Context receiver selection; parameter, result, lookup, schema, and documentation metadata require the Typert build pipeline. +- **Decorator markers are minimal** — markers contain the method name, optional export alias, stream mode, resource readers, and direct or Context receiver selection; parameter, result, lookup, schema, and documentation metadata require the Typert build pipeline. - **Remote signatures are restricted** — decorators accept only public, non-static instance methods with string names, and source-mode execution cannot represent overloaded, destructured, defaulted, or rest-parameter signatures. diff --git a/packages/typert/protocol/README.zh.md b/packages/typert/protocol/README.zh.md index af4a7e7470..f0aa7a126e 100644 --- a/packages/typert/protocol/README.zh.md +++ b/packages/typert/protocol/README.zh.md @@ -46,9 +46,9 @@ export class GoalService extends TypertRemoteService { 一元方法可以直接返回 `Uint8Array`,也可以将其放在嵌套对象、数组、元组、可选字段、联合类型和递归类型中。生成器提供可选的结果 codec `encode()` 和 `decode()`:编码仅访问类型可能包含字节的子树,解码则校验还原后的值;Client 声明在每个字节位置使用 `Uint8Array`,同时保留其他字段类型。纯 JSON 结果不经 Host 字节识别或 Client 解析直接传递。参数、事件与流条目仍仅支持 JSON;不支持运行时对象循环。 -流方法(`@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 方法产出的值,原样透传。 +流方法(`@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 方法产出的值,原样透传。 -`@Remote` 选项仅包含 `name` 和 `mode`;输入策略由消费方选择,而非方法注解。`method.input(options?: TypertRemoteInputOptions)` 返回缓存的 `TypertRemoteValue`,唯一选项是 `strict?: boolean`。`method.input()` 与 `{ strict: false }` 保留普通 Remote 处理方式:已声明对象接受并剥离未知字段。`{ strict: true }` 递归拒绝未声明字段,而显式 JSON 值和字典键保持开放,字典值仍按声明校验。两种具名参数封装都拒绝额外参数。选择一个视图(`const input = method.input({ strict: true })`),即可通过 `input.create().parse(raw)` 与 `input.jsonSchema` 取得匹配的解析和投影;这不会改变 Host 分发。[生成器](<../generator/README.zh.md#ordinary-remote-metadata>)负责发布及配套 codec。 +`@Remote` 选项仅包含 `name`、`mode` 与下文所述的 `resources` 读取函数;输入策略由消费方选择,而非方法注解。`method.input(options?: TypertRemoteInputOptions)` 返回缓存的 `TypertRemoteValue`,唯一选项是 `strict?: boolean`。`method.input()` 与 `{ strict: false }` 保留普通 Remote 处理方式:已声明对象接受并剥离未知字段。`{ strict: true }` 递归拒绝未声明字段,而显式 JSON 值和字典键保持开放,字典值仍按声明校验。两种具名参数封装都拒绝额外参数。选择一个视图(`const input = method.input({ strict: true })`),即可通过 `input.create().parse(raw)` 与 `input.jsonSchema` 取得匹配的解析和投影;这不会改变 Host 分发。[生成器](<../generator/README.zh.md#ordinary-remote-metadata>)负责发布及配套 codec。 普通 `./remote` 产物将 `TYPERT_REMOTE` 导出为 `TypertRemoteInfo`,它扩展可挂载的 `TypertRemoteContribution`。类型化的 `methods[endpoint]` 条目就是 `descriptors` 中同一个直接方法描述符对象,并带有派生的 `input(options?)` 和 `invoke(remote, input, signal?)`。参数文档位于 `parameters[].description`;`result` 是唯一的结果 codec、解析器和可选投影。一元描述符省略 `mode`;流使用 `'stream'`。仅使用 Context 接收器的方法保留在 `descriptors` 和作用域声明中,不进入 `methods`。 @@ -56,6 +56,12 @@ export class GoalService extends TypertRemoteService { 调用器接收调用方拥有、已挂载且无作用域的 Client Remote,并在调用时解析其命名空间方法。它保留命名空间接收者、可选位置参数空位和 signal,不使用 async 包装,直接转发同一个 `Promise>` 或完整 `RemoteStreamHandle`。`DecodedOutput` 默认采用原始输出类型;生成器仅为含字节的一元方法指定 `RemoteDecoded`,避免递归重映射普通 JSON 结果;`result.create().parse()` 保留原始输出类型,不会规范化共享字节缓冲区。任一输入变体或结果不支持 JSON Schema 时,文档与解析能力仍然保留;各变体的 `jsonSchemaError` 说明其投影缺失的原因。Registry 读取方使用同一纯入口提供的 `isTypertRemoteMethod(descriptor)`,将基础描述符收窄为这些直接调用能力。 +### 识别调用方并放行非 owner 调用方 + +`PeerScope.principal` 是进程内 Host 代码创建 Peer 时固定的调用方身份;任何 wire 字段都无法设置它,`PeerScope.signal` 则在 Peer scope 结束时中止。`PrincipalMap` 可合并扩展:每个驱动方声明一个 JSON 条目,带唯一的字面量 `kind` 且不含凭据,因为 principal 可能持久化到会话日志中;内置条目是 `owner: { kind: 'owner' }`。在持久输出上记录调用方的 Remote 方法调用 `principalOf(this.ctx.invocation)`:调用方不是 owner 时它返回该 principal,对 owner 以及在 Remote 调用之外返回 `undefined`;方法只在其有定义时写入该字段。 + +每个 Remote 方法都受访问控制:由 [`dsh-api-remote-access`](../../api/remote-access/README.zh.md) 判定非 owner 调用方能否在该调用触及的资源上调用它。`TypertResourceMap` 与 `TypertLookupMap` 并列、可合并扩展:拥有身份类型的包以 `TypertResource` 声明自己的资源种类(`dsh-session` 声明 `session: TypertResource`,`dsh-workspace` 声明 `workspace: TypertResource`)。`RemoteAccessResource` 要么是内置、无身份的 `{ kind: 'global' }`,要么是某个已声明种类加其身份;资源之间相互独立,`global` 上的访问不蕴含任何会话上的访问。运行时由拥有该种类的领域经 `typert.resources.register(kind, { field })` 登记默认 wire 字段,该登记拒绝 `global`;lookup provider 与 Host Context adapter 经 `resource` 声明其 wire 身份所属的种类,因此带 Agent 查找参数或 Agent 作用域接收器的方法无需选项即携带 `session` 资源。`@Remote` 与 `@RemoteScope` 的 `resources` 选项把种类映射到一个以方法 wire 参数为自身位置参数的读取函数;这些参数的类型从被装饰的方法推断,查找参数映射为其 wire 类型,因此 `@Remote({ name: 'page', resources: { session: request => request.address.sessionId } })` 可以读取默认规则读不到的字段。读取函数与默认规则都未给出资源的调用触及 `global`;它们必须给出方法访问的全部资源,因为访问控制从不检查被遗漏的资源。`TypertForwardableEventEntry` 中的转发 `emit` 条目可以声明 `resources` 读取函数,其类型由该事件自身的参数决定;未声明的条目触及 `global`。资源解析归 [API Gateway](../../api/gateway/README.zh.md);判定与拒绝原因归 [`dsh-api-remote-access`](../../api/remote-access/README.zh.md)。 + ### 把 Host 对象与 Context 关联到 wire identity 复杂的 Host 对象不能直接跨 wire 传输。业务包通过可合并扩展的 `TypertLookupMap` 与 `TypertContextMap` 声明关联。Host Context 适配器拥有稳定 wire 声明,并把 wire identity 解析为活跃 Context。Client Context 适配器需要双向映射,因为作用域调用从 Client Context 发起,而转发的 Host 事件要在 Client 侧解析其显式 wire identity。Host 组合可以覆盖其同步或异步解析器。因策略原因拒绝解析的解析器会抛出带有自身错误码的 `RemoteError`,该码原样到达调用方。 @@ -97,7 +103,9 @@ Host 装配以转发给消费方的 Cordis 事件扩展 `TypertRemoteEventSelect ### Remote 标记 -`@Remote` 与 `@RemoteScope` 调度一个初始化器,把方法名、可选导出名与调用模式追加到原型描述符;`remoteMethods(service)` 校验其版本,并返回与已存描述符分离、按声明顺序排列的快照,供 Gateway 的源码模式回退读取。标记要求名称为字符串的公开、非静态实例方法,同一方法上的冲突标记会被拒绝。 +`@Remote` 与 `@RemoteScope` 调度一个初始化器,把方法名、可选导出名、调用模式与冻结的 `resources` 读取函数追加到原型描述符;`remoteMethods(service)` 校验其版本,并返回与已存描述符分离、按声明顺序排列的快照,供 Gateway 的源码模式回退读取。标记要求名称为字符串的公开、非静态实例方法,同一方法上的冲突标记会被拒绝。 + +读取函数在创建装饰器时校验,并按每次装饰器应用冻结为一条记录;Gateway 按实现成员名从 `remoteMethods(service)` 读取它们,因此协议包的另一个已安装副本也能读取同一记录。 ### 协议映射与描述符 @@ -114,10 +122,12 @@ Host 装配以转发给消费方的 Cordis 事件扩展 `TypertRemoteEventSelect | 文件 | 职责 | |---|---| | [`src/index.ts`](src/index.ts) | 装饰器、Gateway 绑定、`remoteMethods`、段校验 | +| [`src/principal.ts`](src/principal.ts) | `principalOf`:Remote 调用生产者记录的调用方 principal | +| [`src/remote-access.ts`](src/remote-access.ts) | 基于 `TypertResourceMap` 的 `RemoteAccessResource`、`RemoteAccessResourceKind` 与 `RemoteAccessResourceOf` | | [`src/json-value.ts`](src/json-value.ts) | 各载体共享的无损 JSON 校验 `isRemoteJsonValue` 与 `isRemoteUplinkItem` | | [`src/remote-error.ts`](src/remote-error.ts) | `RemoteError` 与结构式识别函数 `remoteErrorOf` | | [`src/remote-info.ts`](src/remote-info.ts) | 纯惰性 info factory 与直接方法类型守卫 | -| [`src/types.ts`](src/types.ts) | 协议映射、`RemoteErrorDetailsMap`、`RemoteResult`、`RemoteStream`、`RemoteStreamHandle`、`PeerScope`、`RemoteInvocation`、`InvocationDescriptor`、编解码器、提供方约定、注册表接口、`TypertClientRemote` | +| [`src/types.ts`](src/types.ts) | 协议映射、`RemoteErrorDetailsMap`、`RemoteResult`、`RemoteStream`、`RemoteStreamHandle`、`PrincipalMap`、`Principal`、`PeerScope`、`RemoteInvocation`、`InvocationDescriptor`、编解码器、提供方约定、注册表接口、`TypertClientRemote` | @@ -152,7 +162,7 @@ Host 装配以转发给消费方的 Cordis 事件扩展 `TypertRemoteEventSelect 这些限制说明声明能表示什么;它们是当前包约束,不是任务积压。 -- **装饰器标记是最小化的**——标记包含方法名、可选的导出别名与流模式,以及直接或 Context 接收器选择;参数、结果、查找、schema 与文档元数据需要 Typert 构建流水线。 +- **装饰器标记是最小化的**——标记包含方法名、可选的导出别名、流模式、资源读取函数,以及直接或 Context 接收器选择;参数、结果、查找、schema 与文档元数据需要 Typert 构建流水线。 - **Remote 签名受限**——装饰器只接受具有字符串名称的公开、非静态实例方法,源码模式执行无法表示重载、解构、默认参数或剩余参数签名。 diff --git a/packages/typert/protocol/src/index.ts b/packages/typert/protocol/src/index.ts index e6a7a91c02..0d5c99621d 100644 --- a/packages/typert/protocol/src/index.ts +++ b/packages/typert/protocol/src/index.ts @@ -6,7 +6,12 @@ */ import { Context, Service } from '@deepseek-ai/cordis' -import type { TypertContextMap } from './types.ts' +import type { + RemoteWireArgs, + TypertContextMap, + TypertResourceReader, + TypertResourceReaders, +} from './types.ts' export { RemoteError, remoteErrorOf } from './remote-error.ts' export { TYPERT_OWNED_VALUE, isTypertOwnedValue, typertOwnedValue } from './owned-value.ts' @@ -14,6 +19,7 @@ export type { TypertOwnedValue } from './owned-value.ts' export { isRemoteJsonValue, isRemoteUplinkItem } from './json-value.ts' export { createTypertRemoteInfo, isTypertRemoteMethod } from './remote-info.ts' export { principalOf } from './principal.ts' +export type { RemoteAccessResource, RemoteAccessResourceKind, RemoteAccessResourceOf } from './remote-access.ts' const TYPERT_REMOTE_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/ @@ -43,6 +49,8 @@ export type { RemoteStream, RemoteStreamHandle, RemoteUplinkWindow, + RemoteWireArgs, + RemoteWireValue, TypertClientEventListener, TypertClientRemote, TypertClientContextAdapter, @@ -56,6 +64,15 @@ export type { TypertForwardableEventEntry, TypertHostContextAdapter, TypertHostContextResolver, + TypertResource, + TypertResourceDeclaration, + TypertResourceEntry, + TypertResourceId, + TypertResourceKind, + TypertResourceMap, + TypertResourceReader, + TypertResourceReaders, + TypertResourceRegistry, TypertLocalRegistry, TypertLookup, TypertLookupDefinition, @@ -116,16 +133,55 @@ export interface RemoteMethodMarker { /** Stream methods yield many independently validated result items. */ readonly mode?: 'stream' readonly invocation: RemoteInvocationMarker + /** + * Declared `resources` readers keyed by resource kind. Callers spread the + * method's wire arguments into each one and validate its result. + */ + readonly resources?: Readonly> } -/** Explicit endpoint and stream choices for one Remote method. */ -export interface RemoteMethodOptions { +/** + * Explicit endpoint and resource choices for one Remote Scope method. + * @template WireArgs - the decorated method's positional parameters as the + * wire carries them; the default `never` admits readers of any tuple. + */ +export interface RemoteScopeOptions { /** Exported endpoint method; omission retains the implementation member name. */ readonly name?: string + /** + * Per-kind readers of the resources one call touches. Each receives the + * method's arguments as its own parameters in undecoded wire JSON form, a + * lookup parameter as its wire identity and a cancellation parameter as the + * call's signal, so it performs plain reads only, and returns one identity, + * several, or `undefined` for none. A throw, an empty list, or an identity + * other than a non-empty string refuses the call. For a kind without a + * reader, the Gateway reads the identity of a scoped Context whose Host + * adapter declares that `resource` and of lookup parameters declaring it, + * else the field registered in `typert.resources`: a top-level wire + * argument or, failing that, the first object argument. A call that carries + * no identity touches `global`. The readers and these defaults together + * must name every resource the method accesses: access control never checks + * a resource they omit. + */ + readonly resources?: TypertResourceReaders +} + +/** + * Explicit endpoint, stream, and resource choices for one direct Remote method. + * @template WireArgs - the decorated method's positional parameters as the + * wire carries them; the default `never` admits readers of any tuple. + */ +export interface RemoteMethodOptions extends RemoteScopeOptions { /** `stream`: deliver each Iterable item over the shared logical-stream carrier. */ readonly mode?: 'stream' } +/** A decorator whose `resources` readers were checked against the decorated method's parameters. */ +type RemoteTypedMethodDecorator = ( + method: (this: This, ...args: Args) => Result, + context: ClassMethodDecoratorContext Result>, +) => void + type RemoteMethodDecorator = ( method: (this: This, ...args: Args) => Result, context: ClassMethodDecoratorContext Result>, @@ -142,12 +198,14 @@ interface StoredRemoteMethodMarker { readonly exportName?: string readonly mode?: 'stream' readonly invocation: RemoteInvocationMarker + readonly resources?: Readonly> } /** Marker fields selected by decorator arguments. */ interface RemoteMarkerOptions { readonly mode?: 'stream' readonly exportName?: string + readonly resources?: Readonly> } interface StoredRemoteMethod extends StoredRemoteMethodMarker { @@ -222,11 +280,19 @@ export function Remote( context: ClassMethodDecoratorContext Result>, ): void /** - * Mark one public instance method with an endpoint name or stream delivery. - * @param option - endpoint name or Remote options for name and mode. + * Mark one public instance method with an endpoint name. + * @param name - exported endpoint method. * @returns a standard method decorator. */ -export function Remote(option: string | RemoteMethodOptions): RemoteMethodDecorator +export function Remote(name: string): RemoteMethodDecorator +/** + * Mark one public instance method with endpoint, stream, or resource options. + * @param options - Remote options; each reader's parameters are inferred from the decorated method. + * @returns a standard method decorator. + */ +export function Remote( + options: NoInfer>>, +): RemoteTypedMethodDecorator export function Remote( methodExportOrOptions: string | RemoteMethodOptions | ((this: This, ...args: Args) => Result), context?: ClassMethodDecoratorContext Result>, @@ -236,25 +302,46 @@ export function Remote( return remoteDecorator({ kind: 'direct' }, { exportName: methodExportOrOptions }) } if (typeof methodExportOrOptions === 'object') { - validateRemoteOptions(methodExportOrOptions) - return remoteDecorator({ kind: 'direct' }, { - ...(methodExportOrOptions.mode === undefined ? {} : { mode: methodExportOrOptions.mode }), - ...(methodExportOrOptions.name === undefined ? {} : { exportName: methodExportOrOptions.name }), - }) + return remoteDecorator({ kind: 'direct' }, markerOptions( + methodExportOrOptions, + REMOTE_OPTION_KEYS, + 'Remote options require name, mode: "stream", or resources', + )) } if (context === undefined) throw new TypeError('typert-protocol: Remote decorator context is missing') addMarkerInitializer(context, { kind: 'direct' }) } -const REMOTE_OPTION_KEYS = new Set(['name', 'mode']) +const REMOTE_OPTION_KEYS: ReadonlySet = new Set(['name', 'mode', 'resources']) +const REMOTE_SCOPE_OPTION_KEYS: ReadonlySet = new Set(['name', 'resources']) -function validateRemoteOptions(options: RemoteMethodOptions): void { +/** Validate one decorator options object against its accepted keys and select the marker fields. */ +function markerOptions(options: RemoteMethodOptions, accepted: ReadonlySet, requirement: string): RemoteMarkerOptions { const keys = Reflect.ownKeys(options) - if (keys.length === 0 || keys.some(key => typeof key !== 'string' || !REMOTE_OPTION_KEYS.has(key)) + if (keys.length === 0 || keys.some(key => typeof key !== 'string' || !accepted.has(key)) || (Object.hasOwn(options, 'mode') && options.mode !== 'stream')) { - throw new TypeError('typert-protocol: Remote options require name or mode: "stream"') + throw new TypeError(`typert-protocol: ${requirement}`) } - if (options.name !== undefined) validateName('Remote export name', options.name) + const { mode, name, resources } = options + if (name !== undefined) validateName('Remote export name', name) + return { + ...(mode === undefined ? {} : { mode }), + ...(name === undefined ? {} : { exportName: name }), + ...(resources === undefined ? {} : { resources: resourceReaders(resources) }), + } +} + +/** Freeze declared readers into the marker record; a misdeclared reader fails at decoration. */ +function resourceReaders(resources: object): Readonly> { + const readers: Record = {} + for (const [kind, reader] of Object.entries(resources as Readonly>)) { + if (typeof reader !== 'function') { + throw new TypeError(`typert-protocol: Remote resource reader "${kind}" must be a function`) + } + readers[kind] = reader as TypertResourceReader + } + if (Object.keys(readers).length === 0) throw new TypeError('typert-protocol: Remote resources require at least one reader') + return Object.freeze(readers) } function remoteDecorator( @@ -272,16 +359,36 @@ function remoteDecorator( /** * Create a decorator for a method resolved from one Remote Scope. * @param key - scope key declared through the Context map. - * @param exportName - optional Remote export name; defaults to the method name. + * @param name - optional Remote export name; defaults to the method name. * @returns a standard method decorator that records a versioned prototype descriptor. */ +export function RemoteScope(key: Extract, name?: string): RemoteMethodDecorator +/** + * Create a decorator for a Remote Scope method with endpoint or resource options. + * @param key - scope key declared through the Context map. + * @param options - Remote Scope options; each reader's parameters are inferred from the decorated method. + * @returns a standard method decorator that records a versioned prototype descriptor. + */ +export function RemoteScope( + key: Extract, + options: NoInfer>>, +): RemoteTypedMethodDecorator export function RemoteScope( key: Extract, - exportName?: string, + option?: string | RemoteScopeOptions, ): RemoteMethodDecorator { validateName('Scope key', key) - if (exportName !== undefined) validateName('Remote export name', exportName) - return remoteDecorator({ kind: 'context', context: key }, exportName === undefined ? {} : { exportName }) + const invocation: RemoteInvocationMarker = { kind: 'context', context: key } + if (option === undefined) return remoteDecorator(invocation) + if (typeof option === 'string') { + validateName('Remote export name', option) + return remoteDecorator(invocation, { exportName: option }) + } + return remoteDecorator(invocation, markerOptions( + option, + REMOTE_SCOPE_OPTION_KEYS, + 'RemoteScope options require name or resources', + )) } /** @@ -336,7 +443,7 @@ function mark( prototype: object, method: string, invocation: RemoteInvocationMarker, - { mode, exportName }: RemoteMarkerOptions, + { mode, exportName, resources }: RemoteMarkerOptions, ): void { const descriptor = readRemoteMethodDescriptor(prototype) const marker: StoredRemoteMethod = Object.freeze({ @@ -344,11 +451,14 @@ function mark( ...(exportName === undefined || exportName === method ? {} : { exportName }), ...(mode === undefined ? {} : { mode }), invocation: Object.freeze(invocation), + ...(resources === undefined ? {} : { resources }), }) const current = descriptor?.methods.find(candidate => candidate.method === method) if (current !== undefined) { + // One decorator application owns one frozen reader record, so identity marks the same declaration. if (current.exportName === marker.exportName && current.mode === marker.mode + && current.resources === marker.resources && sameInvocation(current.invocation, invocation)) return throw new Error(`typert-protocol: Remote method "${method}" has conflicting invocation markers`) } diff --git a/packages/typert/protocol/src/remote-access.ts b/packages/typert/protocol/src/remote-access.ts new file mode 100644 index 0000000000..0e9d785bab --- /dev/null +++ b/packages/typert/protocol/src/remote-access.ts @@ -0,0 +1,38 @@ +/** + * Resources of Remote access control: the built-in identity-less `global` + * resource and one resource per identity of a kind declared in + * `TypertResourceMap`. Resources are independent: access to one resource + * implies no access to any other. + * @module @deepseek-ai/dsh-typert-protocol/remote-access + */ + +/* oxlint-disable typescript/no-redundant-type-constituents -- + * `TypertResourceKind` is the declare-merge key pattern: the resource map is + * empty in this compilation unit, so the rule sees `never`, while every + * domain that declares a kind merges its key in. */ +import type { + TypertResourceId, + TypertResourceKind, + TypertResourceMap, +} from './types.ts' + +/** One resource kind: the built-in `global` kind or a declared kind. */ +export type RemoteAccessResourceKind = 'global' | TypertResourceKind + +/** + * One resource a call or forwarded event touches: `global`, or one identity + * of a declared kind. Identities are wire values, read before any lookup + * resolves or validates them. + */ +export type RemoteAccessResource = + | { readonly kind: 'global' } + | { + [Kind in TypertResourceKind]: { readonly kind: Kind; readonly id: TypertResourceId } + }[TypertResourceKind] + +/** + * The resource variant of one kind: `{ kind: 'global' }` for `global`, else + * that kind with its identity. + * @template K - the resource kind to select. + */ +export type RemoteAccessResourceOf = Extract diff --git a/packages/typert/protocol/src/types.ts b/packages/typert/protocol/src/types.ts index d82b46c44f..50692ae45c 100644 --- a/packages/typert/protocol/src/types.ts +++ b/packages/typert/protocol/src/types.ts @@ -12,6 +12,7 @@ import type { TypertOwnedValue } from './owned-value.ts' declare const LOOKUP_HOST: unique symbol declare const LOOKUP_WIRE: unique symbol declare const CONTEXT_WIRE: unique symbol +declare const RESOURCE_ID: unique symbol /** Type-level association between a Host object and its wire identity. */ export interface TypertLookup { @@ -39,6 +40,58 @@ export interface TypertLookupMap {} /** Merge-extensible scoped Context declarations. */ export interface TypertContextMap {} +/** Type-level association between a resource kind and its identity type. */ +export interface TypertResource { + readonly [RESOURCE_ID]: Id +} + +/** Extract the identity type associated with one resource declaration. */ +export type TypertResourceId = Resource extends TypertResource ? Id : never + +/** + * Merge-extensible resource kinds whose identities a Remote call or forwarded + * event may carry. The package that owns an identity type declares its kind; + * the value is that kind's identity type, a string type such as a branded id. + * `global` names the built-in identity-less resource and is never a kind here. + */ +export interface TypertResourceMap {} + +/** One declared resource kind. */ +export type TypertResourceKind = Extract + +/** + * Per-kind resource readers called with one argument tuple spread + * positionally: plain reads returning one identity, several, or `undefined` + * for none. + * @template Args - the argument tuple the readers receive as parameters. + */ +export type TypertResourceReaders = { + readonly [Kind in TypertResourceKind]?: (...args: Args) => + | TypertResourceId + | readonly TypertResourceId[] + | undefined +} + +/** + * Type-erased resource reader as runtime carriers store it; callers spread + * the argument tuple into it and validate its result. Declared through a + * method so a reader typed by its own parameters is assignable without a cast. + */ +export type TypertResourceReader = { read(...args: readonly unknown[]): unknown }['read'] + +type TypertLookupEntry = TypertLookupMap[keyof TypertLookupMap] +type TypertWireForHost = Entry extends unknown + ? Value extends TypertLookupHost ? TypertLookupWire : never + : never + +/** A method parameter as it arrives on the wire: a lookup's Host object becomes its wire identity. */ +export type RemoteWireValue = Value extends TypertLookupHost + ? TypertWireForHost + : Value + +/** A method's positional parameters as the wire carries them. */ +export type RemoteWireArgs = { [Index in keyof Args]: RemoteWireValue } + /** Merge-extensible direct Remote method signatures generated for consumers. */ export interface TypertRemoteMap {} @@ -170,12 +223,24 @@ export type TypertForwardableEvent = { [Event in keyof Events]: TypertForwardingMode extends never ? never : Event }[keyof Events] -/** Event and dispatch mode accepted by the Remote Event source. */ +/** + * Event and dispatch mode accepted by the Remote Event source. An `emit` entry + * may declare readers of the resources its payload carries; an + * entry without `resources` touches the built-in `global` resource. A + * non-owner Peer receives an `emit` notification only after an access + * decision and never receives a `waterfall`. + */ export type TypertForwardableEventEntry = { [Event in keyof Events]: TypertForwardingMode extends infer Mode - ? Mode extends 'emit' | 'waterfall' - ? { readonly event: Event; readonly mode: Mode } - : never + ? Mode extends 'emit' + ? { + readonly event: Event + readonly mode: Mode + readonly resources?: TypertResourceReaders> + } + : Mode extends 'waterfall' + ? { readonly event: Event; readonly mode: Mode } + : never : never }[keyof Events] @@ -578,12 +643,19 @@ export type TypertLookupResolver = ( id: Wire, ) => Host | undefined | Promise +/** Resource kinds whose identity type is a wire identity; every kind for a type-erased provider. */ +type TypertResourceKindFor = unknown extends Wire + ? TypertResourceKind + : { [Kind in TypertResourceKind]: [Wire] extends [TypertResourceId] ? Kind : never }[TypertResourceKind] + /** Runtime provider for one declared Host object lookup. */ export interface TypertLookupProvider { /** Source parameter name recognized by the SRC weak parser. */ readonly parameter: string /** Wire field replacing the Host object parameter. */ readonly wire: string + /** Resource kind the wire identity names; a parameter of this lookup carries that resource. */ + readonly resource?: TypertResourceKindFor /** Canonical Host type symbol used by strict generation. */ readonly hostTypeSymbol: string /** Canonical wire type symbol used by strict generation. */ @@ -616,6 +688,8 @@ export interface TypertHostContextAdapter { readonly wire: string /** Canonical wire type symbol used by strict generation. */ readonly wireTypeSymbol: string + /** Resource kind the wire identity names; a call scoped to this Context carries that resource. */ + readonly resource?: TypertResourceKindFor /** * Resolve a validated wire identity to a live Host Context. * @param id - validated wire identity. @@ -647,7 +721,7 @@ export interface TypertClientContextAdapter { /** Notification emitted after a Typert runtime registry changes. */ export interface TypertRegistryChange { - readonly kind: 'local' | 'remote' | 'lookup' | 'host-context' | 'client-context' + readonly kind: 'local' | 'remote' | 'lookup' | 'host-context' | 'client-context' | 'resource' readonly key: string } @@ -783,7 +857,7 @@ export interface TypertContextRegistry { adapter: TypertClientContextAdapter>, ): TypertDisposer /** - * Look up a Host Context adapter. + * Look up a Host Context adapter; a configured resolver keeps the adapter's declaration and `resource`. * @param key - descriptor Context key. * @returns the adapter, or `undefined` when absent. */ @@ -802,12 +876,52 @@ export interface TypertContextRegistry { subscribe(listener: TypertRegistryListener): TypertDisposer } +/** Default wire field of one resource kind. */ +export interface TypertResourceDeclaration { + /** Top-level or first-object wire field holding one identity of the kind. */ + readonly field: string +} + +/** One registered resource kind with its default wire field. */ +export interface TypertResourceEntry extends TypertResourceDeclaration { + /** Registered resource kind. */ + readonly kind: string +} + +/** Runtime registry of resource kinds' default wire fields. */ +export interface TypertResourceRegistry { + /** + * Register one kind's default wire field for the calling fiber. Only the + * domain that owns the kind registers it. + * @param kind - merge-declared resource kind. + * @param declaration - the kind's default wire field. + * @returns disposer withdrawing the exact registration. + * @throws Error when the kind is already registered or is `global`, the built-in resource kind. + */ + register(kind: TypertResourceKind, declaration: TypertResourceDeclaration): TypertDisposer + /** + * Look up one kind's declaration. + * @param kind - resource kind. + * @returns the live declaration, or `undefined` when unregistered. + */ + get(kind: string): TypertResourceDeclaration | undefined + /** @returns a registration-order snapshot of registered kinds with their fields. */ + entries(): readonly TypertResourceEntry[] + /** + * Observe later resource-kind changes. + * @param listener - synchronous contained observer. + * @returns disposer for this subscription. + */ + subscribe(listener: TypertRegistryListener): TypertDisposer +} + /** Minimal Typert runtime consumed through dependency inversion. */ export interface TypertRegistryContract { readonly local: TypertLocalRegistry readonly remotes: TypertRemoteRegistry readonly lookups: TypertLookupRegistry readonly contexts: TypertContextRegistry + readonly resources: TypertResourceRegistry } declare module '@deepseek-ai/cordis' { diff --git a/packages/typert/protocol/tests/protocol.spec.ts b/packages/typert/protocol/tests/protocol.spec.ts index b163009e66..78a7942ba7 100644 --- a/packages/typert/protocol/tests/protocol.spec.ts +++ b/packages/typert/protocol/tests/protocol.spec.ts @@ -12,6 +12,7 @@ import { type TypertContext, type TypertForwardableEvent, type TypertForwardableEventEntry, + type TypertResource, type TypertLookup, type TypertRemoteEvent, } from '@deepseek-ai/dsh-typert-protocol' @@ -71,6 +72,11 @@ declare module '@deepseek-ai/dsh-typert-protocol' { otherFixture: TypertContext } + interface TypertResourceMap { + /** Test-only resource kind read from fixture event payloads. */ + 'meta-fixture-id': TypertResource + } + interface TypertRemoteEventSelection extends Record<'meta-fixture/forwardable' | 'meta-fixture/waterfall' | 'meta-fixture/absent', true> {} } @@ -245,7 +251,7 @@ describe('typert-protocol Remote declarations', () => { follow(): void {} } const initializers: Array<(this: Jobs) => void> = [] - Remote({ mode: 'stream' })(Reflect.get(Jobs.prototype, 'follow'), methodContext('follow', initializers)) + 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([ @@ -309,7 +315,7 @@ describe('typert-protocol Remote declarations', () => { expect(() => Remote({ mode: 'stream', extra: true } as unknown as { mode: 'stream' })).toThrow('Remote options require') const bogusMode: string = 'duplex' expect(() => Remote({ mode: bogusMode } as { mode: 'stream' })) - .toThrow('Remote options require name or mode: "stream"') + .toThrow('Remote options require name, mode: "stream", or resources') expect(() => Remote({})).toThrow('Remote options require') expect(() => RemoteScope('' as 'metaFixture')).toThrow('Scope key') expect(() => RemoteScope('metaFixture', 'bad/name')).toThrow('export name') @@ -431,6 +437,25 @@ describe('typert-protocol Remote declarations', () => { .not.toExtend() }) + it('types the resource readers of an emit entry by its own event parameters', () => { + const entries = [ + { + event: 'meta-fixture/forwardable', + mode: 'emit', + resources: { 'meta-fixture-id': (value) => { expectTypeOf(value).toEqualTypeOf(); return value } }, + }, + { event: 'meta-fixture/waterfall', mode: 'waterfall' }, + ] as const satisfies readonly TypertForwardableEventEntry[] + const wrong = [{ + event: 'meta-fixture/forwardable', + mode: 'emit', + // @ts-expect-error -- a reader returns the kind's identity type, not the payload's length. + resources: { 'meta-fixture-id': value => value.length }, + }] as const satisfies readonly TypertForwardableEventEntry[] + expect(entries[0].resources['meta-fixture-id']('s1')).toBe('s1') + expect(wrong).toHaveLength(1) + }) + it('derives Client Context arguments from the selected Cordis waterfall declaration', () => { type ExpectedListener = ( this: Context, diff --git a/packages/typert/protocol/tests/remote-access.spec.ts b/packages/typert/protocol/tests/remote-access.spec.ts new file mode 100644 index 0000000000..15f9f47d6d --- /dev/null +++ b/packages/typert/protocol/tests/remote-access.spec.ts @@ -0,0 +1,267 @@ +import { describe, expect, expectTypeOf, it } from 'vitest' +import { + Remote, + RemoteScope, + principalOf, + remoteMethods, + type PeerScope, + type Principal, + type RemoteAccessResource, + type RemoteAccessResourceKind, + type RemoteAccessResourceOf, + type RemoteInvocation, + type RemoteMethodMarker, + type RemoteScopeOptions, + type TypertContext, + type TypertResource, + type TypertResourceReader, + type TypertLookup, +} from '@deepseek-ai/dsh-typert-protocol' + +type WireSessionId = string & { readonly __wire: 'protocol-access-session' } + +/** Its member name is unique, so no other spec's lookup Host type matches it structurally. */ +interface AccessFixtureAgent { + readonly protocolAccessAgentId: WireSessionId +} + +declare module '@deepseek-ai/dsh-typert-protocol' { + interface PrincipalMap { + /** Test-only non-owner caller shared by the access specs. */ + 'test-guest': { readonly kind: 'test-guest'; readonly guest: string } + } + interface TypertLookupMap { + protocolAccessAgent: TypertLookup + } + interface TypertContextMap { + protocolAccessRoom: TypertContext + } + interface TypertResourceMap { + /** Test-only resource kind whose identity is the fixture's wire id. */ + 'test-room': TypertResource + } +} + +const GUEST: Principal = { kind: 'test-guest', guest: 'g1' } +const OWNER: Principal = { kind: 'owner' } + +function invocationAs(principal: Principal): RemoteInvocation { + const peer = { principal } as PeerScope + return { peer } as RemoteInvocation +} + +function markerOf(service: object, method: string): RemoteMethodMarker { + const marker = remoteMethods(service).find(candidate => candidate.method === method) + if (marker === undefined) throw new Error(`${method} is not marked`) + return marker +} + +function readerOf(marker: RemoteMethodMarker, kind: string): TypertResourceReader { + const reader = marker.resources?.[kind] + if (reader === undefined) throw new Error(`${marker.method} declares no ${kind} reader`) + return reader +} + +describe('principalOf', () => { + it('reports a non-owner caller and nothing for the owner or code outside a call', () => { + expect(principalOf(undefined)).toBeUndefined() + expect(principalOf(invocationAs(OWNER))).toBeUndefined() + expect(principalOf(invocationAs(GUEST))).toBe(GUEST) + const lookalike = JSON.parse('{"kind":"Owner"}') as Principal + expect(principalOf(invocationAs(lookalike))).toBe(lookalike) + }) +}) + +describe('RemoteAccessResource', () => { + it('names the identity-less global resource or one identity of a declared kind', () => { + expectTypeOf<'global'>().toExtend() + expectTypeOf<'test-room'>().toExtend() + expectTypeOf<{ readonly kind: 'global' }>().toExtend() + expectTypeOf<{ readonly kind: 'test-room'; readonly id: WireSessionId }>().toExtend() + expectTypeOf<{ readonly kind: 'test-room' }>().not.toExtend() + expectTypeOf<{ readonly kind: 'test-room'; readonly id: string }>().not.toExtend() + expectTypeOf>().toEqualTypeOf<{ readonly kind: 'global' }>() + expectTypeOf>().toEqualTypeOf<{ readonly kind: 'test-room'; readonly id: WireSessionId }>() + }) +}) + +describe('@Remote resources', () => { + class Marked { + @Remote + plain(request: { readonly sessionId: string }): string { return request.sessionId } + + @Remote({ resources: { 'test-room': agent => agent } }) + lookup(agent: AccessFixtureAgent): string { return agent.protocolAccessAgentId } + + @Remote({ name: 'rooms', mode: 'stream', resources: { 'test-room': request => request.rooms } }) + *watch(request: { readonly rooms: readonly WireSessionId[] }): Iterable { void request } + } + + it('records frozen readers on the Remote marker when the Service is constructed', () => { + const service = new Marked() + expect(markerOf(service, 'plain')).toEqual({ method: 'plain', invocation: { kind: 'direct' } }) + const lookup = markerOf(service, 'lookup') + expect(Object.isFrozen(lookup.resources)).toBe(true) + expect(readerOf(lookup, 'test-room')('s1')).toBe('s1') + const watch = markerOf(service, 'watch') + expect(watch).toMatchObject({ method: 'watch', exportName: 'rooms', mode: 'stream', invocation: { kind: 'direct' } }) + expect(readerOf(watch, 'test-room')({ rooms: ['s1', 's2'] })).toEqual(['s1', 's2']) + expect(remoteMethods(new Marked())).toEqual(remoteMethods(service)) + }) + + it('rejects resources without a reader function at decoration time', () => { + expect(() => Remote({ resources: {} })).toThrow('Remote resources require at least one reader') + const notReader: unknown = 'session-id' + expect(() => Remote({ resources: { 'test-room': notReader as () => undefined } })) + .toThrow('Remote resource reader "test-room" must be a function') + }) + + it('rejects a second declaration of one method with other readers', () => { + const initializers: Array<(this: object) => void> = [] + const context = (): ClassMethodDecoratorContext unknown> => ({ + kind: 'method', name: 'run', private: false, static: false, metadata: {}, + access: { has: () => true, get: () => () => undefined }, + addInitializer: (initializer) => { initializers.push(initializer) }, + }) + const method = function (this: object): void {} + const first = Remote({ resources: { 'test-room': () => undefined } }) + first(method, context()) + first(method, context()) + Remote({ resources: { 'test-room': () => undefined } })(method, context()) + const service = Object.create({}) as object + initializers[0]!.call(service) + initializers[1]!.call(service) + expect(() => { initializers[2]!.call(service) }).toThrow('conflicting invocation markers') + }) + + it('infers each reader argument from the decorated method, mapping lookups to wire identities', () => { + class Inferred { + @Remote({ + resources: { + 'test-room': (agent, request) => { + expectTypeOf(agent).toEqualTypeOf() + expectTypeOf(request).toEqualTypeOf<{ readonly rooms: readonly WireSessionId[] }>() + return request.rooms + }, + }, + }) + both(agent: AccessFixtureAgent, request: { readonly rooms: readonly WireSessionId[] }): void { void agent; void request } + + @Remote({ resources: { 'test-room': (agent?) => { expectTypeOf(agent).toEqualTypeOf(); return agent } } }) + optional(agent?: AccessFixtureAgent): void { void agent } + + @Remote({ + mode: 'stream', + resources: { + 'test-room': (request, signal) => { + expectTypeOf(signal).toEqualTypeOf() + return request.address.kind === 'room' ? request.address.roomId : undefined + }, + }, + }) + *stream( + request: { readonly address: { kind: 'room'; roomId: WireSessionId } | { kind: 'child'; childId: string } }, + signal: AbortSignal, + ): Iterable { void request; void signal } + + @Remote({ + resources: { + 'test-room': (request) => { + expectTypeOf(request).toEqualTypeOf<{ readonly roomId: WireSessionId }>() + return request.roomId + }, + }, + }) + fewer(request: { readonly roomId: WireSessionId }, signal: AbortSignal): void { void request; void signal } + + // @ts-expect-error -- `nope` is not a field of the decorated method's request. + // oxlint-disable-next-line typescript/no-unsafe-return -- the compile error above is the assertion. + @Remote({ resources: { 'test-room': request => request.nope } }) + missing(request: { readonly roomId: WireSessionId }): void { void request } + + // @ts-expect-error -- the decorated method has no second argument. + // oxlint-disable-next-line typescript/no-confusing-void-expression -- the failed overload resolution above is the assertion. + @Remote({ resources: { 'test-room': (request, extra: WireSessionId) => extra } }) + outOfRange(request: { readonly roomId: WireSessionId }): void { void request } + + // @ts-expect-error -- the decorated method has no parameter after its signal. + // oxlint-disable-next-line typescript/no-confusing-void-expression -- the failed overload resolution above is the assertion. + @Remote({ resources: { 'test-room': (request, signal, extra: WireSessionId) => extra } }) + afterSignal(request: { readonly roomId: WireSessionId }, signal: AbortSignal): void { void request; void signal } + + // @ts-expect-error -- a reader for this kind returns its identity type, not a number. + // oxlint-disable-next-line typescript/no-confusing-void-expression -- the failed overload resolution above is the assertion. + @Remote({ resources: { 'test-room': () => 7 } }) + wrongIdentity(): void {} + + // @ts-expect-error -- `global` is a built-in resource with no identity, not a declared kind. + // oxlint-disable-next-line typescript/no-confusing-void-expression -- the failed overload resolution above is the assertion. + @Remote({ resources: { global: () => undefined } }) + globalReader(): void {} + } + const inferred = new Inferred() + expect(markerOf(inferred, 'both').resources).toBeDefined() + expect(readerOf(markerOf(inferred, 'fewer'), 'test-room')({ roomId: 'r1' }, new AbortController().signal)).toBe('r1') + }) +}) + +describe('@RemoteScope resources', () => { + class Scoped { + @RemoteScope('protocolAccessRoom') + plain(): void {} + + @RemoteScope('protocolAccessRoom', { name: 'inspect' }) + named(): void {} + + @RemoteScope('protocolAccessRoom', { + name: 'move', + resources: { + 'test-room': (request) => { + expectTypeOf(request).toEqualTypeOf<{ readonly targetRoomId: WireSessionId }>() + return request.targetRoomId + }, + }, + }) + relocate(request: { readonly targetRoomId: WireSessionId }): void { void request } + + // @ts-expect-error -- `nope` is not a field of the decorated method's request. + // oxlint-disable-next-line typescript/no-unsafe-return -- the compile error above is the assertion. + @RemoteScope('protocolAccessRoom', { resources: { 'test-room': request => request.nope } }) + missing(request: { readonly roomId: WireSessionId }): void { void request } + + // @ts-expect-error -- a reader for this kind returns its identity type, not a number. + @RemoteScope('protocolAccessRoom', { resources: { 'test-room': () => 7 } }) + wrongIdentity(): void {} + + @RemoteScope('protocolAccessRoom', { resources: { 'test-room': request => request.roomId } }) + fewer(request: { readonly roomId: WireSessionId }, signal: AbortSignal): void { void request; void signal } + + // @ts-expect-error -- the decorated method has no parameter after its signal. + @RemoteScope('protocolAccessRoom', { resources: { 'test-room': (request, signal, extra: WireSessionId) => extra } }) + afterSignal(request: { readonly roomId: WireSessionId }, signal: AbortSignal): void { void request; void signal } + + // @ts-expect-error -- no package declares this resource kind. + @RemoteScope('protocolAccessRoom', { resources: { 'protocol-undeclared-kind': () => undefined } }) + undeclaredKind(): void {} + } + + it('records the export name and readers on the scoped Remote marker', () => { + const service = new Scoped() + const invocation = { kind: 'context', context: 'protocolAccessRoom' } + expect(markerOf(service, 'plain')).toEqual({ method: 'plain', invocation }) + expect(markerOf(service, 'named')).toEqual({ method: 'named', exportName: 'inspect', invocation }) + const relocate = markerOf(service, 'relocate') + expect(relocate).toMatchObject({ method: 'relocate', exportName: 'move', invocation }) + expect(Object.isFrozen(relocate.resources)).toBe(true) + expect(readerOf(relocate, 'test-room')({ targetRoomId: 'r2' })).toBe('r2') + }) + + it('rejects options a Remote Scope method cannot carry', () => { + // @ts-expect-error -- a Remote Scope method has no stream option. + expect(() => RemoteScope('protocolAccessRoom', { mode: 'stream' })).toThrow('RemoteScope options require name or resources') + const empty: object = {} + expect(() => RemoteScope('protocolAccessRoom', empty as RemoteScopeOptions)).toThrow('RemoteScope options require name or resources') + expect(() => RemoteScope('protocolAccessRoom', { name: 'bad/name' })).toThrow('export name') + expect(() => RemoteScope('protocolAccessRoom', { resources: {} })).toThrow('Remote resources require at least one reader') + }) +}) diff --git a/packages/typert/registry/README.i18n.yaml b/packages/typert/registry/README.i18n.yaml index 22ef5546f7..51b80ad117 100644 --- a/packages/typert/registry/README.i18n.yaml +++ b/packages/typert/registry/README.i18n.yaml @@ -27,8 +27,8 @@ en: 2b2f750a1b145d85 zh: d15c55c01af1755a /deepseek-ai-dsh-typert-registry/use-this-package/lookup-and-context-providers: - en: bc9ea8f035ec1199 - zh: d25f1dc16cfc8654 + en: 937dfd14d9e18a5c + zh: 6cf6aa352c8825c6 /deepseek-ai-dsh-typert-registry/understand-the-implementation: en: 1a78dfbae50a8b0d zh: dc10b4b3f3d832ed @@ -36,8 +36,8 @@ en: 4d50f3efeb45eaa4 zh: ec3575d29d5a040b /deepseek-ai-dsh-typert-registry/understand-the-implementation/sub-registries: - en: cc0deb5e1ae93fd6 - zh: 4bb6c88213262afe + en: d6007eef4d33f3a4 + zh: 499ff578453d5cc0 /deepseek-ai-dsh-typert-registry/understand-the-implementation/identity-and-validation: en: 4764fc33a7e16e60 zh: 1ddf03cbe16197da diff --git a/packages/typert/registry/README.md b/packages/typert/registry/README.md index f7c654dc42..19d3f32ecf 100644 --- a/packages/typert/registry/README.md +++ b/packages/typert/registry/README.md @@ -49,6 +49,8 @@ Generated artifacts register through the [loader](../loader/README.md) in Loader Remote calls resolve Host objects and scoped Contexts through `ctx.typert.lookups` and `ctx.typert.contexts`. `registerHost()` installs the Host wire declaration and its wire-to-Context resolver, while `configureHost()` replaces only that resolver. `registerClient()` installs the bidirectional Client adapter for the same merge-declared kind. Host-to-Client event sources carry their domain identity explicitly instead of projecting it back from a Context. +`ctx.typert.resources` holds the default wire field of each resource kind declared in `TypertResourceMap`. The domain that owns a kind registers it with `register(kind, { field })` as a Cordis effect of the calling fiber; a second registration of one kind throws, and so does `global`, the built-in identity-less resource kind. `get(kind)` reads one declaration, and `entries()` lists every registered `{ kind, field }` in registration order. A lookup provider and a Host Context adapter may name the resource kind of their wire identity through `resource`, and a configured resolver keeps it. The Gateway reads all three when it resolves the resources of a non-owner call. + ----- @@ -69,6 +71,7 @@ The registry is built on one principle: a contribution is one atomic, fiber-owne - `ctx.typert.remotes` — consumer-selected contributions mounted in the calling fiber. - `ctx.typert.lookups` — lookup providers plus composition-owned resolver overrides per key. - `ctx.typert.contexts` — Host Context providers and Client Context binders per scoped key. +- `ctx.typert.resources` — the default wire field per resource kind. Each sub-registry publishes `TypertRegistryChange` events to subscribed listeners; a throwing listener is logged and does not stop later listeners. diff --git a/packages/typert/registry/README.zh.md b/packages/typert/registry/README.zh.md index 6d117d9fa5..2c34b5df8a 100644 --- a/packages/typert/registry/README.zh.md +++ b/packages/typert/registry/README.zh.md @@ -49,6 +49,8 @@ kind: "package-reference" Remote 调用通过 `ctx.typert.lookups` 与 `ctx.typert.contexts` 解析 Host 对象与作用域 Context。`registerHost()` 安装 Host wire 声明及其 wire 到 Context 的 resolver,`configureHost()` 只替换该 resolver。`registerClient()` 为同一个 merge-declared kind 安装双向 Client adapter。Host 到 Client 的事件源显式携带领域 identity,而不从 Context 反向投影。 +`ctx.typert.resources` 保存 `TypertResourceMap` 中每个资源种类的默认 wire 字段。拥有该种类的领域以 `register(kind, { field })` 登记,登记是调用方 fiber 的 Cordis effect;同一种类重复登记即抛错,登记内置的无身份资源种类 `global` 同样抛错。`get(kind)` 读取一个种类的声明,`entries()` 按登记顺序列出全部已登记的 `{ kind, field }`。lookup provider 与 Host Context adapter 可经 `resource` 声明其 wire 身份所属的资源种类,配置的 resolver 保留该声明。Gateway 解析非 owner 调用的资源时读取这三者。 + ----- @@ -69,6 +71,7 @@ Remote 调用通过 `ctx.typert.lookups` 与 `ctx.typert.contexts` 解析 Host - `ctx.typert.remotes`——在调用方 fiber 中挂载的、由消费方选中的贡献。 - `ctx.typert.lookups`——lookup 提供方,以及按键配置的组合方解析器覆盖。 - `ctx.typert.contexts`——按作用域键配置的 Host Context 提供方与 Client Context 绑定器。 +- `ctx.typert.resources`——每个资源种类的默认 wire 字段。 每个子注册表都会向已订阅的监听器发布 `TypertRegistryChange` 事件;抛异常的监听器会被记录日志,且不会阻止后续监听器。 diff --git a/packages/typert/registry/src/service.ts b/packages/typert/registry/src/service.ts index 93a370e34f..c64817c819 100644 --- a/packages/typert/registry/src/service.ts +++ b/packages/typert/registry/src/service.ts @@ -9,6 +9,7 @@ import { Context, Service } from '@deepseek-ai/cordis' import { z } from 'zod' import type { InvocationDescriptor, + RemoteAccessResourceKind, TypertClientContextAdapter, TypertContextMap, TypertContextRegistry, @@ -16,6 +17,8 @@ import type { TypertDisposer, TypertHostContextAdapter, TypertHostContextResolver, + TypertResourceDeclaration, + TypertResourceRegistry, TypertLocalRegistry, TypertLookupHost, TypertLookupDefinition, @@ -255,6 +258,8 @@ class LookupStore { return { parameter: provider.parameter, wire: provider.wire, + // oxlint-disable-next-line typescript/no-unnecessary-condition -- domain packages merge resource kinds into TypertResourceMap. + ...provider.resource === undefined ? {} : { resource: provider.resource }, hostTypeSymbol: provider.hostTypeSymbol, wireTypeSymbol: provider.wireTypeSymbol, resolve: id => resolver.resolve(id), @@ -372,6 +377,8 @@ class ContextStore { return { wire: adapter.wire, wireTypeSymbol: adapter.wireTypeSymbol, + // oxlint-disable-next-line typescript/no-unnecessary-condition -- domain packages merge resource kinds into TypertResourceMap. + ...adapter.resource === undefined ? {} : { resource: adapter.resource }, resolve: id => resolver.resolve(id), } } @@ -439,6 +446,48 @@ interface HostContextResolverEntry { resolve(id: unknown): Promise } +/** The built-in identity-less resource kind; no declared resource kind may take its name. */ +const GLOBAL_RESOURCE_KIND = 'global' satisfies RemoteAccessResourceKind + +class ResourceStore { + private readonly declarations = new Map>() + private readonly changes: ChangeSource + + constructor(report: ReportObserverError) { + this.changes = new ChangeSource(report) + } + + view(ctx: Context): TypertResourceRegistry { + return { + register: (kind, declaration) => this.register(ctx, kind, declaration), + get: kind => this.declarations.get(kind)?.provider, + entries: () => [...this.declarations].map(([kind, entry]) => Object.freeze({ kind, field: entry.provider.field })), + subscribe: listener => this.changes.subscribe(ctx, listener), + } + } + + private register(ctx: Context, kind: string, declaration: TypertResourceDeclaration): TypertDisposer { + validateSegment('resource kind', kind) + if (kind === GLOBAL_RESOURCE_KIND) { + throw new Error(`typert: resource kind "${GLOBAL_RESOURCE_KIND}" is reserved for the built-in global resource`) + } + validateWireName('resource field', declaration.field) + if (this.declarations.has(kind)) throw new Error(`typert: resource kind "${kind}" is already registered`) + const entry: ProviderEntry = { provider: { field: declaration.field }, owner: {} } + const { declarations, changes } = this + return ctx.effect(function* () { + declarations.set(kind, entry) + changes.emit({ kind: 'resource', key: kind }) + yield () => { + /* v8 ignore next -- duplicate registration is rejected, so this effect remains the kind's unique owner. */ + if (declarations.get(kind) !== entry) return + declarations.delete(kind) + changes.emit({ kind: 'resource', key: kind }) + } + }, `typert.resources.register(${JSON.stringify(kind)})`) + } +} + /** * Registry of generated schemas, package reflection, invocations, and Remote * dependency providers. @@ -451,6 +500,7 @@ export class TypertRegistry extends Service implements TypertRegistryContract { private readonly remoteStore: RemoteStore private readonly lookupStore: LookupStore private readonly contextStore: ContextStore + private readonly resourceStore: ResourceStore constructor(ctx: Context) { super(ctx, 'typert') @@ -462,6 +512,7 @@ export class TypertRegistry extends Service implements TypertRegistryContract { this.remoteStore = new RemoteStore(new DescriptorStore('remote', report)) this.lookupStore = new LookupStore(report) this.contextStore = new ContextStore(report) + this.resourceStore = new ResourceStore(report) } /** Current-environment invocation definitions. */ @@ -490,6 +541,11 @@ export class TypertRegistry extends Service implements TypertRegistryContract { return this.contextStore.view(this.ctx) } + /** Default wire fields of resource kinds. */ + get resources(): TypertResourceRegistry { + return this.resourceStore.view(this.ctx) + } + /** * Register one generated contribution atomically for the calling fiber. * Duplicate package-face identities, schemas, invocation ids, or endpoints diff --git a/packages/typert/registry/tests/typert.spec.ts b/packages/typert/registry/tests/typert.spec.ts index f1ca217e7a..38a2c06f12 100644 --- a/packages/typert/registry/tests/typert.spec.ts +++ b/packages/typert/registry/tests/typert.spec.ts @@ -10,7 +10,10 @@ import TypertRegistry, { import type { InvocationDescriptor, TypertContext, + TypertResource, + TypertResourceKind, TypertLookup, + TypertRegistryChange, TypertRemoteContribution, } from '@deepseek-ai/dsh-typert-protocol' import { createTypertRemoteInfo, isTypertRemoteMethod } from '@deepseek-ai/dsh-typert-protocol/remote-info' @@ -25,6 +28,11 @@ declare module '@deepseek-ai/dsh-typert-protocol' { registryFixture: TypertContext registryFixtureOther: TypertContext } + + interface TypertResourceMap { + /** Test-only resource kind whose identity is the fixture lookup's wire id. */ + registryFixture: TypertResource + } } async function makeCtx(): Promise { @@ -416,6 +424,73 @@ describe('TypertRegistry', () => { expect(ctx.typert.contexts.getClient('registryFixture')).toBeUndefined() }) + it('registers one default field per resource kind and withdraws it with its fiber', async () => { + const ctx = await makeCtx() + const changes: TypertRegistryChange[] = [] + ctx.typert.resources.subscribe((change) => { changes.push(change) }) + const fiber = ctx.plugin({ + inject: ['typert'], + apply: (pluginCtx: Context) => { pluginCtx.typert.resources.register('registryFixture', { field: 'fixtureId' }) }, + }) + await fiber + expect(ctx.typert.resources.get('registryFixture')).toEqual({ field: 'fixtureId' }) + expect(ctx.typert.resources.entries()).toEqual([{ kind: 'registryFixture', field: 'fixtureId' }]) + expect(() => ctx.typert.resources.register('registryFixture', { field: 'otherId' })).toThrow('already registered') + expect(() => ctx.typert.resources.register('registryFixture', { field: '' })).toThrow() + await fiber.dispose() + expect(ctx.typert.resources.get('registryFixture')).toBeUndefined() + expect(ctx.typert.resources.entries()).toEqual([]) + expect(changes).toEqual([ + { kind: 'resource', key: 'registryFixture' }, + { kind: 'resource', key: 'registryFixture' }, + ]) + const dispose = ctx.typert.resources.register('registryFixture', { field: 'fixtureId' }) + await dispose() + expect(ctx.typert.resources.get('registryFixture')).toBeUndefined() + }) + + it('reserves the built-in global resource kind', async () => { + const ctx = await makeCtx() + const changes: TypertRegistryChange[] = [] + ctx.typert.resources.subscribe((change) => { changes.push(change) }) + const reserved: string = 'global' + expect(() => ctx.typert.resources.register(reserved as TypertResourceKind, { field: 'globalId' })) + .toThrow('resource kind "global" is reserved for the built-in global resource') + expect(ctx.typert.resources.get('global')).toBeUndefined() + expect(changes).toEqual([]) + }) + + it('keeps a lookup provider\'s resource kind under a configured resolver', async () => { + const ctx = await makeCtx() + ctx.typert.lookups.register('fixture', { + parameter: 'agent', + wire: 'agentId', + resource: 'registryFixture', + hostTypeSymbol: '@fixture/agent#Agent', + wireTypeSymbol: '@fixture/session#SessionId', + resolve: () => undefined, + }) + expect(ctx.typert.lookups.get('fixture')?.resource).toBe('registryFixture') + ctx.typert.lookups.configure('fixture', () => undefined) + expect(ctx.typert.lookups.get('fixture')?.resource).toBe('registryFixture') + }) + + it('keeps a Host Context adapter\'s resource kind under a configured resolver', async () => { + const ctx = await makeCtx() + const scoped = ctx.extend() + ctx.typert.contexts.registerHost('registryFixture', { + wire: 'agentId', + wireTypeSymbol: '@fixture/session#SessionId', + resource: 'registryFixture', + resolve: () => undefined, + }) + expect(ctx.typert.contexts.getHost('registryFixture')?.resource).toBe('registryFixture') + ctx.typert.contexts.configureHost('registryFixture', id => id === 'agent-1' ? scoped : undefined) + const configured = ctx.typert.contexts.getHost('registryFixture') + expect(configured).toMatchObject({ wire: 'agentId', wireTypeSymbol: '@fixture/session#SessionId', resource: 'registryFixture' }) + await expect(configured?.resolve('agent-1')).resolves.toBe(scoped) + }) + it('configures an asynchronous lookup resolver independently of provider load order', async () => { const ctx = await makeCtx() const fallback = { id: 'fallback' } diff --git a/packages/workspace/workspace/src/types.ts b/packages/workspace/workspace/src/types.ts index da5f671aa9..1b09bef969 100644 --- a/packages/workspace/workspace/src/types.ts +++ b/packages/workspace/workspace/src/types.ts @@ -7,7 +7,7 @@ import type { Branded } from '@deepseek-ai/dsh-brand' import type { SessionId } from '@deepseek-ai/dsh-session/types' -import type {} from '@deepseek-ai/dsh-typert-protocol' +import type { TypertResource } from '@deepseek-ai/dsh-typert-protocol' /** * Identifies one workspace record. A generated uuid, never the path: path @@ -16,6 +16,11 @@ import type {} from '@deepseek-ai/dsh-typert-protocol' export type WorkspaceId = Branded<'WorkspaceId'> declare module '@deepseek-ai/dsh-typert-protocol' { + interface TypertResourceMap { + /** One Workspace; the Workspace Controller registers its default `workspaceId` wire field. */ + workspace: TypertResource + } + interface RemoteErrorDetailsMap { /** No registration carries that Workspace identity. */ 'workspace/not-found': { readonly workspaceId: WorkspaceId } From d7bd68ee7aa2280d5bd4f9df2fec954f262d8c28 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:46:52 +0800 Subject: [PATCH 18/35] feat(remote-access): add rbac policies and gate non-owner calls and events --- benchmarks/package.json | 1 + .../session-corpus/projection-list.worker.ts | 8 +- .../session-corpus/session-corpus.worker.ts | 8 +- packages/api/README.i18n.yaml | 4 +- packages/api/README.md | 1 + packages/api/README.zh.md | 1 + packages/api/gateway/README.i18n.yaml | 6 +- packages/api/gateway/README.md | 10 +- packages/api/gateway/README.zh.md | 12 +- packages/api/gateway/package.json | 4 +- packages/api/gateway/src/access.ts | 170 ++++ packages/api/gateway/src/index.ts | 87 +- packages/api/gateway/src/types.ts | 12 +- .../gateway/tests/echo-stream.host.spec.ts | 4 + .../gateway/tests/gateway-access.host.spec.ts | 767 ++++++++++++++++++ .../gateway/tests/gateway-stream.host.spec.ts | 64 +- .../api/gateway/tests/gateway.host.spec.ts | 89 +- .../gateway/tests/strict-source.host.spec.ts | 11 +- packages/api/gateway/tsconfig.host.json | 4 + packages/api/remote-access/README.i18n.yaml | 34 + packages/api/remote-access/README.md | 97 +++ packages/api/remote-access/README.zh.md | 97 +++ packages/api/remote-access/package.json | 83 ++ .../api/remote-access/src/client/index.ts | 33 + .../api/remote-access/src/client/mount.ts | 132 +++ .../api/remote-access/src/client/state.ts | 208 +++++ packages/api/remote-access/src/index.ts | 424 ++++++++++ packages/api/remote-access/src/snapshot.ts | 8 + packages/api/remote-access/src/types.ts | 137 ++++ .../tests/client-access.client.spec.ts | 291 +++++++ .../tests/remote-access.host.spec.ts | 610 ++++++++++++++ .../api/remote-access/tsconfig.client.json | 22 + packages/api/remote-access/tsconfig.host.json | 18 + packages/api/remote-access/tsconfig.json | 7 + packages/api/remote-access/tsdown.config.ts | 7 + packages/api/remotes/tests/built-lib.e2e.ts | 4 + packages/api/workspace-files/package.json | 1 + .../workspace-files/tests/binary-rpc.spec.ts | 7 +- packages/boot/plugin-manager/src/index.ts | 2 +- .../boot/plugin-manager/tests/manager.spec.ts | 1 + packages/bundle/base/cordis.patch.yml | 5 + packages/bundle/base/package.json | 1 + .../client-ui-cot-translation/package.json | 1 + .../tests/activation.host.spec.ts | 2 + .../cordis-host-runner/package.json | 1 + .../tests/inspect-registry.spec.ts | 7 +- .../src/assembly/remote-default-responses.ts | 2 + pnpm-lock.yaml | 70 ++ scripts/package-dependency-policy.ts | 1 - .../verify-package-readme-model-experience.ts | 1 + tsconfig.base.json | 3 + tsconfig.client.json | 1 + tsconfig.host.json | 1 + vitest.config.ts | 3 + 54 files changed, 3519 insertions(+), 66 deletions(-) create mode 100644 packages/api/gateway/src/access.ts create mode 100644 packages/api/gateway/tests/gateway-access.host.spec.ts create mode 100644 packages/api/remote-access/README.i18n.yaml create mode 100644 packages/api/remote-access/README.md create mode 100644 packages/api/remote-access/README.zh.md create mode 100644 packages/api/remote-access/package.json create mode 100644 packages/api/remote-access/src/client/index.ts create mode 100644 packages/api/remote-access/src/client/mount.ts create mode 100644 packages/api/remote-access/src/client/state.ts create mode 100644 packages/api/remote-access/src/index.ts create mode 100644 packages/api/remote-access/src/snapshot.ts create mode 100644 packages/api/remote-access/src/types.ts create mode 100644 packages/api/remote-access/tests/client-access.client.spec.ts create mode 100644 packages/api/remote-access/tests/remote-access.host.spec.ts create mode 100644 packages/api/remote-access/tsconfig.client.json create mode 100644 packages/api/remote-access/tsconfig.host.json create mode 100644 packages/api/remote-access/tsconfig.json create mode 100644 packages/api/remote-access/tsdown.config.ts diff --git a/benchmarks/package.json b/benchmarks/package.json index 4a2be7ed1d..d36d79bfdb 100644 --- a/benchmarks/package.json +++ b/benchmarks/package.json @@ -15,6 +15,7 @@ "@deepseek-ai/dsh-agent-loop": "workspace:*", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:*", "@deepseek-ai/dsh-agent-preset-registry": "workspace:*", + "@deepseek-ai/dsh-api-remote-access": "workspace:*", "@deepseek-ai/dsh-api-session-controller": "workspace:*", "@deepseek-ai/dsh-brand": "workspace:*", "@deepseek-ai/dsh-client-store": "workspace:*", diff --git a/benchmarks/session-corpus/projection-list.worker.ts b/benchmarks/session-corpus/projection-list.worker.ts index 106c520be2..5e95356c12 100644 --- a/benchmarks/session-corpus/projection-list.worker.ts +++ b/benchmarks/session-corpus/projection-list.worker.ts @@ -7,6 +7,7 @@ import { monitorEventLoopDelay, performance } from 'node:perf_hooks' import { scheduler, setTimeout as delay } from 'node:timers/promises' import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' +import RemoteAccessService from '@deepseek-ai/dsh-api-remote-access' import SessionController from '@deepseek-ai/dsh-api-session-controller' import SessionStore, { SESSION_FORMAT_VERSION, SessionId, SessionLogOffset, type SessionHeader } from '@deepseek-ai/dsh-session' import * as JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' @@ -196,10 +197,15 @@ async function mountHost(ctx: Context, root: string, workload: typeof WORKLOADS[ await ctx.plugin(SqliteSessionQueryEngine, { path: ':memory:', openAt: 'never' }) // No Agents, uploads, model calls, or workspace actions occur on this list path. const dispose = (): void => {} - ctx.provide('typert', { lookups: { configure: () => dispose }, contexts: { configureHost: () => dispose } } as never) + ctx.provide('typert', { + lookups: { configure: () => dispose }, + contexts: { configureHost: () => dispose }, + resources: { register: () => dispose }, + } as never) ctx.provide('fileUploads', { registerAgentResolver: () => dispose } as never) ctx.provide('agentDefaultModel', { currentSelection: () => ({ provider: 'synthetic', model: 'generated-model' }) } as never) ctx.provide('workspaceRegistry', { list: () => [], archivedSessionIds: [] } as never) + new RemoteAccessService(ctx) return new SessionController(ctx, { nativeOpen: false, ...(listWorkSliceMs === undefined ? {} : { listWorkSliceMs }) }, { canOpenPath: () => false }) } diff --git a/benchmarks/session-corpus/session-corpus.worker.ts b/benchmarks/session-corpus/session-corpus.worker.ts index c533852fa1..2d1acd56ba 100644 --- a/benchmarks/session-corpus/session-corpus.worker.ts +++ b/benchmarks/session-corpus/session-corpus.worker.ts @@ -8,6 +8,7 @@ import { Context } from '@deepseek-ai/cordis' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { agentPresetProjectionDefinition } from '@deepseek-ai/dsh-agent-preset-registry' +import RemoteAccessService from '@deepseek-ai/dsh-api-remote-access' import SessionController from '@deepseek-ai/dsh-api-session-controller' import { currentSessionMessageProjections } from '@deepseek-ai/dsh-session-format-catalog/message-projections' import { Session, SessionLogOffset } from '@deepseek-ai/dsh-session' @@ -148,10 +149,15 @@ async function mountHost(root: string): Promise<{ readonly ctx: Context; readonl await ctx.plugin(AgentLoop, { agents: [] }) // Transport, upload, model-default, and Workspace services are outside every measured path. const dispose = (): void => {} - ctx.provide('typert', { lookups: { configure: () => dispose }, contexts: { configureHost: () => dispose } } as never) + ctx.provide('typert', { + lookups: { configure: () => dispose }, + contexts: { configureHost: () => dispose }, + resources: { register: () => dispose }, + } as never) ctx.provide('fileUploads', { registerAgentResolver: () => dispose } as never) ctx.provide('agentDefaultModel', { currentSelection: () => BENCH_MODEL } as never) ctx.provide('workspaceRegistry', { list: () => [], archivedSessionIds: [] } as never) + new RemoteAccessService(ctx) const controller = new SessionController(ctx, { nativeOpen: false }, { canOpenPath: () => false }) return { ctx, controller } } diff --git a/packages/api/README.i18n.yaml b/packages/api/README.i18n.yaml index 7c3e86cc1c..bbe9d4ab52 100644 --- a/packages/api/README.i18n.yaml +++ b/packages/api/README.i18n.yaml @@ -15,8 +15,8 @@ en: 1eb35b015a6c423f zh: a8a3dceee2c9e943 /api-remote-api-layers/packages: - en: 9bf51a5550fd4853 - zh: 4d13dd77d86f7f97 + en: 37388d921f2f47cf + zh: 24eb674c14dc2c6d /api-remote-api-layers/related-documentation: en: 507116ae09161df1 zh: f5e538ac5fdaf454 diff --git a/packages/api/README.md b/packages/api/README.md index 691a56856d..9f490b8134 100644 --- a/packages/api/README.md +++ b/packages/api/README.md @@ -28,6 +28,7 @@ The packages below provide the Remote layer; the package READMEs own the exhaust |---|---|---| | [`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 their Client uplinks, and forwarded Host events. | `ctx.typertGateway` / `ctx.remote` | +| [`remote-access/`](remote-access/README.md) | Decides which Remote methods non-owner Peers may call, filters list results, and streams each Peer's access snapshot. | `ctx.remoteAccess` / `ctx.remote.remoteAccess` | | [`job-controller/`](job-controller/README.md) | Streams one background job's observation record to the Client. | `ctx.jobController` / `ctx.remote.job` | | [`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` | diff --git a/packages/api/README.zh.md b/packages/api/README.zh.md index 9303fbaae7..3d7ae60113 100644 --- a/packages/api/README.zh.md +++ b/packages/api/README.zh.md @@ -28,6 +28,7 @@ kind: "package-group" |---|---|---| | [`remotes/`](remotes/README.zh.md) | 决定 Client 可以消费哪些 Host 能力与事件。 | — | | [`gateway/`](gateway/README.zh.md) | 承载类型化一元调用、带 Client 上行的多路复用流,以及转发的 Host 事件。 | `ctx.typertGateway` / `ctx.remote` | +| [`remote-access/`](remote-access/README.zh.md) | 决定非 owner Peer 可以调用哪些 Remote 方法,过滤列表结果,并为每个 Peer 推送其访问快照。 | `ctx.remoteAccess` / `ctx.remote.remoteAccess` | | [`job-controller/`](job-controller/README.zh.md) | 把一个后台任务的观测 record 流式送到 Client。 | `ctx.jobController` / `ctx.remote.job` | | [`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` | diff --git a/packages/api/gateway/README.i18n.yaml b/packages/api/gateway/README.i18n.yaml index 3c5a28123f..96eee8a741 100644 --- a/packages/api/gateway/README.i18n.yaml +++ b/packages/api/gateway/README.i18n.yaml @@ -15,11 +15,11 @@ en: d9859219cdb0f367 zh: 2a3cfa8cb786e327 /deepseek-ai-dsh-api-gateway/host-service-typertgatewayservice-ctx-key-typertgateway: - en: 2aabd26516a45771 - zh: 5907f472a4d536df + en: 5427b6ade7a2e324 + zh: 7ca42589a6572c5d /deepseek-ai-dsh-api-gateway/client-service-clientremote-ctx-key-remote: en: c106f0ca3e547f84 - zh: 833e30d7151c41c7 + zh: 8eadd3bffde9e49d /deepseek-ai-dsh-api-gateway/model-experience: en: 6cb309d3378f035d zh: 998042a02ee32850 diff --git a/packages/api/gateway/README.md b/packages/api/gateway/README.md index c212ec01ff..97aeec7da3 100644 --- a/packages/api/gateway/README.md +++ b/packages/api/gateway/README.md @@ -30,19 +30,23 @@ Strict mode reads generated invocation descriptors from `ctx.typert.local`. Look 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. +Every call names the Peer it speaks for: `InvokeRemoteRequest.peer` and the `peer` argument of `wireStream.open()` are required, and the Gateway never supplies a default Peer. Dispatch from a disposed Peer fails with `gateway/peer-disposed`, for unary calls, streams, `$events`, and `$events/result` alike, and wire paths check it before they read the payload. The Host service injects `remoteAccess` and does not start without [`dsh-api-remote-access`](../remote-access/README.md). For a call whose Peer principal is not the owner, after the endpoint and argument-field checks and before lookup and Context resolution, the Gateway resolves the resources the call touches and asks `ctx.remoteAccess.decide({ principal, action, resources, args })` with the endpoint as the action, or refuses with reason `resource-invalid` without asking when the resources are invalid; a refused call throws `remote-access/denied` with details `{ action, reason }`, so a refused call fails before it activates any Session. An owner call is dispatched without resource resolution or a decision. + +Resources are read from the undecoded wire arguments. For each resource kind, a reader in the method's `@Remote({ resources })` or `@RemoteScope(key, { resources })` options wins; it is called with the method's positional wire arguments, where lookup positions hold wire identities and the cancellation position holds the call's signal, and is read from the class metadata of the root Service instance. Otherwise every lookup parameter whose provider declares that kind through `resource` contributes its wire identity, an omitted optional one contributing none, and a Context method whose Host Context adapter declares that kind contributes its Context identity, read from the arguments before the Context is resolved. Otherwise the kind's default field registered in `typert.resources` is read from a top-level wire argument of that name or, failing that, from the first object argument. A reader that throws or returns an empty list, a default field holding an array, or an identity, including a Context identity, that is not a non-empty string makes the resolution `invalid`; a call that names no resource touches the `global` resource alone, and a call that names resources touches only those. A forwarded event carries every resource its entry declares, so there a reader returning `undefined` also makes the resolution `invalid`, while an entry without readers touches `global`. + 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 Pong, any received frame, or a downlink write that waited in the full local send buffer and then completed counts as peer activity; a socket with no activity across two Ping intervals is terminated, so a congested link that delays the Pong behind data stays connected. Independently cancellable logical streams share that socket; an in-process Connection carrier provides equivalent streams directly without opening it. When the launcher supplies `ctx.appReady`, Gateway registers the WebSocket upgrade route only after successful application startup. Earlier connection attempts remain carrier failures handled by Connection's retry policy, so a restarting Host cannot accept streams while its controllers are still initializing. Unloading cancels a pending readiness subscription and closes any registered carrier. Embedded Hosts without `appReady` register immediately and own their startup ordering; in-process invocation is unchanged. -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. +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 an explicitly named 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 Peer its caller passes. A stream method's `signal` also aborts when that Peer is disposed, so the stream ends with `gateway/cancelled`, and a `$events` stream ends with its Peer; a unary call already running is not cancelled. Stream cancellation and stopping keep read-local state, not a history of delivered items, on the Host downlink, Host uplink, and Client uplink pump. Completing the downlink also wakes pending uplink reads. -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. +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. A `$events` stream whose Peer principal is not the owner never receives a scoped waterfall it could answer; it receives a broadcast emit frame, whole and untrimmed, only when `ctx.remoteAccess.decide({ principal, action: 'event:', resources })` allows it on the resources the frame's readers name, or on `global` for a frame without readers; the resources are resolved once per frame, a frame whose resources are invalid reaches no non-owner stream without a decision, and a denied frame is dropped for that stream alone. An `$events/result` reply from such a Peer fails with `gateway/invocation-unavailable`. -`hasLiveClient()` checks the existing `$events` records for an uncancelled stream. A cancelled stream does not count even while its iterator is still awaiting cleanup; a bare WebSocket does not count. This synchronous observation does not guarantee delivery or Client provider readiness. +`hasLiveClient()` checks the existing `$events` records for an uncancelled stream whose Peer principal is the owner, the only kind that can answer a forwarded waterfall. A cancelled stream does not count even while its iterator is still awaiting cleanup; a bare WebSocket does not count. This synchronous observation does not guarantee delivery or Client provider readiness. ## Client service: `ClientRemote` (ctx key: `remote`) diff --git a/packages/api/gateway/README.zh.md b/packages/api/gateway/README.zh.md index 1c3a9a2048..68e516dba6 100644 --- a/packages/api/gateway/README.zh.md +++ b/packages/api/gateway/README.zh.md @@ -30,19 +30,23 @@ kind: "package-reference" 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`,它选定的码原样到达调用方。 +每次调用都指明它所代表的 Peer:`InvokeRemoteRequest.peer` 与 `wireStream.open()` 的 `peer` 参数都是必填项,Gateway 从不提供默认 Peer。来自已释放 Peer 的分发以 `gateway/peer-disposed` 失败,一元调用、流、`$events` 与 `$events/result` 一律如此,wire 路径在读取 payload 之前检查。Host 服务注入 `remoteAccess`,没有 [`dsh-api-remote-access`](../remote-access/README.zh.md) 就不启动。对 Peer 的 principal 不是 owner 的调用,Gateway 在 endpoint 与参数字段检查之后、lookup 与 Context 解析之前解析调用访问的资源,并以端点为 action 询问 `ctx.remoteAccess.decide({ principal, action, resources, args })`,资源非法时不询问而以原因 `resource-invalid` 拒绝;被拒的调用抛出 details 为 `{ action, reason }` 的 `remote-access/denied`,因此被拒绝的调用在激活任何 Session 之前就失败。owner 的调用不解析资源、不询问判定即分派。 + +资源从未解码的 wire 参数中读取。对每个资源种类,方法 `@Remote({ resources })` 或 `@RemoteScope(key, { resources })` options 中的读取函数优先;它以方法的位置 wire 参数调用(lookup 位为 wire 身份,取消参数位为调用的 signal),从根 Service 实例的类元数据读取。否则 provider 经 `resource` 声明了该种类的每个 lookup 参数贡献其 wire 身份,省略的可选 lookup 参数不贡献;Host Context adapter 声明了该种类的 Context 方法贡献其 Context 身份,在解析 Context 之前直接从参数读取。再否则从同名顶层 wire 参数读取该种类在 `typert.resources` 中登记的默认字段,没有同名参数时读第一个对象参数的同名字段。读取函数抛错或返回空列表、默认字段读到数组、或某个身份(含 Context 身份)不是非空字符串时,解析结果为 `invalid`;不指明任何资源的调用只访问 `global` 资源,指明了资源的调用只访问这些资源。转发事件携带其条目声明的每个资源,因此在事件路径上读取函数返回 `undefined` 同样使解析结果为 `invalid`,而没有读取函数的条目访问 `global`。 + 支持取消的 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 流帧。Pong、收到的任意帧,或曾在已满的本机发送缓冲区中等待后完成的下行写入,都视为对端活动;连续两个 Ping 间隔没有活动的 socket 会被终止,因此 Pong 被数据阻塞在拥塞链路上时连接仍会保持。可独立取消的逻辑流共享这条连接;进程内 Connection 载体直接提供等价的流,不打开该 WebSocket。 启动器提供 `ctx.appReady` 时,Gateway 仅在应用成功启动后注册 WebSocket 升级路由。此前的连接尝试仍属于载体故障,由 Connection 的重试策略处理,因此重启中的 Host 不会在控制器仍在初始化时接受流。卸载会取消尚未触发的就绪订阅,并关闭已注册的载体。不提供 `appReady` 的嵌入式 Host 会立即注册,并自行负责启动顺序;进程内调用不变。 -每条流还在同一条逻辑流上携带从 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 关闭;进程内载体的流直接代表操作者。 +每条流还在同一条逻辑流上携带从 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 关闭;进程内载体的流代表其调用方传入的 Peer。该 Peer 释放时,流方法的 `signal` 也随之中止,流以 `gateway/cancelled` 结束,`$events` 流也随其 Peer 结束;已在运行的一元调用不会被取消。 Host 下行、Host 上行和 Client 上行泵的取消与停止状态归属单次读取,不保留已交付项的历史。下行结束也会唤醒尚未完成的上行读取。 -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 读取。 +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 读取。Peer 的 principal 不是 owner 的 `$events` 流从不收到它可以作答的 scoped waterfall(瀑布式事件);只有当 `ctx.remoteAccess.decide({ principal, action: 'event:', resources })` 在该帧读取函数指明的资源上(帧没有读取函数时为 `global`)允许时,才整条、不裁剪地收到广播 emit 帧;每帧只解析一次资源,资源非法的帧不经判定即不送达任何非 owner 流,被拒的帧只对该流丢弃。这类 Peer 的 `$events/result` 回复以 `gateway/invocation-unavailable` 失败。 -`hasLiveClient()` 检查已有 `$events` 记录中是否有未取消的流。已取消的流即使尚未完成 iterator 清理也不计入,单独的 WebSocket 也不计入。这一同步观察不保证后续投递成功或 Client Provider 已就绪。 +`hasLiveClient()` 检查已有 `$events` 记录中是否有 Peer principal 为 owner 的未取消流,只有这类流能回答转发的 waterfall。已取消的流即使尚未完成 iterator 清理也不计入,单独的 WebSocket 也不计入。这一同步观察不保证后续投递成功或 Client Provider 已就绪。 ## Client 服务:`ClientRemote`(ctx key:`remote`) @@ -59,7 +63,7 @@ Host 组合可通过 `registerRemoteEvents()` 注册唯一的应用事件 source `ctx.remote.$stream()` 返回跨越多个物理载体代次的单消费方 `RemoteStream`。Host 仍在线时,它允许一次立即重试;Host 离线时,它等待下一代连接,并为每个流项标注物理代次。领域消费方校验并接受各代次的 opening value;业务与协议错误仍然终止流。一切终态失败离开本面时都是 `RemoteError`,包括重试耗尽和在 opening value 之前就结束的代次,因此流消费方与一元调用方用同一种方式判别。`RemoteStreamCarrierError` 命名的是可重试的物理丢失,它只作为 `carrierFailed` 回调参数到达领域,绝不作为终态结果。`RemoteSnapshotStream` 在此之上规定每代由一个初始快照和后续 delta 组成。`RemoteJournalStream` 基于领域提供的 entry 闭区间提供 follow-before-page、分页、重连追赶与缺口修复;它丢弃完整重复项,并拒绝缺口、倒置区间和部分重叠。领域还可以携带无 cursor 的通知:通知绝不推进或修复持久 cursor,在缺口修复期间收到的通知只会在 replacement page 提交后发布。若更新代次取代该修复,旧代次 held notification 会与其 page 一同丢弃。对任一种流执行 dispose(资源释放)时,系统会取消该流的请求,并在活动 iterator 完全停止后完成资源释放。 -`ctx.remote.$on()` 订阅一条被转发的 Host 事件。它的合法键恰好等于 Host 装配声明的转发选择,listener 类型就是事件所属包自己的 Cordis `Events` 声明,因此不存在会与之漂移的第二份签名。每个订阅归属调用方 fiber,并随该 fiber 一起消失。Client Remote 服务激活时就把 `$events` pump 注册为 Connection generation source,无论当前是否存在 `$on` listener。浏览器使用 Remote mux,进程内组合使用 `connection.rpc.open`;opening `ready` 项建立 Connection generation 并提供 Host 信息。物理 carrier 失败、Remote 流故障、意外正常结束、非 ready 首项或畸形事件项都会终止该 generation,由 Connection 按持续且间隔封顶的带抖动指数退避重开。普通通知按注册顺序运行并隔离 listener 失败;Agent-scoped waterfall(瀑布式事件)允许 listener 返回结果、调用 `next()` 或拒绝,Gateway 再通过现有 HTTP 一元载体回送该结果。 +`ctx.remote.$on()` 订阅一条被转发的 Host 事件。它的合法键恰好等于 Host 装配声明的转发选择,listener 类型就是事件所属包自己的 Cordis `Events` 声明,因此不存在会与之漂移的第二份签名。每个订阅归属调用方 fiber,并随该 fiber 一起消失。Client Remote 服务激活时就把 `$events` pump 注册为 Connection generation source,无论当前是否存在 `$on` listener。浏览器使用 Remote mux,进程内组合使用 `connection.rpc.open`;opening `ready` 项建立 Connection generation 并提供 Host 信息。物理 carrier 失败、Remote 流故障、意外正常结束、非 ready 首项或畸形事件项都会终止该 generation,由 Connection 按持续且间隔封顶的带抖动指数退避重开。普通通知按注册顺序运行并隔离 listener 失败;Agent-scoped waterfall 允许 listener 返回结果、调用 `next()` 或拒绝,Gateway 再通过现有 HTTP 一元载体回送该结果。 Client waterfall 的 Context 解析保持同步。解析器可以返回借用的 Context 或 `TypertOwnedValue`;Gateway 仅在处理器使用和回复结算均结束后释放 owned value。Context 解析失败保留既有的记录错误并委托语义,处理器失败产生拒绝回复。取消会抑制迟到回复,但不会释放处理器仍在使用的 Context。每个 handler 都必须响应 `request.signal` 并在取消后结束;插件销毁与 Connection generation 替换会等待未结束的 handler 结算。Session Context 的获取本身不执行历史 I/O。 diff --git a/packages/api/gateway/package.json b/packages/api/gateway/package.json index d4469cf8cd..af26594447 100644 --- a/packages/api/gateway/package.json +++ b/packages/api/gateway/package.json @@ -64,10 +64,10 @@ "@deepseek-ai/dsh-typert-protocol": "workspace:*" }, "peerDependencies": { - "@deepseek-ai/cordis": "workspace:~", - "@deepseek-ai/dsh-client-connection": "workspace:*" + "@deepseek-ai/cordis": "workspace:~" }, "devDependencies": { + "@deepseek-ai/dsh-api-remote-access": "workspace:*", "@deepseek-ai/dsh-brand": "workspace:*", "@deepseek-ai/dsh-cmdline": "workspace:*", "@deepseek-ai/dsh-client-connection": "workspace:*", diff --git a/packages/api/gateway/src/access.ts b/packages/api/gateway/src/access.ts new file mode 100644 index 0000000000..5c290e9f70 --- /dev/null +++ b/packages/api/gateway/src/access.ts @@ -0,0 +1,170 @@ +/** + * Resource resolution for non-owner access decisions: the resources one call + * or one forwarded event touches, read from undecoded wire values before any + * lookup or Context resolves them. + * @module @deepseek-ai/dsh-api-gateway/src/access + */ + +import type { + InvocationDescriptor, + RemoteAccessResource, + TypertRegistryContract, + TypertResourceReader, +} from '@deepseek-ai/dsh-typert-protocol' + +const GLOBAL: readonly RemoteAccessResource[] = Object.freeze([Object.freeze({ kind: 'global' })]) + +/** Resource ids one source produced for a kind, or `invalid` for a malformed value. */ +type ResourceIds = readonly unknown[] | 'invalid' + +/** + * Resolve the resources one call touches. For each resource kind, a + * `resources` reader of the method wins; otherwise every lookup parameter + * whose provider declares the kind contributes its wire resource id, and so + * does the Context identity of a Context method whose Host adapter declares + * the kind; otherwise the kind's default field registered in + * `typert.resources` is read from a top-level wire argument or, failing + * that, from the first object argument. + * @param typert - registry holding default fields, lookup providers, and Host Context adapters. + * @param readers - the method's `resources` readers, or `undefined` when it declares none. + * @param descriptor - receiver, parameter order, lookups, and cancellation slot of the method. + * @param args - named wire arguments, already matched to the descriptor's field set. + * @param signal - value placed in the cancellation slot of the positional arguments. + * @returns the touched resources, the global resource alone when none; + * `undefined` when the resources are invalid: a reader throws or returns + * an empty list, a default field holds an array, or a resource id is not a + * non-empty string. + */ +export function callResources( + typert: Pick, + readers: Readonly> | undefined, + descriptor: Pick, + args: Readonly>, + signal: AbortSignal, +): readonly RemoteAccessResource[] | undefined { + // Each later source replaces an earlier one for its kind and keeps the kind's position. + const sources = new Map ResourceIds>() + for (const { kind, field } of typert.resources.entries()) { + sources.set(kind, () => { + // A default field names exactly one resource id; only a reader may return several. + const id = defaultFieldId(descriptor, args, field) + return Array.isArray(id) ? 'invalid' : id === undefined ? [] : [id] + }) + } + for (const [kind, ids] of lookupResources(typert, descriptor, args)) { + sources.set(kind, () => ids) + } + let positional: readonly unknown[] | undefined + for (const [kind, reader] of Object.entries(readers ?? {})) { + sources.set(kind, () => { + positional ??= [ + ...descriptor.parameters.map(parameter => args[parameter.wire]), + ...descriptor.cancellation === undefined ? [] : [signal], + ] + return read(reader, positional, []) + }) + } + const resources: RemoteAccessResource[] = [] + for (const [kind, ids] of sources) { + if (!collect(resources, kind, ids())) return undefined + } + return resources.length === 0 ? GLOBAL : resources +} + +/** + * Resolve the resources one forwarded event notification touches. An event + * carries every resource it declares, so a reader returning `undefined` + * makes the notification invalid instead of a global one. + * @param readers - the event entry's declared readers, or `undefined` when it declares none. + * @param args - the event's validated JSON arguments, passed to each reader as its positional arguments. + * @returns the global resource alone without readers, including an empty + * reader set; otherwise the touched resources; `undefined` when the + * resources are invalid: a resource id is missing or malformed. + */ +export function eventResources( + readers: Readonly> | undefined, + args: readonly unknown[], +): readonly RemoteAccessResource[] | undefined { + const declared = Object.entries(readers ?? {}) + if (declared.length === 0) return GLOBAL + const resources: RemoteAccessResource[] = [] + for (const [kind, reader] of declared) { + if (!collect(resources, kind, read(reader, args, 'invalid'))) return undefined + } + return resources +} + +/** + * The resource ids lookup parameters and the Context identity name, per + * resource kind: the Context identity first, then lookup parameters in order. + * An optional lookup parameter the call omits names no resource id; the + * Context identity is required. + */ +function lookupResources( + typert: Pick, + descriptor: Pick, + args: Readonly>, +): ReadonlyMap { + const ids = new Map() + const add = (kind: string, id: unknown): void => { + const list = ids.get(kind) + if (list === undefined) ids.set(kind, [id]) + else list.push(id) + } + const receiver = descriptor.invocation + if (receiver.kind === 'context') { + const kind = typert.contexts.getHost(receiver.context)?.resource + // oxlint-disable-next-line typescript/no-unnecessary-condition -- domain packages merge resource kinds into TypertResourceMap. + if (kind !== undefined) add(kind, args[receiver.wire]) + } + for (const parameter of descriptor.parameters) { + if (parameter.lookup === undefined) continue + const kind = typert.lookups.get(parameter.lookup)?.resource + const id = args[parameter.wire] + // oxlint-disable-next-line typescript/no-unnecessary-condition -- domain packages merge resource kinds into TypertResourceMap. + if (kind !== undefined && id !== undefined) add(kind, id) + } + return ids +} + +/** Run one reader with spread positional arguments: `undefined` yields `absent`, a list names several and must not be empty. */ +function read(reader: TypertResourceReader, args: readonly unknown[], absent: ResourceIds): ResourceIds { + let value: unknown + try { + value = reader(...args) + } catch { + // A reader over unvalidated wire JSON fails on a malformed call; the call is refused. + return 'invalid' + } + if (value === undefined) return absent + if (!Array.isArray(value)) return [value] + return value.length === 0 ? 'invalid' : value +} + +/** Append one kind's resource ids; false when any is malformed. */ +function collect(resources: RemoteAccessResource[], kind: string, ids: ResourceIds): boolean { + if (ids === 'invalid') return false + for (const id of ids) { + if (typeof id !== 'string' || id.length === 0) return false + // Kinds come from registrations, lookups, Context adapters, and reader keys, all typed by the merged resource map. + resources.push({ kind, id } as RemoteAccessResource) + } + return true +} + +/** The default field: a top-level wire field, else that field of the first object argument. */ +function defaultFieldId( + descriptor: Pick, + args: Readonly>, + field: string, +): unknown { + if (descriptor.parameters.some(parameter => parameter.wire === field)) return args[field] + for (const parameter of descriptor.parameters) { + const value = args[parameter.wire] + if (typeof value !== 'object' || value === null || Array.isArray(value)) continue + if (!Object.hasOwn(value, field)) return undefined + const id: unknown = Reflect.get(value, field) + return id + } + return undefined +} diff --git a/packages/api/gateway/src/index.ts b/packages/api/gateway/src/index.ts index 42148cd2d3..6259358b21 100644 --- a/packages/api/gateway/src/index.ts +++ b/packages/api/gateway/src/index.ts @@ -11,6 +11,8 @@ import type { ConnectionRpcAttachment, ConnectionRpcHandler, } from '@deepseek-ai/dsh-client-connection' +import type { RemoteAccessDecision } from '@deepseek-ai/dsh-api-remote-access' +import { callResources, eventResources } from './access.ts' 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' @@ -25,7 +27,10 @@ import { type InvocationDescriptor, type InvocationParameterDescriptor, type PeerScope, + type Principal, + type RemoteAccessResource, type RemoteInvocation, + type RemoteMethodMarker, type TypertCodec, type TypertGatewayBinding, } from '@deepseek-ai/dsh-typert-protocol' @@ -120,6 +125,10 @@ interface RemoteEventClient { readonly queue: RemoteEventQueue readonly signal: AbortSignal readonly deliveries: Map + /** The stream's Peer principal: only the owner answers scoped waterfalls and receives every broadcast. */ + readonly principal: Principal + /** Only owner streams receive scoped waterfalls they could answer; other Peers receive admitted broadcasts only. */ + readonly answers: boolean } interface PendingRemoteEvent { @@ -197,7 +206,7 @@ export class TypertGatewayError extends RemoteError { * @typert service typertGateway */ export class TypertGatewayService extends Service implements TypertGateway { - static inject = ['typert'] + static inject = ['typert', 'remoteAccess'] static Config: z = z.object({ websocketHeartbeatIntervalMs: z.number().step(1).min(1).max(MAX_TIMER_DELAY_MS) .default(DEFAULT_WEBSOCKET_HEARTBEAT_INTERVAL_MS), @@ -277,12 +286,12 @@ export class TypertGatewayService extends Service implements TypertGateway { } /** - * Check for an active Client event stream. - * @returns whether a stream is open and has not been cancelled. + * Check for an active owner Client event stream, the only kind that can answer forwarded waterfalls. + * @returns whether an owner stream is open and has not been cancelled. */ hasLiveClient(): boolean { for (const client of this.remoteEventClients.values()) { - if (!client.signal.aborted) return true + if (client.answers && !client.signal.aborted) return true } return false } @@ -330,6 +339,18 @@ export class TypertGatewayService extends Service implements TypertGateway { return this.srcClaims.has(endpoint) } + /** + * The `resources` readers of a method, read from the root Service's class + * metadata so a Context method's receiver is not resolved before access is + * decided; `undefined` when the Service is absent or the method declares none. + */ + private resourceReaders(descriptor: InvocationDescriptor): RemoteMethodMarker['resources'] { + const receiver: unknown = this.ctx.get(descriptor.service) + if (!isObject(receiver)) return undefined + const member = descriptor.implementation ?? descriptor.method + return remoteMethods(originalOf(receiver)).find(marker => marker.method === member)?.resources + } + private collectSrcClaims(): ReadonlySet { const claims = new Set() for (const [serviceKey, definition] of Object.entries(this.ctx.reflect.props)) { @@ -429,6 +450,10 @@ export class TypertGatewayService extends Service implements TypertGateway { if (endpoint === REMOTE_EVENT_RESULT_ENDPOINT) { try { assertPeerLive(peer, endpoint) + // oxlint-disable-next-line typescript/no-unnecessary-condition -- other packages merge non-owner kinds into PrincipalMap. + if (peer.principal.kind !== 'owner') { + throw new TypertGatewayError('gateway/invocation-unavailable', endpoint, 'only owner Peers answer forwarded Remote events') + } const result = parseRemoteEventResultPayload(payload) const client = this.remoteEventClients.get(result.clientId) if (client === undefined) { @@ -498,9 +523,14 @@ export class TypertGatewayService extends Service implements TypertGateway { queue: new RemoteEventQueue(), signal: lifetime, deliveries: new Map(), + principal: peer.principal, + // oxlint-disable-next-line typescript/no-unnecessary-condition -- other packages merge non-owner kinds into PrincipalMap. + answers: peer.principal.kind === 'owner', } this.remoteEventClients.set(clientId, client) - for (const pending of this.pendingRemoteEvents.values()) this.deliverRemoteEvent(pending, client) + if (client.answers) { + for (const pending of this.pendingRemoteEvents.values()) this.deliverRemoteEvent(pending, client) + } try { yield { ...REMOTE_EVENT_STREAM_READY, clientId, host: registration.host } yield* client.queue.iterate(lifetime) @@ -533,7 +563,21 @@ export class TypertGatewayService extends Service implements TypertGateway { event: frame.event, args: frame.args, } - for (const client of this.remoteEventClients.values()) client.queue.push(wire) + let resolved = false + let resources: readonly RemoteAccessResource[] | undefined + for (const client of this.remoteEventClients.values()) { + if (!client.answers) { + // A non-owner receives the whole notification or nothing: its resources decide, payloads are never trimmed, + // and a notification with invalid resources reaches no non-owner. + if (!resolved) { + resources = eventResources(frame.resources, frame.args) + resolved = true + } + if (resources === undefined) continue + if (this.ctx.remoteAccess.decide({ principal: client.principal, action: `event:${frame.event}`, resources }).kind === 'deny') continue + } + client.queue.push(wire) + } } private startRemoteEvent(source: TypertRemoteEventInvocation): void { @@ -589,7 +633,11 @@ export class TypertGatewayService extends Service implements TypertGateway { this.pendingRemoteEvents.set(id, pending) for (const signal of signals) signal.addEventListener('abort', abort, { once: true }) if ([...signals].some(signal => signal.aborted)) abort() - else for (const client of this.remoteEventClients.values()) this.deliverRemoteEvent(pending, client) + else { + for (const client of this.remoteEventClients.values()) { + if (client.answers) this.deliverRemoteEvent(pending, client) + } + } } catch (error) { source.reject(error) } @@ -694,6 +742,31 @@ export class TypertGatewayService extends Service implements TypertGateway { assertPeerLive(request.peer, endpoint) const descriptor = this.resolveDescriptor(request.namespace, request.method, endpoint) assertExactArguments(request.args, descriptor, endpoint) + // Access precedes lookup resolution, so a refused call activates nothing. + // oxlint-disable-next-line typescript/no-unnecessary-condition -- other packages merge non-owner kinds into PrincipalMap. + if (request.peer.principal.kind !== 'owner') { + const resources = callResources( + this.ctx.typert, + this.resourceReaders(descriptor), + descriptor, + request.args, + request.signal ?? NEVER_ABORTED_SIGNAL, + ) + const decision: RemoteAccessDecision = resources === undefined + ? { kind: 'deny', reason: 'resource-invalid' } + : this.ctx.remoteAccess.decide({ + principal: request.peer.principal, + action: `${descriptor.namespace}/${descriptor.method}`, + resources, + args: request.args, + }) + if (decision.kind === 'deny') { + throw new RemoteError('remote-access/denied', `remote access: ${endpoint}: access denied (${decision.reason})`, { + action: endpoint, + reason: decision.reason, + }) + } + } const receiverContext = await this.resolveReceiverContext(descriptor, request.args, endpoint) const receiver: unknown = receiverContext.get(descriptor.service) if (!isObject(receiver)) { diff --git a/packages/api/gateway/src/types.ts b/packages/api/gateway/src/types.ts index 27a0989ea2..d83f120ab3 100644 --- a/packages/api/gateway/src/types.ts +++ b/packages/api/gateway/src/types.ts @@ -4,7 +4,7 @@ */ import type { Context } from '@deepseek-ai/cordis' -import type { PeerScope, RemoteUplinkWindow } from '@deepseek-ai/dsh-typert-protocol' +import type { PeerScope, RemoteUplinkWindow, TypertResourceReader } from '@deepseek-ai/dsh-typert-protocol' import type { RemoteEventHostInfo } from './stream-protocol.ts' /** One Remote method request after a carrier has decoded its envelope. */ @@ -34,6 +34,12 @@ export interface TypertRemoteEventFrame { readonly event: string /** Original event argument list after the owner validates it for JSON transport. */ readonly args: readonly unknown[] + /** + * Readers of the resources the arguments carry, keyed by resource kind and + * called with the arguments as positional parameters. Absent or empty for + * a notification that touches the global resource. + */ + readonly resources?: Readonly> } /** Live Host values used to project one scoped Remote Event. */ @@ -143,8 +149,8 @@ export interface TypertGateway { readonly wireStream: TypertGatewayWireStream /** - * Check for an active Client event stream. - * @returns whether a stream is open and has not been cancelled. + * Check for an active owner Client event stream, the only kind that can answer forwarded waterfalls. + * @returns whether an owner stream is open and has not been cancelled. */ hasLiveClient(): boolean diff --git a/packages/api/gateway/tests/echo-stream.host.spec.ts b/packages/api/gateway/tests/echo-stream.host.spec.ts index d56b529002..24a24f6918 100644 --- a/packages/api/gateway/tests/echo-stream.host.spec.ts +++ b/packages/api/gateway/tests/echo-stream.host.spec.ts @@ -7,7 +7,9 @@ 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 RemoteAccessService from '@deepseek-ai/dsh-api-remote-access' import { browserCookie, provideBrowserCredentials } from './browser-credentials.ts' +import { ownerPeer } from './peer.ts' /** The smallest Remote stream that reads its uplink: every carrier has to serve it alike. */ class EchoService extends TypertRemoteService { @@ -36,6 +38,7 @@ 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({ + peer: ownerPeer(ctx), namespace: 'echo', method: 'echo', args: { prefix: '> ' }, uplink: items(['a', 'b']), }) await expect(collect(replies)).resolves.toEqual(['> a', '> b']) @@ -73,6 +76,7 @@ async function setup(transport: boolean): Promise { provideBrowserCredentials(ctx) } await ctx.plugin(TypertRegistry) + await ctx.plugin(RemoteAccessService) await ctx.plugin(TypertGatewayService, {}) if (transport) await ctx.plugin({ inject: [...connectionInject], apply: applyConnection }) await ctx.plugin(EchoService) diff --git a/packages/api/gateway/tests/gateway-access.host.spec.ts b/packages/api/gateway/tests/gateway-access.host.spec.ts new file mode 100644 index 0000000000..fe7aeb4701 --- /dev/null +++ b/packages/api/gateway/tests/gateway-access.host.spec.ts @@ -0,0 +1,767 @@ +import { describe, expect, it, vi, afterEach } from 'vitest' +import { Context, symbols } from '@deepseek-ai/cordis' +import { apply as applyConnection, inject as connectionInject } from '@deepseek-ai/dsh-client-connection' +import RemoteAccessService, { + type RemoteAccessAction, + type RemoteAccessBinding, + type RemoteAccessResource, + type RemoteAccessResourceKind, + type RemoteAccessRole, +} from '@deepseek-ai/dsh-api-remote-access' +import { + Remote, + RemoteScope, + TypertRemoteService, + type InvocationDescriptor, + type InvocationParameterDescriptor, + type PeerScope, + type Principal, + type PrincipalMap, + type TypertContext, + type TypertLookup, + type TypertResource, +} from '@deepseek-ai/dsh-typert-protocol' +import TypertRegistry from '@deepseek-ai/dsh-typert-registry' +import { z } from 'zod' +import TypertGatewayService, { type TypertRemoteEventDispatch } from '@deepseek-ai/dsh-api-gateway' +import { callResources, eventResources } from '../src/access.ts' +import { provideBrowserCredentials } from './browser-credentials.ts' +import { ownerPeer, peerFor } from './peer.ts' + +interface AccessAgent { + readonly id: string +} + +declare module '@deepseek-ai/dsh-typert-protocol' { + interface PrincipalMap { + /** Test-only non-owner caller shared by the access specs. */ + 'test-guest': { readonly kind: 'test-guest'; readonly guest: string } + } + interface TypertLookupMap { + gatewayAccessAgent: TypertLookup + } + interface TypertContextMap { + gatewayAccessScope: TypertContext + } + interface TypertResourceMap { + /** Test-only resource kinds; the Gateway itself names no kind. */ + 'test-shelf': TypertResource + 'test-desk': TypertResource + } +} + +type Guest = PrincipalMap['test-guest'] + +const OWNER: Principal = { kind: 'owner' } +const GUEST: Guest = { kind: 'test-guest', guest: 'gateway' } +const GLOBAL = { kind: 'global' } as const +const shelf = (id: string) => ({ kind: 'test-shelf', id }) as const +const desk = (id: string) => ({ kind: 'test-desk', id }) as const + +const FIXTURE_ACTIONS: readonly RemoteAccessAction[] = [ + 'accessFixture/open', 'accessFixture/read', 'accessFixture/byAgent', 'accessFixture/byLookup', + 'accessFixture/byLookupAndOption', 'accessFixture/byList', 'accessFixture/scoped', 'accessFixture/scopedOnDesk', + 'accessFixture/scopedByReader', 'absentAccess/read', 'event:fixture/notice', 'event:fixture/shelf', +] + +function role(resource: K): RemoteAccessRole { + return { name: `test:${resource}`, resource, actions: FIXTURE_ACTIONS } +} + +/** One binding granting every fixture action on `resource`. */ +function on(resource: RemoteAccessResource): RemoteAccessBinding { + switch (resource.kind) { + case 'global': + return { role: role('global'), resource } + case 'test-shelf': + return { role: role('test-shelf'), resource } + case 'test-desk': + return { role: role('test-desk'), resource } + default: + throw new Error('no fixture role for this resource kind') + } +} + +class AccessFixture extends TypertRemoteService { + readonly principals: Principal[] = [] + release: PromiseWithResolvers = Promise.withResolvers() + + constructor(ctx: Context) { + super(ctx, 'accessFixture') + } + + @Remote + open(): string { + return this.seen() + } + + @Remote + read(request: { readonly shelfId?: unknown; readonly principal?: unknown }): string { + void request + return this.seen() + } + + @Remote({ resources: { 'test-shelf': agentId => agentId } }) + byAgent(agentId: string): string { + void agentId + return this.seen() + } + + @Remote + byLookup(agent: AccessAgent): string { + return `${agent.id}:${this.seen()}` + } + + @Remote({ resources: { 'test-desk': (agentId, request) => [agentId, request.deskId] } }) + byLookupAndOption(agent: AccessAgent, request: { readonly deskId: string; readonly shelfId: string }): string { + void request + return `${agent.id}:${this.seen()}` + } + + @Remote({ resources: { 'test-shelf': shelfIds => shelfIds } }) + byList(shelfIds: readonly string[]): string { + void shelfIds + return this.seen() + } + + @RemoteScope('gatewayAccessScope') + scoped(): string { + return this.seen() + } + + @RemoteScope('gatewayAccessScope', { resources: { 'test-desk': request => request.deskId } }) + scopedOnDesk(request: { readonly deskId: string }): string { + void request + return this.seen() + } + + @RemoteScope('gatewayAccessScope', { resources: { 'test-shelf': request => request.shelfId } }) + scopedByReader(request: { readonly shelfId: string }): string { + void request + return this.seen() + } + + @Remote + async slow(): Promise { + await this.release.promise + return this.seen() + } + + @Remote({ mode: 'stream' }) + async *watch(signal: AbortSignal): AsyncIterable { + yield this.seen() + await new Promise((resolve) => { + if (signal.aborted) resolve() + else signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) + } + + private seen(): string { + const principal = this.ctx.invocation?.peer.principal + if (principal !== undefined) this.principals.push(principal) + return principal?.kind ?? 'none' + } +} + +const roots: Context[] = [] + +afterEach(async () => { + vi.restoreAllMocks() + await Promise.all(roots.splice(0).map(ctx => ctx.fiber.dispose())) +}) + +async function setup(options: { readonly connection?: boolean } = {}): Promise<{ + readonly ctx: Context + readonly fixture: AccessFixture + /** Replace the guest's bindings: every fixture action on each listed resource. */ + readonly hold: (...resources: RemoteAccessResource[]) => void + readonly bindings: ReturnType readonly RemoteAccessBinding[]>> +}> { + const ctx = new Context() + roots.push(ctx) + await ctx.plugin(TypertRegistry) + await ctx.plugin(RemoteAccessService) + await ctx.plugin(TypertGatewayService) + if (options.connection === true) { + provideBrowserCredentials(ctx) + await ctx.plugin({ inject: [...connectionInject], apply: applyConnection }) + } + ctx.typert.resources.register('test-shelf', { field: 'shelfId' }) + await ctx.plugin(AccessFixture) + let held: readonly RemoteAccessBinding[] = [] + const bindings = vi.fn((guest: Guest): readonly RemoteAccessBinding[] => { void guest; return held }) + ctx.remoteAccess.registerPolicy('test-guest', { bindings }) + const receiver = ctx.get('accessFixture') as AccessFixture & { [symbols.original]?: AccessFixture } + return { + ctx, + fixture: receiver[symbols.original] ?? receiver, + hold: (...resources) => { held = resources.map(on) }, + bindings, + } +} + +/** Record every request the service decides from now on; the returned function lists their actions and resources in order. */ +function recordDecisions(): () => { readonly action: string; readonly resources: unknown }[] { + const decide = vi.spyOn(RemoteAccessService.prototype, 'decide') + return () => decide.mock.calls.map(([request]) => ({ action: request.action, resources: request.resources })) +} + +function call(ctx: Context, peer: PeerScope, method: string, args: Record = {}): Promise { + return ctx.typertGateway.invoke({ peer, namespace: 'accessFixture', method, args }) +} + +function registerScope(ctx: Context, resource: 'test-shelf' | undefined, resolve = vi.fn((id: string) => { void id; return ctx })): typeof resolve { + ctx.typert.contexts.registerHost('gatewayAccessScope', { + wire: 'scopeId', + wireTypeSymbol: '@fixture/access#ScopeId', + ...resource === undefined ? {} : { resource }, + resolve, + }) + return resolve +} + +describe('Gateway access checks', () => { + it('dispatches the owner without resolving resources or reading the policy', async () => { + const { ctx, bindings } = await setup() + const decide = vi.spyOn(RemoteAccessService.prototype, 'decide') + await expect(call(ctx, ownerPeer(ctx), 'open')).resolves.toBe('owner') + await expect(call(ctx, ownerPeer(ctx), 'read', { request: { shelfId: 7 } })).resolves.toBe('owner') + expect(decide).not.toHaveBeenCalled() + expect(bindings).not.toHaveBeenCalled() + }) + + it('starts only in a composition that mounts remoteAccess', async () => { + const ctx = new Context() + roots.push(ctx) + await ctx.plugin(TypertRegistry) + void ctx.plugin(TypertGatewayService) + await new Promise(resolve => setTimeout(resolve, 0)) + expect(ctx.get('typertGateway')).toBeUndefined() + await ctx.plugin(RemoteAccessService) + await vi.waitFor(() => { expect(ctx.get('typertGateway')).toBeDefined() }) + }) + + it('refuses a non-owner call no binding allows before the method runs', async () => { + const { ctx, fixture, hold } = await setup() + const guest = peerFor(ctx, GUEST) + await expect(call(ctx, guest, 'open')).rejects.toMatchObject({ + code: 'remote-access/denied', + message: 'remote access: accessFixture/open: access denied (no-allow)', + details: { action: 'accessFixture/open', reason: 'no-allow' }, + }) + expect(fixture.principals).toEqual([]) + hold(GLOBAL) + await expect(call(ctx, guest, 'open')).resolves.toBe('test-guest') + expect(fixture.principals).toEqual([GUEST]) + }) + + it('hands remoteAccess the endpoint action, the resolved resources, and the call arguments', async () => { + const { ctx, hold } = await setup() + hold(GLOBAL, shelf('s1'), shelf('s2')) + const decide = vi.spyOn(RemoteAccessService.prototype, 'decide') + const guest = peerFor(ctx, GUEST) + await call(ctx, guest, 'read', { request: { shelfId: 's1' } }) + await call(ctx, guest, 'byAgent', { agentId: 's2' }) + await call(ctx, guest, 'read', { request: {} }) + expect(decide.mock.calls.map(([request]) => request)).toEqual([ + { principal: GUEST, action: 'accessFixture/read', resources: [shelf('s1')], args: { request: { shelfId: 's1' } } }, + { principal: GUEST, action: 'accessFixture/byAgent', resources: [shelf('s2')], args: { agentId: 's2' } }, + { principal: GUEST, action: 'accessFixture/read', resources: [GLOBAL], args: { request: {} } }, + ]) + }) + + it('reads a lookup parameter\'s resource and lets a reader replace it for its kind', async () => { + const { ctx, hold } = await setup() + hold(shelf('s9'), desk('a1'), desk('a2'), desk('d9')) + const decisions = recordDecisions() + ctx.typert.lookups.register('gatewayAccessAgent', { + parameter: 'agent', + wire: 'agentId', + resource: 'test-desk', + hostTypeSymbol: '@fixture/access#Agent', + wireTypeSymbol: '@fixture/access#AgentId', + resolve: (id: string) => ({ id }), + }) + const guest = peerFor(ctx, GUEST) + await expect(call(ctx, guest, 'byLookup', { agentId: 'a1' })).resolves.toBe('a1:test-guest') + await expect(call(ctx, guest, 'byLookupAndOption', { agentId: 'a2', request: { deskId: 'd9', shelfId: 's9' } })) + .resolves.toBe('a2:test-guest') + expect(decisions().map(({ resources }) => resources)).toEqual([ + [desk('a1')], + // The reader owns `test-desk`; the registered `shelfId` field still names `test-shelf`. + [shelf('s9'), desk('a2'), desk('d9')], + ]) + }) + + it('refuses an empty reader list and a malformed default field with resource-invalid without asking remoteAccess', async () => { + const { ctx, hold, bindings } = await setup() + hold(shelf('s1'), shelf('s2')) + const decide = vi.spyOn(RemoteAccessService.prototype, 'decide') + const guest = peerFor(ctx, GUEST) + await expect(call(ctx, guest, 'byList', { shelfIds: [] })).rejects.toMatchObject({ + code: 'remote-access/denied', details: { action: 'accessFixture/byList', reason: 'resource-invalid' }, + }) + await expect(call(ctx, guest, 'read', { request: { shelfId: 7 } })).rejects.toMatchObject({ + code: 'remote-access/denied', details: { reason: 'resource-invalid' }, + }) + expect(decide).not.toHaveBeenCalled() + expect(bindings).not.toHaveBeenCalled() + // Every listed resource must be allowed. + await expect(call(ctx, guest, 'byList', { shelfIds: ['s1', 's2'] })).resolves.toBe('test-guest') + await expect(call(ctx, guest, 'byList', { shelfIds: ['s1', 's3'] })).rejects.toMatchObject({ details: { reason: 'no-allow' } }) + }) + + it('resolves a strict method whose Service is absent from its arguments alone', async () => { + const { ctx } = await setup() + const decisions = recordDecisions() + ctx.typert.register({ + package: '@fixture/gateway-access', + face: 'host', + schemas: [], + model: { services: [], events: [], objects: [] }, + invocations: [{ + id: '@fixture/gateway-access#absentAccess/read', + service: 'absentAccess', + namespace: 'absentAccess', + method: 'read', + invocation: { kind: 'direct' }, + parameters: [], + result: { mode: 'strict', typeSymbol: '@fixture/gateway-access#Result', create: () => z.string() }, + }], + }) + await expect(ctx.typertGateway.invoke({ peer: peerFor(ctx, GUEST), namespace: 'absentAccess', method: 'read', args: {} })) + .rejects.toMatchObject({ code: 'remote-access/denied', details: { action: 'absentAccess/read', reason: 'no-allow' } }) + expect(decisions()).toEqual([{ action: 'absentAccess/read', resources: [GLOBAL] }]) + }) + + it('checks access before resolving lookups, so a refused call activates nothing', async () => { + const { ctx, hold } = await setup() + const resolve = vi.fn((id: string) => ({ id })) + ctx.typert.lookups.register('gatewayAccessAgent', { + parameter: 'agent', + wire: 'agentId', + hostTypeSymbol: '@fixture/access#Agent', + wireTypeSymbol: '@fixture/access#AgentId', + resolve, + }) + const guest = peerFor(ctx, GUEST) + await expect(call(ctx, guest, 'byLookup', { agentId: 'a1' })).rejects.toMatchObject({ code: 'remote-access/denied' }) + expect(resolve).not.toHaveBeenCalled() + // A lookup without a resource kind contributes nothing, so the call touches the global resource. + hold(GLOBAL) + await expect(call(ctx, guest, 'byLookup', { agentId: 'a1' })).resolves.toBe('a1:test-guest') + expect(resolve).toHaveBeenCalledOnce() + }) + + it('keeps the principal out of reach of wire fields', async () => { + const { ctx, fixture, hold } = await setup({ connection: true }) + hold(GLOBAL) + const guest = ctx.connection.createPeer({ principal: GUEST }) + await expect(call(ctx, guest, 'read', { request: {}, principal: OWNER })).rejects.toMatchObject({ + code: 'gateway/arguments-invalid', + }) + const shared = ctx.connection.createSharedFetchHandler('/api') + const response = await shared.fetch(new Request('http://host/api/accessFixture/read', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + type: 'client-request', + rpcId: 'rpc-1', + method: 'accessFixture/read', + principal: OWNER, + payload: { args: { request: { principal: OWNER } } }, + }), + }), guest) + expect(await response.json()).toMatchObject({ result: { ok: true, value: 'test-guest' } }) + const payloadSibling = await shared.fetch(new Request('http://host/api/accessFixture/read', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + type: 'client-request', + rpcId: 'rpc-2', + method: 'accessFixture/read', + payload: { args: { request: {} }, principal: OWNER }, + }), + }), guest) + expect(await payloadSibling.json()).toMatchObject({ result: { ok: false } }) + expect(fixture.principals).toEqual([GUEST]) + }) +}) + +describe('Gateway access checks for Context methods', () => { + it('reads the Context identity as the resource its adapter declares, before resolving the Context', async () => { + const { ctx, fixture, hold } = await setup() + const resolve = registerScope(ctx, 'test-shelf') + const guest = peerFor(ctx, GUEST) + await expect(call(ctx, guest, 'scoped', { scopeId: 's1' })).rejects.toMatchObject({ + code: 'remote-access/denied', details: { action: 'accessFixture/scoped', reason: 'no-allow' }, + }) + expect(resolve).not.toHaveBeenCalled() + hold(shelf('s1')) + await expect(call(ctx, guest, 'scoped', { scopeId: 's1' })).resolves.toBe('test-guest') + expect(resolve).toHaveBeenCalledWith('s1') + expect(fixture.principals).toEqual([GUEST]) + }) + + it('adds the resources a reader declares to the Context resource, and lets a reader replace it for its kind', async () => { + const { ctx, hold } = await setup() + registerScope(ctx, 'test-shelf') + const decisions = recordDecisions() + const guest = peerFor(ctx, GUEST) + hold(shelf('s1')) + await expect(call(ctx, guest, 'scopedOnDesk', { scopeId: 's1', request: { deskId: 'd1' } })).rejects.toMatchObject({ + details: { reason: 'no-allow' }, + }) + hold(shelf('s1'), desk('d1')) + await expect(call(ctx, guest, 'scopedOnDesk', { scopeId: 's1', request: { deskId: 'd1' } })).resolves.toBe('test-guest') + await expect(call(ctx, guest, 'scopedByReader', { scopeId: 's9', request: { shelfId: 's1' } })).resolves.toBe('test-guest') + expect(decisions().map(({ resources }) => resources)).toEqual([ + [shelf('s1'), desk('d1')], + [shelf('s1'), desk('d1')], + [shelf('s1')], + ]) + }) + + it('refuses a malformed Context identity with resource-invalid without asking remoteAccess or resolving the Context', async () => { + const { ctx, hold, bindings } = await setup() + const resolve = registerScope(ctx, 'test-shelf') + hold(GLOBAL, shelf('s1')) + const decide = vi.spyOn(RemoteAccessService.prototype, 'decide') + const guest = peerFor(ctx, GUEST) + for (const scopeId of [7, '']) { + await expect(call(ctx, guest, 'scoped', { scopeId })).rejects.toMatchObject({ + code: 'remote-access/denied', details: { action: 'accessFixture/scoped', reason: 'resource-invalid' }, + }) + } + expect(resolve).not.toHaveBeenCalled() + expect(decide).not.toHaveBeenCalled() + expect(bindings).not.toHaveBeenCalled() + }) + + it('resolves a Context method whose adapter declares no resource by the ordinary sources', async () => { + const { ctx, hold } = await setup() + registerScope(ctx, undefined) + const decisions = recordDecisions() + const guest = peerFor(ctx, GUEST) + await expect(call(ctx, guest, 'scoped', { scopeId: 's1' })).rejects.toMatchObject({ details: { reason: 'no-allow' } }) + hold(GLOBAL, desk('d1')) + await expect(call(ctx, guest, 'scoped', { scopeId: 's1' })).resolves.toBe('test-guest') + await expect(call(ctx, guest, 'scopedOnDesk', { scopeId: 's1', request: { deskId: 'd1' } })).resolves.toBe('test-guest') + expect(decisions().map(({ resources }) => resources)).toEqual([ + [GLOBAL], + [GLOBAL], + [desk('d1')], + ]) + }) +}) + +describe('Gateway Peer lifetime', () => { + it('refuses calls and streams from a disposed Peer without affecting other Peers', async () => { + const { ctx } = await setup({ connection: true }) + const peer = peerFor(ctx, OWNER) + await peer.dispose() + await expect(call(ctx, peer, 'open')).rejects.toMatchObject({ code: 'gateway/peer-disposed' }) + await expect(ctx.typertGateway.stream({ peer, namespace: 'accessFixture', method: 'watch', args: {} })) + .rejects.toMatchObject({ code: 'gateway/peer-disposed' }) + await expect(ctx.typertGateway.wireStream.open('$events', { args: {} }, (async function* () {})(), peer, new AbortController().signal)) + .rejects.toMatchObject({ code: 'gateway/peer-disposed' }) + const result = await ctx.connection.createSharedFetchHandler('/api').fetch(new Request('http://host/api/$events/result', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + type: 'client-request', + rpcId: 'rpc-disposed', + method: '$events/result', + payload: { args: { clientId: 'any', eventId: 'any', outcome: { kind: 'next' } } }, + }), + }), peer) + expect(await result.json()).toMatchObject({ result: { ok: false, error: { code: 'gateway/peer-disposed' } } }) + await expect(call(ctx, ownerPeer(ctx), 'open')).resolves.toBe('owner') + }) + + it('refuses a disposed Peer before reading its wire payload', async () => { + const { ctx } = await setup({ connection: true }) + const peer = peerFor(ctx, OWNER) + await peer.dispose() + for (const payload of [{}, { args: 1 }, { args: {}, extra: true }]) { + await expect(ctx.typertGateway.wireStream.open('accessFixture/watch', payload, (async function* () {})(), peer, new AbortController().signal)) + .rejects.toMatchObject({ code: 'gateway/peer-disposed' }) + } + const response = await ctx.connection.createSharedFetchHandler('/api').fetch(new Request('http://host/api/accessFixture/open', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ type: 'client-request', rpcId: 'rpc-malformed', method: 'accessFixture/open', payload: { args: 1 } }), + }), peer) + expect(await response.json()).toMatchObject({ result: { ok: false, error: { code: 'gateway/peer-disposed' } } }) + }) + + it('ends a disposed Peer\'s streams and lets its running unary calls finish', async () => { + const { ctx, fixture } = await setup() + const peer = peerFor(ctx, OWNER) + const other = peerFor(ctx, OWNER) + const stream = (await ctx.typertGateway.stream({ peer, namespace: 'accessFixture', method: 'watch', args: {} }))[Symbol.asyncIterator]() + const survivorAbort = new AbortController() + const survivor = (await ctx.typertGateway.stream({ + peer: other, namespace: 'accessFixture', method: 'watch', args: {}, signal: survivorAbort.signal, + }))[Symbol.asyncIterator]() + await expect(stream.next()).resolves.toEqual({ done: false, value: 'owner' }) + await expect(survivor.next()).resolves.toEqual({ done: false, value: 'owner' }) + const running = call(ctx, peer, 'slow') + const pending = stream.next() + await peer.dispose() + // The stream's method signal joins the Peer's, so disposal cancels it like a carrier cancel. + await expect(pending).rejects.toMatchObject({ code: 'gateway/cancelled' }) + fixture.release.resolve() + await expect(running).resolves.toBe('owner') + const next = survivor.next() + let settled = false + void next.then(() => { settled = true }, () => { settled = true }) + await new Promise(resolve => setTimeout(resolve, 10)) + expect(settled).toBe(false) + survivorAbort.abort() + await expect(next).rejects.toMatchObject({ code: 'gateway/cancelled' }) + }) +}) + +/** Install a pushable event source; `push` yields one dispatch to every open `$events` stream. */ +function eventSource(ctx: Context): (dispatch: TypertRemoteEventDispatch) => void { + const queue: TypertRemoteEventDispatch[] = [] + let wake: (() => void) | undefined + ctx.effect(() => ctx.typertGateway.registerRemoteEvents(async function* (signal) { + while (!signal.aborted) { + while (queue.length > 0) yield queue.shift() as TypertRemoteEventDispatch + await new Promise((resolve) => { + wake = resolve + signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) + } + }, { home: '/fixture' })) + return (dispatch) => { + queue.push(dispatch) + wake?.() + } +} + +describe('Gateway forwarded events for non-owner Peers', () => { + it('delivers only allowed broadcasts and no answerable waterfall to a non-owner, and refuses its results', async () => { + const { ctx, hold } = await setup({ connection: true }) + hold(shelf('s1')) + const decide = vi.spyOn(RemoteAccessService.prototype, 'decide') + const push = eventSource(ctx) + const guest = ctx.connection.createPeer({ principal: GUEST }) + const abort = new AbortController() + const guestEvents = (await ctx.typertGateway.wireStream.open('$events', { args: {} }, (async function* () {})(), guest, abort.signal))[Symbol.asyncIterator]() + const guestReady = await guestEvents.next() + // A non-owner stream receives the same Host facts but cannot answer, so it does not count as a live Client. + expect(guestReady.value).toMatchObject({ type: 'ready', host: { home: '/fixture' } }) + expect(ctx.typertGateway.hasLiveClient()).toBe(false) + const ownerEvents = (await ctx.typertGateway.wireStream.open('$events', { args: {} }, (async function* () {})(), ctx.connection.browserPeer, abort.signal))[Symbol.asyncIterator]() + await expect(ownerEvents.next()).resolves.toMatchObject({ value: { type: 'ready', host: { home: '/fixture' } } }) + expect(ctx.typertGateway.hasLiveClient()).toBe(true) + + const agentCtx = ctx.extend({}) + const subject = {} + const settled = vi.fn() + push({ + event: 'fixture/ask', + request: { question: 'q', agent: subject }, + context: { value: agentCtx, subject, agentId: 'agent-1' }, + resolve: settled, + reject: settled, + }) + const resources = { 'test-shelf': (shelfId: unknown) => shelfId } + push({ event: 'fixture/notice', args: ['hello'] }) + push({ event: 'fixture/shelf', args: ['s2'], resources }) + push({ event: 'fixture/shelf', args: ['s1'], resources }) + await expect(ownerEvents.next()).resolves.toMatchObject({ value: { type: 'waterfall', event: 'fixture/ask' } }) + await expect(ownerEvents.next()).resolves.toMatchObject({ value: { type: 'emit', event: 'fixture/notice' } }) + await expect(ownerEvents.next()).resolves.toMatchObject({ value: { type: 'emit', event: 'fixture/shelf', args: ['s2'] } }) + await expect(ownerEvents.next()).resolves.toMatchObject({ value: { type: 'emit', event: 'fixture/shelf', args: ['s1'] } }) + // The global notice and the denied shelf never reach the non-owner; the allowed one arrives whole. + await expect(guestEvents.next()).resolves.toEqual({ done: false, value: { type: 'emit', event: 'fixture/shelf', args: ['s1'] } }) + expect(decide.mock.calls.map(([request]) => request)).toEqual([ + { principal: GUEST, action: 'event:fixture/notice', resources: [GLOBAL] }, + { principal: GUEST, action: 'event:fixture/shelf', resources: [shelf('s2')] }, + { principal: GUEST, action: 'event:fixture/shelf', resources: [shelf('s1')] }, + ]) + + const clientId: unknown = Reflect.get(guestReady.value as object, 'clientId') + const shared = ctx.connection.createSharedFetchHandler('/api') + const refused = await shared.fetch(new Request('http://host/api/$events/result', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + type: 'client-request', + rpcId: 'rpc-result', + method: '$events/result', + payload: { args: { clientId, eventId: 'any', outcome: { kind: 'next' } } }, + }), + }), guest) + expect(await refused.json()).toMatchObject({ + result: { ok: false, error: { code: 'gateway/invocation-unavailable', details: { endpoint: '$events/result' } } }, + }) + abort.abort() + await guestEvents.return?.() + await ownerEvents.return?.() + }) + + it('delivers a global event that a global role lists, and drops one whose resource is missing without asking remoteAccess', async () => { + const { ctx, hold } = await setup({ connection: true }) + hold(GLOBAL, shelf('s1')) + const decisions = recordDecisions() + const push = eventSource(ctx) + const abort = new AbortController() + const open = async (): Promise> => (await ctx.typertGateway.wireStream.open( + '$events', { args: {} }, (async function* () {})(), ctx.connection.createPeer({ principal: GUEST }), abort.signal, + ))[Symbol.asyncIterator]() + const streams = [await open(), await open()] + for (const events of streams) await events.next() + const missing = vi.fn(() => undefined) + push({ event: 'fixture/shelf', args: [null], resources: { 'test-shelf': missing } }) + push({ event: 'fixture/notice', args: ['hello'], resources: {} }) + for (const events of streams) { + await expect(events.next()).resolves.toEqual({ done: false, value: { type: 'emit', event: 'fixture/notice', args: ['hello'] } }) + } + // A frame's resources are resolved once for all non-owner streams. + expect(missing).toHaveBeenCalledOnce() + const notice = { action: 'event:fixture/notice', resources: [GLOBAL] } + expect(decisions()).toEqual([notice, notice]) + abort.abort() + for (const events of streams) await events.return?.() + }) + + it('contains a throwing policy to the non-owner deliveries it decides', async () => { + const ctx = new Context() + roots.push(ctx) + await ctx.plugin(TypertRegistry) + await ctx.plugin(RemoteAccessService) + await ctx.plugin(TypertGatewayService) + provideBrowserCredentials(ctx) + await ctx.plugin({ inject: [...connectionInject], apply: applyConnection }) + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) + ctx.remoteAccess.registerPolicy('test-guest', { bindings: () => { throw new Error('binding store unavailable') } }) + const push = eventSource(ctx) + const abort = new AbortController() + const open = async (peer: PeerScope): Promise> => + (await ctx.typertGateway.wireStream.open('$events', { args: {} }, (async function* () {})(), peer, abort.signal))[Symbol.asyncIterator]() + const guestEvents = await open(ctx.connection.createPeer({ principal: GUEST })) + const ownerEvents = await open(ctx.connection.browserPeer) + await guestEvents.next() + await ownerEvents.next() + push({ event: 'fixture/shelf', args: ['s1'], resources: { 'test-shelf': (shelfId: unknown) => shelfId } }) + push({ event: 'fixture/notice', args: ['after'] }) + await expect(ownerEvents.next()).resolves.toMatchObject({ value: { type: 'emit', event: 'fixture/shelf' } }) + // The shared source survives the failure: later events still reach the owner. + await expect(ownerEvents.next()).resolves.toMatchObject({ value: { type: 'emit', event: 'fixture/notice' } }) + expect(warn).toHaveBeenCalledWith( + 'remote access: the "test-guest" policy failed to list bindings for event:fixture/shelf; its further failures are not logged until access state changes') + const guestNext = guestEvents.next() + abort.abort() + await expect(guestNext).resolves.toEqual({ done: true, value: undefined }) + await ownerEvents.return?.() + }) +}) + +describe('resource resolution', () => { + const NEVER = new AbortController().signal + const ROOM = { kind: 'test-shelf', id: 'r1' } as const + const DESK = { kind: 'test-desk', id: 'd1' } as const + const DIRECT: InvocationDescriptor['invocation'] = { kind: 'direct' } + + /** A direct method's descriptor fields carrying only what resource resolution reads. */ + function method(...wires: string[]): Pick { + return { invocation: DIRECT, parameters: wires.map(wire => ({ name: wire, wire, source: 'json' }) as InvocationParameterDescriptor) } + } + + async function registry(): Promise { + const ctx = new Context() + roots.push(ctx) + await ctx.plugin(TypertRegistry) + ctx.typert.resources.register('test-shelf', { field: 'roomId' }) + ctx.typert.resources.register('test-desk', { field: 'deskId' }) + return ctx.typert + } + + it('reads a default field from a top-level argument, else from the first object argument, else touches global', async () => { + const typert = await registry() + expect(callResources(typert, undefined, method('roomId'), { roomId: 'r1' }, NEVER)) + .toEqual([ROOM]) + expect(callResources(typert, undefined, method('count', 'tags', 'none', 'request'), { + count: 1, tags: ['r9'], none: null, request: { roomId: 'r1', deskId: 'd1' }, + }, NEVER)).toEqual([ROOM, DESK]) + // Only the first object argument is read; a later one naming the field does not count. + expect(callResources(typert, undefined, method('first', 'second'), { first: {}, second: { roomId: 'r1' } }, NEVER)) + .toEqual([GLOBAL]) + expect(callResources(typert, undefined, method(), {}, NEVER)).toEqual([GLOBAL]) + }) + + it('lets a reader replace the defaults for its kind, called with the positional wire arguments', async () => { + const typert = await registry() + const readers = { 'test-shelf': (ids: unknown) => ids } + expect(callResources(typert, readers, method('ids'), { ids: ['r1', 'r2'] }, NEVER)) + .toEqual([ROOM, { kind: 'test-shelf', id: 'r2' }]) + expect(callResources(typert, readers, method('ids', 'roomId'), { ids: undefined, roomId: 'r9' }, NEVER)) + .toEqual([GLOBAL]) + const signal = new AbortController().signal + const positional = vi.fn((...args: readonly unknown[]) => (args.at(-1) === signal ? args[1] : 'wrong')) + expect(callResources(typert, { 'test-desk': positional }, { + ...method('first', 'desk'), cancellation: { parameter: 'signal' }, + }, { first: 'f', desk: 'd1' }, signal)).toEqual([DESK]) + expect(positional).toHaveBeenCalledWith('f', 'd1', signal) + }) + + it('reads lookup parameters and a Context identity of a declared kind, an omitted optional lookup as none', async () => { + const typert = await registry() + typert.lookups.register('gatewayAccessAgent', { + parameter: 'agent', + wire: 'agentId', + resource: 'test-desk', + hostTypeSymbol: '@fixture/access#Agent', + wireTypeSymbol: '@fixture/access#AgentId', + resolve: (id: string) => ({ id }), + }) + typert.contexts.registerHost('gatewayAccessScope', { + wire: 'scopeId', wireTypeSymbol: '@fixture/access#ScopeId', resource: 'test-desk', resolve: () => undefined, + }) + const agent = { name: 'agent', wire: 'agentId', source: 'lookup', lookup: 'gatewayAccessAgent' } as InvocationParameterDescriptor + const unknownLookup = { name: 'other', wire: 'otherId', source: 'lookup', lookup: 'absentLookup' } as InvocationParameterDescriptor + expect(callResources(typert, undefined, { invocation: DIRECT, parameters: [agent, unknownLookup] }, { agentId: undefined, otherId: 'o1' }, NEVER)) + .toEqual([GLOBAL]) + expect(callResources(typert, undefined, { invocation: DIRECT, parameters: [agent] }, { agentId: 'd1' }, NEVER)) + .toEqual([DESK]) + const scoped = (context: string): InvocationDescriptor['invocation'] => ({ kind: 'context', context, wire: 'scopeId', codec: { mode: 'src-json' } }) + expect(callResources(typert, undefined, { invocation: scoped('gatewayAccessScope'), parameters: [agent] }, { scopeId: 'd0', agentId: 'd1' }, NEVER)) + .toEqual([{ kind: 'test-desk', id: 'd0' }, DESK]) + // A required Context identity is never omitted, so a missing one is malformed. + expect(callResources(typert, undefined, { invocation: scoped('gatewayAccessScope'), parameters: [] }, {}, NEVER)) + .toBeUndefined() + expect(callResources(typert, undefined, { invocation: scoped('absentScope'), parameters: [] }, { scopeId: 'd0' }, NEVER)) + .toEqual([GLOBAL]) + }) + + it('reports invalid resources when a reader throws or returns an empty list, or a resource id is not a non-empty string', async () => { + const typert = await registry() + const one = method('value') + const throwing = { 'test-shelf': () => { throw new TypeError('malformed call') } } + expect(callResources(typert, throwing, one, { value: 1 }, NEVER)).toBeUndefined() + for (const value of ['', 7, ['r1', ''], [null], []]) { + expect(callResources(typert, { 'test-shelf': (read: unknown) => read }, one, { value }, NEVER)).toBeUndefined() + } + // A default field names exactly one identity, so an array is malformed. + for (const roomId of [7, ['r1'], '']) { + expect(callResources(typert, undefined, method('roomId'), { roomId }, NEVER)).toBeUndefined() + } + }) + + it('reads a forwarded event through the readers its entry declares, and the global resource without them', () => { + expect(eventResources(undefined, ['r1'])).toEqual([GLOBAL]) + expect(eventResources({}, ['r1'])).toEqual([GLOBAL]) + expect(eventResources({ 'test-shelf': (id: unknown) => id }, ['r1'])).toEqual([ROOM]) + expect(eventResources({ 'test-desk': (_first: unknown, second: unknown) => second }, ['r1', 'd1'])) + .toEqual([DESK]) + // An event carries every resource it declares, so a missing one is invalid rather than a global event. + expect(eventResources({ 'test-shelf': () => undefined }, ['r1'])).toBeUndefined() + expect(eventResources({ 'test-shelf': () => [] }, ['r1'])).toBeUndefined() + expect(eventResources({ 'test-shelf': () => 7 }, ['r1'])).toBeUndefined() + }) +}) diff --git a/packages/api/gateway/tests/gateway-stream.host.spec.ts b/packages/api/gateway/tests/gateway-stream.host.spec.ts index 2efcb950e0..72a46e21f6 100644 --- a/packages/api/gateway/tests/gateway-stream.host.spec.ts +++ b/packages/api/gateway/tests/gateway-stream.host.spec.ts @@ -21,6 +21,7 @@ import { RemoteError, } from '@deepseek-ai/dsh-typert-protocol' import TypertRegistry from '@deepseek-ai/dsh-typert-registry' +import RemoteAccessService from '@deepseek-ai/dsh-api-remote-access' declare module '@deepseek-ai/dsh-typert-protocol' { interface RemoteErrorDetailsMap { @@ -41,6 +42,7 @@ import type { RemoteEventClientId, RemoteEventInvocationFrame, } from '../src/stream-protocol.ts' +import { ownerPeer, peerFor } from './peer.ts' vi.mock('node:crypto', async (importOriginal) => { const actual = await importOriginal() @@ -368,13 +370,13 @@ describe('Typert Remote streams', () => { const unregister = ctx.effect(() => ctx.typertGateway.registerRemoteEvents(source.source, REMOTE_HOST)) const abort = new AbortController() const empty = (async function* () {})() - const events = await ctx.typertGateway.wireStream.open('$events', { args: {} }, empty, undefined, abort.signal) + const events = await ctx.typertGateway.wireStream.open('$events', { args: {} }, empty, ownerPeer(ctx), abort.signal) const first = events[Symbol.asyncIterator]() await first.next() expect(ctx.typertGateway.hasLiveClient()).toBe(true) abort.abort() expect(ctx.typertGateway.hasLiveClient()).toBe(false) - const replacement = await ctx.typertGateway.wireStream.open('$events', { args: {} }, empty, undefined, new AbortController().signal) + const replacement = await ctx.typertGateway.wireStream.open('$events', { args: {} }, empty, ownerPeer(ctx), new AbortController().signal) const second = replacement[Symbol.asyncIterator]() try { await second.next() @@ -432,7 +434,7 @@ describe('Typert Remote streams', () => { 'feed/sync', { args: { label: 'wire' } }, toAsync([]), - undefined, + ownerPeer(ctx), new AbortController().signal, ) await expect(collect(source)).resolves.toEqual(['wire:one', 'wire:two']) @@ -441,7 +443,7 @@ describe('Typert Remote streams', () => { 'feed/echo', { args: { prefix: 'wire:' } }, toAsync(['a', 'b']), - undefined, + ownerPeer(ctx), new AbortController().signal, ) await expect(collect(echoed)).resolves.toEqual(['wire:a', 'wire:b']) @@ -450,13 +452,16 @@ describe('Typert Remote streams', () => { 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({ + peer: ownerPeer(ctx), 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({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'echo', args: { prefix: 'x' }, }))).resolves.toEqual([]) await expect(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'echo', args: { prefix: 'x' }, })).rejects.toMatchObject({ code: 'gateway/signature-invalid', @@ -474,6 +479,7 @@ describe('Typert Remote streams', () => { }), } await expect(collect(await ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'ignore', args: { prefix: 'i' }, uplink: unread, }))).resolves.toEqual(['i:one', 'i:two']) await vi.waitFor(() => { expect(returned).toHaveBeenCalledOnce() }) @@ -482,6 +488,7 @@ describe('Typert Remote streams', () => { 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({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'echo', args: { prefix: '' }, uplink: toAsync(['ok', 1]), }) const iterator = rejected[Symbol.asyncIterator]() @@ -496,6 +503,7 @@ describe('Typert Remote streams', () => { expect(service.returns).toBe(1) await expect(collect(await ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'swallow', args: {}, uplink: toAsync([1]), }))).rejects.toMatchObject({ code: 'gateway/input-invalid' }) }) @@ -504,6 +512,7 @@ describe('Typert Remote streams', () => { const { ctx, service } = await setup(false) const abort = new AbortController() const source = await ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'peek', args: {}, uplink: neverYielding(), signal: abort.signal, }) const iterator = source[Symbol.asyncIterator]() @@ -520,6 +529,7 @@ describe('Typert Remote streams', () => { const { ctx, service } = await setup(false) const abort = new AbortController() const source = await ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'hold', args: {}, uplink: toAsync(['unread']), signal: abort.signal, }) const iterator = source[Symbol.asyncIterator]() @@ -536,7 +546,9 @@ describe('Typert Remote streams', () => { 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) + const caller = peerFor(ctx, { kind: 'owner' }) await expect(collect(await ctx.typertGateway.stream({ + peer: caller, namespace: 'feed', method: 'context', args: { label: 'ctx' }, uplink: toAsync([1, { nested: true }]), }))).resolves.toEqual([ 'feed/context:feed:{"label":"ctx"}:ctx', @@ -547,18 +559,22 @@ describe('Typert Remote streams', () => { expect(service.peers.at(-1)?.id).toMatch(/^[0-9a-f-]{36}$/) expect(service.invocationOutsideCall()).toBeUndefined() await expect(collect(await ctx.typertGateway.stream({ + peer: caller, namespace: 'feed', method: 'context', args: { label: 'again' }, }))).resolves.toHaveLength(2) + expect(service.peers.at(-1)).toBe(caller) 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({ + peer: ownerPeer(ctx), 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({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'context', args: { label: 'bad' }, uplink: toAsync([1n]), }))).rejects.toMatchObject({ code: 'gateway/input-invalid', @@ -579,6 +595,7 @@ describe('Typert Remote streams', () => { }), } const source = await ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'hold', args: {}, uplink: stuck, signal: abort.signal, }) const iterator = source[Symbol.asyncIterator]() @@ -592,6 +609,7 @@ describe('Typert Remote streams', () => { it('closes the uplink before returning a method blocked on it', async () => { const { ctx } = await setup(false) const source = await ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'drain', args: {}, uplink: neverYielding(), }) const iterator = source[Symbol.asyncIterator]() @@ -608,6 +626,7 @@ describe('Typert Remote streams', () => { let index = 0 const returned = vi.fn(async (): Promise> => ({ done: true, value: undefined })) const source = await ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'hold', args: {}, uplink: { [Symbol.asyncIterator]: () => ({ next: () => reads[index++]!.promise, return: returned }) }, }) @@ -639,9 +658,9 @@ describe('Typert Remote streams', () => { const { ctx, service } = await setup(false) service.source = { [Symbol.iterator]: () => ({ next: () => new ReadResult() }) } const source = await ctx.typertGateway.stream(direction === 'downlink' - ? { namespace: 'feed', method: 'items', args: {} } + ? { peer: ownerPeer(ctx), namespace: 'feed', method: 'items', args: {} } : { - namespace: 'feed', method: 'echo', args: { prefix: '' }, + peer: ownerPeer(ctx), namespace: 'feed', method: 'echo', args: { prefix: '' }, uplink: { [Symbol.asyncIterator]: () => ({ next: async () => new ReadResult() }) }, }) const iterator = source[Symbol.asyncIterator]() @@ -664,6 +683,7 @@ describe('Typert Remote streams', () => { const next = vi.fn((): IteratorResult => ({ done: false, value: 'item' })) service.source = { [Symbol.iterator]: () => ({ next }) } const source = await ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'items', args: {}, signal: abort.signal, }) const iterator = source[Symbol.asyncIterator]() @@ -687,9 +707,9 @@ describe('Typert Remote streams', () => { } service.source = { [Symbol.iterator]: () => ({ next }) } const source = await ctx.typertGateway.stream(direction === 'downlink' - ? { namespace: 'feed', method: 'items', args: {}, signal: abort.signal } + ? { peer: ownerPeer(ctx), namespace: 'feed', method: 'items', args: {}, signal: abort.signal } : { - namespace: 'feed', method: 'hold', args: {}, signal: abort.signal, + peer: ownerPeer(ctx), namespace: 'feed', method: 'hold', args: {}, signal: abort.signal, uplink: { [Symbol.asyncIterator]: () => ({ next }) }, }) const iterator = source[Symbol.asyncIterator]() @@ -706,6 +726,7 @@ describe('Typert Remote streams', () => { it('reports done on every read after the uplink ended', async () => { const { ctx } = await setup(false) await expect(collect(await ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'rereads', args: {}, uplink: toAsync(['a', 'b']), }))).resolves.toEqual(['a', 'b', 'done']) }) @@ -713,6 +734,7 @@ describe('Typert Remote streams', () => { 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({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'context', args: { label: 'absent' }, uplink: toAsync([undefined, 'x']), }))).resolves.toEqual([ 'feed/context:feed:{"label":"absent"}:absent', @@ -721,6 +743,7 @@ describe('Typert Remote streams', () => { 'typert gateway: feed/context: invocation.uplink() is available once per call', ]) await expect(collect(await ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'echo', args: { prefix: '' }, uplink: toAsync([undefined]), }))).rejects.toMatchObject({ code: 'gateway/input-invalid', details: { endpoint: 'feed/echo', field: 'uplink' } }) }) @@ -732,6 +755,7 @@ describe('Typert Remote streams', () => { [Symbol.asyncIterator]: () => ({ next: () => new Promise>(() => {}), return: returned }), } await expect(ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'unary', args: { label: 'a' }, uplink: unread, })).rejects.toMatchObject({ code: 'gateway/signature-invalid' }) await vi.waitFor(() => { expect(returned).toHaveBeenCalledOnce() }) @@ -740,6 +764,7 @@ describe('Typert Remote streams', () => { it('lets a unary method read uplink items while it runs', async () => { const { ctx } = await setup(false) await expect(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'unaryUplink', args: {}, uplink: toAsync(['a', 'b']), })).resolves.toEqual(['a', 'b']) }) @@ -747,6 +772,7 @@ describe('Typert Remote streams', () => { it('derives SRC descriptors whose uplink arrives without a codec', async () => { const { ctx } = await setup(false) await expect(collect(await ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'srcEcho', args: { prefix: 's:' }, uplink: toAsync(['a', 2]), }))).resolves.toEqual(['s:a', 's:2']) }) @@ -756,6 +782,7 @@ describe('Typert Remote streams', () => { const window = { maxBytes: 512, frameOverheadBytes: 40 } for (const uplinkWindow of [undefined, window]) { await expect(collect(await ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'echo', args: { prefix: '' }, uplink: toAsync(['a']), uplinkWindow, }))).resolves.toEqual(['a']) } @@ -803,7 +830,7 @@ describe('Typert Remote streams', () => { ]) }) expect(service.returns).toBe(1) - expect(service.peers.at(-1)).toBe(ctx.connection.operator) + expect(service.peers.at(-1)).toBe(ctx.connection.browserPeer) sendOpen(socket, 'rejected', 'feed/echo', { prefix: '' }) socket.send(JSON.stringify({ type: 'item', streamId: 'rejected', value: 1 })) @@ -860,6 +887,7 @@ describe('Typert Remote streams', () => { const { ctx, service } = await setup(false) const abort = new AbortController() const source = await ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'follow', args: { label: 'a' }, @@ -870,35 +898,44 @@ describe('Typert Remote streams', () => { const pending = iterator.next() abort.abort(new Error('fixture cancellation')) await expect(pending).rejects.toThrow('Remote invocation "feed/follow" was aborted') - expect(service.signals).toEqual([abort.signal]) + // A stream's method signal also ends with its Peer, so it follows the caller's signal rather than being it. + expect(service.signals).toHaveLength(1) + expect(service.signals[0]?.reason).toBe(abort.signal.reason) expect(service.returns).toBe(1) await expect(collect(await ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'sync', args: { label: 'b' }, }))).resolves.toEqual(['b:one', 'b:two']) await expect(collect(await ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'invalid', args: {}, }))).resolves.toEqual([42]) await expect(collect(await ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'nonJson', args: {}, }))).resolves.toEqual([1n]) await expect(ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'missing', args: {}, })).rejects.toMatchObject({ code: 'gateway/result-invalid' }) await expect(collect(await ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'src', args: { label: 'c' }, }))).resolves.toEqual(['c:src']) const abortedBeforeOpen = new AbortController() abortedBeforeOpen.abort(new Error('cancelled before open')) await expect(ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'abortBeforeOpen', args: {}, signal: abortedBeforeOpen.signal, })).rejects.toThrow('Remote invocation "feed/abortBeforeOpen" was aborted') const abortedBeforeIteration = new AbortController() abortedBeforeIteration.abort(new Error('cancelled before iteration')) const preCancelled = await ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'sync', args: { label: 'ignored' }, signal: abortedBeforeIteration.signal, }) await expect(collect(preCancelled)).rejects.toThrow('Remote invocation "feed/sync" was aborted') @@ -907,9 +944,11 @@ describe('Typert Remote streams', () => { it('keeps unary and stream invocation modes distinct', async () => { const { ctx } = await setup(false) await expect(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'sync', args: { label: 'a' }, })).rejects.toMatchObject({ code: 'gateway/signature-invalid' } satisfies Partial) await expect(ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'unary', args: { label: 'a' }, })).rejects.toMatchObject({ code: 'gateway/signature-invalid' } satisfies Partial) }) @@ -1004,7 +1043,7 @@ describe('Typert Remote streams', () => { const idle: AsyncIterable = { [Symbol.asyncIterator]: () => ({ next: () => new Promise>(() => {}), return: returned }), } - const events = await ctx.typertGateway.wireStream.open('$events', { args: {} }, idle, undefined, new AbortController().signal) + const events = await ctx.typertGateway.wireStream.open('$events', { args: {} }, idle, ownerPeer(ctx), new AbortController().signal) await vi.waitFor(() => { expect(returned).toHaveBeenCalledOnce() }) await events[Symbol.asyncIterator]().return?.() @@ -1259,7 +1298,7 @@ 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. + // Connection minted the browser 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) @@ -1593,6 +1632,7 @@ async function setup( provideBrowserCredentials(ctx) } await ctx.plugin(TypertRegistry) + await ctx.plugin(RemoteAccessService) await ctx.plugin(TypertGatewayService, gatewayConfig) if (transport) { await ctx.plugin({ inject: [...connectionInject], apply: applyConnection }) diff --git a/packages/api/gateway/tests/gateway.host.spec.ts b/packages/api/gateway/tests/gateway.host.spec.ts index e0fcbec649..8f3f6fddc4 100644 --- a/packages/api/gateway/tests/gateway.host.spec.ts +++ b/packages/api/gateway/tests/gateway.host.spec.ts @@ -7,7 +7,6 @@ import { apply as applyConnection, inject as connectionInject } from '@deepseek- import type { ConnectionRpcHandler, HostConnectionHandle, - PeerId, PeerScope, } from '@deepseek-ai/dsh-client-connection' import type { WebServer, WebRoute } from '@deepseek-ai/dsh-host-webserver' @@ -23,7 +22,9 @@ import { } from '@deepseek-ai/dsh-typert-protocol' import TypertRegistry, { type TypertContribution } from '@deepseek-ai/dsh-typert-registry' import TypertGatewayService, { TypertGatewayError } from '@deepseek-ai/dsh-api-gateway' +import RemoteAccessService from '@deepseek-ai/dsh-api-remote-access' import { provideBrowserCredentials } from './browser-credentials.ts' +import { ownerPeer } from './peer.ts' interface FixtureAgent { readonly id: string @@ -116,12 +117,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 operator: PeerScope + /** The browser Peer every call this fake dispatches speaks for. */ + readonly browserPeer: PeerScope constructor(ctx: Context) { super(ctx, 'connection') - this.operator = { id: 'fake-operator' as PeerId, ctx, dispose: () => Promise.resolve() } + this.browserPeer = ownerPeer(ctx) } get rpc() { @@ -135,7 +136,7 @@ class FakeConnectionService extends Service { owner.effect(() => { this.channel = channel this.matches = matches - this.handler = (endpoint, payload, signal) => handler(endpoint, payload, signal, this.operator) + this.handler = (endpoint, payload, signal) => handler(endpoint, payload, signal, this.browserPeer) return () => { this.channel = undefined this.matches = undefined @@ -407,6 +408,7 @@ describe('TypertGatewayService', () => { const abort = new AbortController() await expect(caller.typertGateway.invoke({ + peer: ownerPeer(caller), namespace: 'goals', method: 'create', args: { agentId: 'agent-1', request: { title: ' ship ' } }, @@ -416,6 +418,7 @@ describe('TypertGatewayService', () => { expect(service.lastSignal).toBe(abort.signal) await expect(caller.typertGateway.invoke({ + peer: ownerPeer(caller), namespace: 'goals', method: 'create', args: { agentId: 'agent-1', request: { title: 'again' } }, @@ -431,6 +434,7 @@ describe('TypertGatewayService', () => { registerStrict(ctx, [renameDescriptor()]) await expect(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'rename', args: { agentId: 'agent-1', request: { title: 'land' } }, @@ -446,6 +450,7 @@ describe('TypertGatewayService', () => { const abort = new AbortController() await expect(caller.typertGateway.invoke({ + peer: ownerPeer(caller), namespace: 'goals', method: 'create', args: { agentId: 'agent-1', request: { title: 'ship' } }, @@ -464,6 +469,7 @@ describe('TypertGatewayService', () => { registerAgentLookup(ctx, { id: 'agent-1' }) await expect(ctx.extend({ fixtureScope: 'unbound' }).typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'create', args: { agentId: 'agent-1', request: { title: 'ship' } }, @@ -477,6 +483,7 @@ describe('TypertGatewayService', () => { await dispose() await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'create', args: { agentId: 'agent-1', request: { title: 'ship' } }, @@ -490,6 +497,7 @@ describe('TypertGatewayService', () => { ctx.typert.contexts.registerHost('gatewayFixture', contextProvider(scoped)) await expect(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'rename', args: { agentId: 'agent-1', request: { title: 'land' } }, @@ -503,15 +511,19 @@ describe('TypertGatewayService', () => { await ctx.plugin(InheritedMethodService) await expect(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'exported', method: 'execute', args: { value: 'ship' }, })).resolves.toBe('ship') await expect(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'empty', method: 'ping', args: {}, })).resolves.toBe('pong') await expect(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'inherited', method: 'run', args: { value: 'land' }, })).resolves.toBe('land') await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'other', method: 'absent', args: {}, }), 'gateway/invocation-unavailable') }) @@ -521,6 +533,7 @@ describe('TypertGatewayService', () => { await colliding.plugin(CollidingWireService) registerAgentLookup(colliding, { id: 'agent-1' }) await expectCode(colliding.typertGateway.invoke({ + peer: ownerPeer(colliding), namespace: 'colliding-wire', method: 'run', args: { agentId: 'agent-1' }, @@ -528,6 +541,7 @@ describe('TypertGatewayService', () => { const missing = await setup() await expectCode(missing.ctx.typertGateway.invoke({ + peer: ownerPeer(missing.ctx), namespace: 'goals', method: 'rename', args: { agentId: 'agent-1', request: { title: 'land' } }, @@ -537,6 +551,7 @@ describe('TypertGatewayService', () => { await contextCollision.plugin(ContextWireService) contextCollision.typert.contexts.registerHost('gatewayFixture', contextProvider(contextCollision.extend())) await expectCode(contextCollision.typertGateway.invoke({ + peer: ownerPeer(contextCollision), namespace: 'context-wire', method: 'run', args: { agentId: 'agent-1' }, @@ -551,6 +566,7 @@ describe('TypertGatewayService', () => { await disposeLookup() await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'create', args: { agentId: 'agent-1', request: { title: 'ship' } }, @@ -559,6 +575,7 @@ describe('TypertGatewayService', () => { registerAgentLookup(ctx, agent) await serviceFiber.dispose() await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'create', args: { agentId: 'agent-1', request: { title: 'ship' } }, @@ -573,6 +590,7 @@ describe('TypertGatewayService', () => { await dispose() await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'rename', args: { agentId: 'agent-1', request: { title: 'land' } }, @@ -583,6 +601,7 @@ describe('TypertGatewayService', () => { resolve: () => { throw new Error('provider failed') }, }) const error = await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'rename', args: { agentId: 'agent-1', request: { title: 'land' } }, @@ -600,6 +619,7 @@ describe('TypertGatewayService', () => { registerStrict(ctx, [renameDescriptor()]) await expect(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'rename', args: { agentId: 'agent-1', request: { title: 'land' } }, @@ -615,6 +635,7 @@ describe('TypertGatewayService', () => { wire: 'differentAgentId', }) await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'rename', args: { agentId: 'agent-1', request: { title: 'land' } }, @@ -626,6 +647,7 @@ describe('TypertGatewayService', () => { resolve: () => undefined, }) await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'rename', args: { agentId: 'agent-1', request: { title: 'land' } }, @@ -640,6 +662,7 @@ describe('TypertGatewayService', () => { resolve: async () => { throw new Error('lookup failed') }, }) const failure = await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'create', args: { agentId: 'agent-1', request: { title: 'ship' } }, @@ -652,6 +675,7 @@ describe('TypertGatewayService', () => { resolve: () => Promise.resolve(undefined), }) await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'create', args: { agentId: 'agent-1', request: { title: 'ship' } }, @@ -663,6 +687,7 @@ describe('TypertGatewayService', () => { resolve: async id => ({ id }), }) await expect(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'create', args: { agentId: 'agent-1', request: { title: 'ship' } }, @@ -675,6 +700,7 @@ describe('TypertGatewayService', () => { await dispose() await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'passthrough', args: { value: 'would pass through SRC' }, @@ -684,12 +710,14 @@ describe('TypertGatewayService', () => { it('seeds the no-downgrade guard from definitions present before Gateway startup', async () => { const ctx = new Context() await ctx.plugin(TypertRegistry) + await ctx.plugin(RemoteAccessService) const dispose = registerStrict(ctx, [passthroughDescriptor()]) await ctx.plugin(TypertGatewayService) await ctx.plugin(GoalService) await dispose() await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'passthrough', args: { value: 'would pass through SRC' }, @@ -699,6 +727,7 @@ describe('TypertGatewayService', () => { it('retains the no-downgrade guard across Gateway Service reloads', async () => { const ctx = new Context() await ctx.plugin(TypertRegistry) + await ctx.plugin(RemoteAccessService) const gatewayFiber = ctx.plugin(TypertGatewayService) await gatewayFiber await ctx.plugin(GoalService) @@ -709,6 +738,7 @@ describe('TypertGatewayService', () => { await ctx.plugin(TypertGatewayService) await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'passthrough', args: { value: 'would pass through SRC' }, @@ -721,6 +751,7 @@ describe('TypertGatewayService', () => { await ctx.plugin(SecondSharedService) const error = await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'shared', method: 'run', args: { value: 'ship' }, @@ -739,6 +770,7 @@ describe('TypertGatewayService', () => { const ctx = await setupGateway() await ctx.plugin(testCase.plugin) await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: testCase.namespace, method: 'run', args: testCase.args, @@ -753,6 +785,7 @@ describe('TypertGatewayService', () => { ctx.typert.lookups.register('gatewayFixtureAlias', provider) await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'create', args: { agentId: 'agent-1', request: { title: 'ship' } }, @@ -764,16 +797,19 @@ describe('TypertGatewayService', () => { registerAgentLookup(ctx, { id: 'agent-1' }) await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'create', args: { request: { title: 'ship' } }, }), 'gateway/arguments-invalid') await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'create', args: { agentId: 'agent-1', request: { title: 'ship' }, optional: true }, }), 'gateway/arguments-invalid') await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'create', args: [] as unknown as Record, @@ -786,6 +822,7 @@ describe('TypertGatewayService', () => { registerStrict(ctx, [strictOnlyDescriptor()]) await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'strictOnly', args: { request: { title: 1 } }, @@ -793,6 +830,7 @@ describe('TypertGatewayService', () => { service.nextResult = { title: 1 } await expect(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'strictOnly', args: { request: { title: 'ship' } }, @@ -805,6 +843,7 @@ describe('TypertGatewayService', () => { service.nextResult = 1n await expect(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'strictOnly', args: { request: { title: 'ship' } }, @@ -824,6 +863,7 @@ describe('TypertGatewayService', () => { ])('rejects non-JSON SRC input %#', async (value) => { const { ctx } = await setup() await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'passthrough', args: { value }, @@ -836,6 +876,7 @@ describe('TypertGatewayService', () => { // cannot see which are optional, so an absent field is admitted; the case // above keeps an explicitly undefined field rejected. await expect(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'passthrough', args: {}, @@ -848,6 +889,7 @@ describe('TypertGatewayService', () => { const cyclic: { self?: unknown } = {} cyclic.self = cyclic await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'passthrough', args: { value: cyclic }, @@ -856,6 +898,7 @@ describe('TypertGatewayService', () => { const result = new Date(0) service.nextResult = result await expect(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'passthrough', args: { value: null }, @@ -865,6 +908,7 @@ describe('TypertGatewayService', () => { it('accepts dense JSON and rejects decorated arrays and object properties', async () => { const { ctx } = await setup() await expect(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'passthrough', args: { value: [1, { nested: true }] }, @@ -882,6 +926,7 @@ describe('TypertGatewayService', () => { Object.defineProperty(accessor, 'value', { get: () => true, enumerable: true }) for (const value of [sparseWithExtra, symbolArray, symbolObject, hidden, accessor]) { await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'passthrough', args: { value }, }), 'gateway/input-invalid') } @@ -896,6 +941,7 @@ describe('TypertGatewayService', () => { registerStrict(ctx, [createDescriptor()]) await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'create', args: { agentId: 'agent-1', request: { title: 'ship' } }, @@ -906,6 +952,7 @@ describe('TypertGatewayService', () => { const ctx = await setupGateway() await ctx.plugin(WrongBindingService) await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'wrong-binding', method: 'run', args: { value: 'ship' }, @@ -914,6 +961,7 @@ describe('TypertGatewayService', () => { await ctx.plugin(GoalService) registerStrict(ctx, [{ ...passthroughDescriptor(), method: 'missing' }]) await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'missing', args: { value: 'ship' }, @@ -931,6 +979,7 @@ describe('TypertGatewayService', () => { method: 'run', }]) await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'no-binding', method: 'run', args: { value: 'ship' }, }), 'gateway/binding-invalid') @@ -954,6 +1003,7 @@ describe('TypertGatewayService', () => { }], }) await expect(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'plain', method: 'run', args: { value: 'land' }, })).resolves.toBe('land') }) @@ -968,6 +1018,7 @@ describe('TypertGatewayService', () => { }) try { await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'missing-method', method: 'run', args: { value: 'ship' }, }), 'gateway/method-unavailable') } finally { @@ -981,6 +1032,7 @@ describe('TypertGatewayService', () => { service.businessError = failure await expect(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'fail', args: { request: { reason: 'fixture' } }, @@ -990,6 +1042,7 @@ describe('TypertGatewayService', () => { it('reports an absent endpoint without retaining receiver state', async () => { const { ctx } = await setup() await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'absent', args: {}, @@ -999,6 +1052,7 @@ describe('TypertGatewayService', () => { it('mounts a shared /api interceptor through an optional Connection and returns existing RPC results', async () => { const ctx = new Context().extend({ fixtureScope: 'rpc-caller' }) await ctx.plugin(TypertRegistry) + await ctx.plugin(RemoteAccessService) await ctx.plugin(FakeConnectionService) const gatewayFiber = ctx.plugin(TypertGatewayService) await gatewayFiber @@ -1031,11 +1085,12 @@ describe('TypertGatewayService', () => { abort.abort(new Error('client disconnected')) expect(service.lastSignal?.aborted).toBe(true) await expect(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'create', args: { agentId: 'agent-1', request: { title: 'direct' } }, })).resolves.toMatchObject({ title: 'direct' }) - expect(service.lastPeer).toBe(connection.operator) + expect(service.lastPeer).toBe(connection.browserPeer) const invalid = await handler('goals/create', { invalid: true }, signal) expect(invalid).toMatchObject({ ok: false, @@ -1207,6 +1262,7 @@ describe('TypertGatewayService', () => { it('claims and validates in-process Remote event results for the active Client generation', async () => { const ctx = new Context() await ctx.plugin(TypertRegistry) + await ctx.plugin(RemoteAccessService) await ctx.plugin(FakeConnectionService) await ctx.plugin(TypertGatewayService) const connection = rawConnection(ctx) @@ -1229,7 +1285,9 @@ describe('TypertGatewayService', () => { }) })(), { home: '/home/fixture' }) const carrier = new AbortController() - const events = rawGatewayEventHarness(ctx).openRemoteEvents({ args: {} }, carrier.signal) + const events = (await ctx.typertGateway.wireStream.open( + '$events', { args: {} }, (async function* () {})(), ownerPeer(ctx), carrier.signal, + ))[Symbol.asyncIterator]() const opening = await events.next() expect(opening).toMatchObject({ done: false, @@ -1252,7 +1310,7 @@ describe('TypertGatewayService', () => { value: undefined, }) - await events.return(undefined) + await events.return?.(undefined) await unregister() await ctx.fiber.dispose() }) @@ -1260,6 +1318,7 @@ describe('TypertGatewayService', () => { it('preserves a lookup policy rejection through the Connection RPC result', async () => { const ctx = new Context() await ctx.plugin(TypertRegistry) + await ctx.plugin(RemoteAccessService) await ctx.plugin(FakeConnectionService) await ctx.plugin(TypertGatewayService) await ctx.plugin(GoalService) @@ -1284,6 +1343,7 @@ describe('TypertGatewayService', () => { it('caches SRC ownership until the Cordis Service set changes', async () => { const ctx = new Context() await ctx.plugin(TypertRegistry) + await ctx.plugin(RemoteAccessService) await ctx.plugin(FakeConnectionService) await ctx.plugin(TypertGatewayService) const observedFiber = ctx.plugin(ObservedClaimService) @@ -1319,6 +1379,7 @@ describe('TypertGatewayService', () => { const connectionFiber = ctx.plugin({ inject: [...connectionInject], apply: applyConnection }) await connectionFiber await ctx.plugin(TypertRegistry) + await ctx.plugin(RemoteAccessService) const gatewayFiber = ctx.plugin(TypertGatewayService) await gatewayFiber const goalFiber = ctx.plugin(GoalService) @@ -1428,6 +1489,7 @@ async function setup(): Promise<{ async function setupGateway(): Promise { const ctx = new Context() await ctx.plugin(TypertRegistry) + await ctx.plugin(RemoteAccessService) await ctx.plugin(TypertGatewayService) return ctx } @@ -1444,17 +1506,6 @@ function rawConnection(ctx: Context): FakeConnectionService { return receiver[symbols.original] ?? receiver } -interface GatewayEventHarness { - openRemoteEvents(payload: unknown, signal: AbortSignal): AsyncGenerator -} - -function rawGatewayEventHarness(ctx: Context): GatewayEventHarness { - const receiver = ctx.get('typertGateway') as unknown as GatewayEventHarness & { - [symbols.original]?: GatewayEventHarness - } - return receiver[symbols.original] ?? receiver -} - function registerStrict(ctx: Context, descriptors: readonly InvocationDescriptor[]): () => Promise { return ctx.typert.register({ package: '@fixture/gateway', diff --git a/packages/api/gateway/tests/strict-source.host.spec.ts b/packages/api/gateway/tests/strict-source.host.spec.ts index 2bd9c35a17..fcf4988c12 100644 --- a/packages/api/gateway/tests/strict-source.host.spec.ts +++ b/packages/api/gateway/tests/strict-source.host.spec.ts @@ -3,7 +3,9 @@ import { afterEach, expect, it } from 'vitest' import { z } from 'zod' import { bindTypertRemote, Remote } from '@deepseek-ai/dsh-typert-protocol' import TypertRegistry, { type TypertContribution } from '@deepseek-ai/dsh-typert-registry' +import RemoteAccessService from '@deepseek-ai/dsh-api-remote-access' import TypertGateway from '../src/index.ts' +import { ownerPeer } from './peer.ts' const contexts: Context[] = [] afterEach(async () => { await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose())) }) @@ -39,6 +41,7 @@ async function setup() { const ctx = new Context() contexts.push(ctx) await ctx.plugin(TypertRegistry) + await ctx.plugin(RemoteAccessService) await ctx.plugin(TypertGateway) const service = new ItemsService(ctx) return { ctx, service } @@ -47,11 +50,11 @@ async function setup() { it('validates source dispatch with the ordinary local Registry before invoking the Service', async () => { const { ctx, service } = await setup() ctx.typert.register(contribution) - await expect(ctx.typertGateway.invoke({ namespace: 'items', method: 'read', args: { request: { title: 'ready' } } })) + await expect(ctx.typertGateway.invoke({ namespace: 'items', method: 'read', args: { request: { title: 'ready' } }, peer: ownerPeer(ctx) })) .resolves.toBe('ready') for (const args of [{}, { request: { title: 'ready', extra: true } }, { request: { title: 1 } }]) { const code: unknown = expect.stringMatching(/^gateway\/(arguments|input)-invalid$/) - await expect(ctx.typertGateway.invoke({ namespace: 'items', method: 'read', args })) + await expect(ctx.typertGateway.invoke({ namespace: 'items', method: 'read', args, peer: ownerPeer(ctx) })) .rejects.toMatchObject({ code }) } expect(service.calls).toBe(1) @@ -59,7 +62,7 @@ it('validates source dispatch with the ordinary local Registry before invoking t it('allows limited source fallback without recovering erased object constraints', async () => { const { ctx, service } = await setup() - await expect(ctx.typertGateway.invoke({ namespace: 'items', method: 'read', args: { request: { title: 'ready', extra: true } } })) + await expect(ctx.typertGateway.invoke({ namespace: 'items', method: 'read', args: { request: { title: 'ready', extra: true } }, peer: ownerPeer(ctx) })) .resolves.toBe('ready') expect(service.calls).toBe(1) }) @@ -71,7 +74,7 @@ it('refuses source fallback after the strict definition owner unloads', async () expect(ctx.typert.local.get('items/read')).toBe(contribution.invocations[0]) await owner.dispose() expect(ctx.typert.local.get('items/read')).toBeUndefined() - await expect(ctx.typertGateway.invoke({ namespace: 'items', method: 'read', args: { request: { title: 'ready' } } })) + await expect(ctx.typertGateway.invoke({ namespace: 'items', method: 'read', args: { request: { title: 'ready' } }, peer: ownerPeer(ctx) })) .rejects.toMatchObject({ code: 'gateway/definition-unavailable' }) expect(service.calls).toBe(0) }) diff --git a/packages/api/gateway/tsconfig.host.json b/packages/api/gateway/tsconfig.host.json index 5cb68a7183..7694cea7cb 100644 --- a/packages/api/gateway/tsconfig.host.json +++ b/packages/api/gateway/tsconfig.host.json @@ -6,6 +6,7 @@ "tsBuildInfoFile": "lib/tsconfig.host.tsbuildinfo" }, "files": [ + "src/access.ts", "src/index.ts", "src/remote-error-codes.ts", "src/stream-protocol.ts", @@ -28,6 +29,9 @@ { "path": "../../client/connection/tsconfig.host.json" }, + { + "path": "../remote-access/tsconfig.host.json" + }, { "path": "../../host/webserver" }, diff --git a/packages/api/remote-access/README.i18n.yaml b/packages/api/remote-access/README.i18n.yaml new file mode 100644 index 0000000000..6a423304bb --- /dev/null +++ b/packages/api/remote-access/README.i18n.yaml @@ -0,0 +1,34 @@ +# Bilingual-pair consistency record for README.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/api/remote-access/README.md +/: + en: bbd88553f12d4f3d + zh: c5fa4ac3f9746170 +/deepseek-ai-dsh-api-remote-access: + en: 15713a51f827c9c1 + zh: a1349a5f83efc82c +/deepseek-ai-dsh-api-remote-access/summary: + en: ee81bdee09f42c9b + zh: 41c6864e59c24519 +/deepseek-ai-dsh-api-remote-access/table-of-contents: + en: a18ecc232e3c3933 + zh: b8e43580403f6d77 +/deepseek-ai-dsh-api-remote-access/host-service-remoteaccessservice-ctx-key-remoteaccess: + en: 42dd46f104e790f2 + zh: c15b37ecda2a1f0d +/deepseek-ai-dsh-api-remote-access/client-face: + en: 1db01235b6af84fa + zh: c0ced511cc1841a0 +/deepseek-ai-dsh-api-remote-access/model-experience: + en: 8b07250cecacd3aa + zh: d28ab93980bdcdad +/deepseek-ai-dsh-api-remote-access/model-experience/kv-cache-effect: + en: 60835eca1cee4e0e + zh: 3b5579ceccd5b5e7 +/deepseek-ai-dsh-api-remote-access/known-limitations-and-deferred-work: + en: 66b483bf5144ac46 + zh: 84bde5e0b1af270b +/deepseek-ai-dsh-api-remote-access/known-limitations-and-deferred-work/dev-note: + en: e67f633dc6fe7773 + zh: e88aeb617ffb74ea diff --git a/packages/api/remote-access/README.md b/packages/api/remote-access/README.md new file mode 100644 index 0000000000..5ccfb9e3f4 --- /dev/null +++ b/packages/api/remote-access/README.md @@ -0,0 +1,97 @@ +--- +description: "Method-level Remote access control for non-owner Peers: role-based policy decisions, list filtering, and the per-Peer access snapshot." +kind: "package-reference" +--- + +# @deepseek-ai/dsh-api-remote-access + +English | [中文](README.zh.md) + +## Summary + +Decides which Remote methods a non-owner Peer may call and which forwarded events it may receive. Business packages define roles, each an action set over one resource kind, and register one policy per non-owner principal kind that binds roles to resources. The [API Gateway](../gateway/README.md) resolves the resources a call or event touches and asks this package before it resolves lookups or decodes arguments. List methods filter items through `allows()`, and each Peer follows its own access snapshot, so the Client can hide entries the Host would refuse. The [Peer principals Agent Note](../../../.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md) owns the design. + +## Table of Contents + +- [Host service: `RemoteAccessService` (ctx key: `remoteAccess`)](#host-service) +- [Client face](#client-face) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + +----- + + +## Host service: `RemoteAccessService` (ctx key: `remoteAccess`) + +Every composition that mounts [API Gateway](../gateway/README.md) mounts this service: the Gateway injects `remoteAccess` and does not start without it. [`bundle/base`](../../bundle/base/cordis.patch.yml) loads it beside `typert-gateway`. The owner (`{ kind: 'owner' }`) is never checked; every method below concerns non-owner principals. + +| Member | Contract | +|---|---| +| `registerPolicy(kind, policy)` | Registers the `RemoteAccessPolicy` of one non-owner principal kind; its `bindings()` and `explain()` receive principals typed by that kind. A second policy for the same kind throws. The registration is a Cordis effect of the caller's fiber; registration and removal recompute open snapshots. | +| `invalidate(principal?)` | Reports that bindings or condition answers for one principal, or for every principal, may have changed. A business package calls it on every grant, revocation, mode change, and expiry. | +| `decide(request)` | Gateway-only. `request` is `{ principal, action, resources, args? }`: the caller or recipient, the endpoint `ns/method` or `event:`, the valid resources the Gateway resolved, and a call's wire arguments. Returns `{ kind: 'allow' }` or `{ kind: 'deny', reason }`; the Gateway throws `remote-access/denied` with details `{ action, reason }` for a denied call and drops a denied event without building an error. The owner and `remoteAccess/follow` are always allowed. | +| `allows(resource)` | Reads the invocation of the Context that accessed the service, so a list method calls `this.ctx.remoteAccess.allows(resource)` for each item. Decides the call's endpoint action on that resource without arguments, reading the bindings again on every check. True for the owner and outside a Remote call. | +| `snapshotFor(principal)` | The baseline a page opens with: `{ type: 'baseline', owner: true }` for the owner, otherwise the principal's current grants. | +| `follow(signal)` | Remote stream `remoteAccess/follow`: one `baseline`, then `upsert` and `remove` frames. The owner receives only `{ type: 'baseline', owner: true }`. The stream ends when `signal` aborts or the service unloads, so a reloaded service starts every reader from a fresh baseline. | + +A role `{ name, resource, actions }` is a plain value naming an action set over one resource kind, `global` or a kind declared in `TypertResourceMap`. A binding `{ role, resource, condition? }` grants the role on one resource of the role's kind. A policy answers `bindings(principal)` and an optional `explain(request)` synchronously from state it holds in memory. A request is allowed only when, on every resolved resource, some binding names that resource, its role lists the action, and it has no condition or its condition allows. Resources are independent: a grant on `global` implies nothing on any other resource, and no resource implies another. A condition receives `{ action, resource, args? }` and runs only for a binding whose resource and role match; `args` is absent for events, snapshots, and list filtering. `bindings()` runs once per `decide()`, once per snapshot computation, and once per Remote call that filters through `allows()` and again after each `invalidate()` of its principal or policy registration or removal; it is never cached across decisions, so time-dependent state applies to the next call. + +The Gateway resolves resources from `@Remote` and `@RemoteScope` `resources` readers, lookup and Host Context adapter `resource` declarations, and the default fields in `typert.resources`, and the [API Gateway](../gateway/README.md) owns that order; this package reads no argument field. A call that names no resource touches the `global` resource alone, so only a `global` role can allow it; putting an action in a `global` role lets the principal call it on any resource the call does not name. The Gateway refuses a call whose resources are invalid with `resource-invalid` without asking `decide()`. + +When `bindings()`, a condition, or `explain()` throws, the decision is refused with `policy-failed`; in a snapshot, a throwing `bindings()` leaves the principal no grant and a throwing condition excludes its action. A faulty rule therefore never breaks the shared `$events` source, the snapshot stream, or page rendering. The service logger reports the first failure of each policy after each `invalidate()` and each policy registration or removal; later failures of that policy stay silent until then. + +A forwarded event is decided as the action `event:` on the resources its forwarding entry's readers name, or on `global` when the entry declares none, and reaches a non-owner whole or not at all; its payload is never trimmed. An event carries every resource it declares, so a reader that returns `undefined` makes the event's resources invalid, and the Gateway delivers it to no non-owner without asking `decide()`. An event names at most one resource, so collection changes travel on follow streams (`session/follow`, `session/control`, `workspace/follow`), whose methods decide each item inline through `allows()`. + +The service adds a `global` row named `REMOTE_ACCESS_SNAPSHOT_KEY` (`__DSH_REMOTE_ACCESS__`) holding the owner baseline `{ type: 'baseline', owner: true }` to every index the webServer renders through `webserver/index-inject`. A business package that renders a non-owner page, such as a liveshare viewer page rendered on the owner Host, places an assignment of that global to `ctx.remoteAccess.snapshotFor(principal)` after every index injection row, for example a `global` row appended to the end of `ctx.webServer.collectIndexInjections()`. The later assignment replaces the owner baseline, and the Client entry reads the global when it mounts, so the page opens with the viewer's grants instead of the owner's. + +| `details.reason` of `remote-access/denied` | Cause | +|---|---| +| `resource-invalid` | Given by the Gateway before `decide()`: a resource reader threw or returned an empty list, or a resource id, including a Context identity, is not a non-empty string. | +| `no-allow` | The principal kind has no policy, or no binding allows the action on some resource and neither a condition nor `explain()` names a reason. | +| `policy-failed` | `bindings()`, a condition, or `explain()` threw; the failure is logged as described above. | +| condition or `explain()` reason | On the first resource no binding allows, the first refusing condition of a matching binding names the reason; without one, `explain(request)` does. | + +Snapshot grants list, per resource the principal's bindings name, the sorted endpoint actions of those bindings' roles that `decide()` allows there without call arguments, so the snapshot agrees with `decide()` for the same principal, action, and resource; a resource with no such action gets no grant, and the stream pushes `remove` once its allowed set becomes empty. `event:` actions never enter the snapshot. Snapshots are recomputed only after `invalidate()` and policy registration or removal. + + +## Client face + +The Client entry reads the baseline the index injected under `REMOTE_ACCESS_SNAPSHOT_KEY` synchronously, mounts the generated `remoteAccess` contribution itself through `mountAccess(ctx, contribution, initial)`, follows the snapshot stream, and provides: + +- the Client service `ctx.remoteAccess.source(kind, id, action)`, an identity-stable boolean source per `(kind, id, action)` for one resource of a declared kind; +- the standard Hook `useRemoteAccess(action)`, which answers on the `global` resource. + +Domain UI packages publish their resource Hooks over `source()`: [ui-session](../../client/ui-session/README.md) provides `useSessionAccess(sessionId, action)` and the Session-scope `useAccess(action)` on Session resources, a different name from the `global` `useRemoteAccess`, and [ui-workspace](../../client/ui-workspace/README.md) provides `useWorkspaceAccess(workspaceId, action)` on Workspace resources. A Hook's `action` is a `RemoteEndpoint`, an endpoint some Client Remote namespace declares. A non-owner answer is whether that resource's grant lists the action and never falls back to the `global` answer, so a resource without a grant, or whose grant lists no action, answers false. Every answer is true for the owner; a non-owner page without an injected baseline, and a non-owner page whose stream fails terminally, answers false; the terminal failure is reported with its error, for a non-owner with the advice to reload the page and for the owner with the note that its answers are unaffected; a reconnect keeps the last snapshot. A malformed injected baseline fails the mount. Hooks never issue requests, and the Host still checks every call. [`dsh-client-test-runtime`](../../test-support/client-runtime/README.md) replaces this row with allow-all access in Client specs. + + +## Model Experience + +None, as this package decides Remote access for Client callers and registers nothing model-facing. + +#### KV Cache effect + +No direct effect. + +## Known Limitations and Deferred Work + + + +- Snapshot and list-filtering decisions carry no call arguments, so a condition that refines its answer from `args` can refuse a call that the snapshot shows as allowed. +- Resource readers and automatic resolution must cover every resource a method touches; a resource the call does not name is not checked, so a principal granted on the named resources reaches it. +- Default resource resolution matches wire field names; adding a field with a registered kind's name to a method adds that kind's resource to the call. +- Exact Connection Fetch routes do not pass through the Gateway; the shared Fetch handler refuses them to every non-owner Peer with 403 until each route declares its own access rule. +- Interaction participation (answering forwarded waterfalls) is owner-only: non-owner `$events` streams receive only the broadcast frames whose `event:` action the policy allows on the event's resources. +- `api-session/added` carries the whole `SessionSummary`, so a non-owner allowed to receive it also learns its `parentSessionId`. +- A granted Session's cwd and tool paths already contain the owner's absolute paths, so the `$events` ready frame gives a non-owner the full `host`, including `home`; the Gateway does not hide `home` separately. +- `workspace/follow` notices a Workspace whose visibility a policy revoked only when that Workspace or the Workspace order next changes, and then replaces the non-owner's baseline; an access change alone sends no frame. + + +### Dev Note + +

+Working context for maintainers — click to expand + +None. + +
diff --git a/packages/api/remote-access/README.zh.md b/packages/api/remote-access/README.zh.md new file mode 100644 index 0000000000..c14033e993 --- /dev/null +++ b/packages/api/remote-access/README.zh.md @@ -0,0 +1,97 @@ +--- +description: "非 owner Peer 的 Remote 方法级访问控制:基于角色的 policy 判定、列表过滤与每个 Peer 的访问快照。" +kind: "package-reference" +--- + +# @deepseek-ai/dsh-api-remote-access + +[English](README.md) | 中文 + +## 概述 + +决定非 owner Peer 可以调用哪些 Remote 方法、可以收到哪些转发事件。业务包定义 role(限定一种资源种类的 action 集合),并为每种非 owner principal kind 注册一个 policy,把 role 绑定到资源上。[API Gateway](../gateway/README.zh.md) 解析一次调用或一个事件访问的资源,并在解析 lookup、解码参数之前询问本包。列表方法经 `allows()` 逐项过滤;每个 Peer 订阅自己的访问快照,Client 据此隐藏 Host 会拒绝的入口。设计由 [Peer 身份属性 Agent Note](../../../.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md) 负责。 + +## 目录 + +- [Host 服务:`RemoteAccessService`(ctx key:`remoteAccess`)](#host-service) +- [Client 面](#client-face) +- [模型体验](#model-experience) +- [已知限制与暂缓事项](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## Host 服务:`RemoteAccessService`(ctx key:`remoteAccess`) + +挂载 [API Gateway](../gateway/README.zh.md) 的组合都要挂载本服务:Gateway 注入 `remoteAccess`,没有它就不启动。[`bundle/base`](../../bundle/base/cordis.patch.yml) 紧随 `typert-gateway` 装配它。owner(`{ kind: 'owner' }`)从不受检查;下列成员都只针对非 owner principal。 + +| 成员 | 契约 | +|---|---| +| `registerPolicy(kind, policy)` | 为一种非 owner principal kind 注册 `RemoteAccessPolicy`;其 `bindings()` 与 `explain()` 收到的 principal 按该 kind 定型。同一 kind 再注册一个 policy 即抛错。登记是调用方 fiber 的 Cordis effect;注册与撤回都会重新计算已打开的快照。 | +| `invalidate(principal?)` | 报告某个 principal(省略时为所有 principal)的 binding 或 condition 结论可能已变化。业务包在每次授权、撤权、模式切换与过期时调用。 | +| `decide(request)` | 仅供 Gateway 使用。`request` 为 `{ principal, action, resources, args? }`:调用方或接收方、端点 `ns/method` 或 `event:`、Gateway 解析出的合法资源,以及调用的 wire 参数。返回 `{ kind: 'allow' }` 或 `{ kind: 'deny', reason }`;调用被拒时由 Gateway 抛出 details 为 `{ action, reason }` 的 `remote-access/denied`,事件被拒时由 Gateway 直接丢弃、不构造错误。owner 与 `remoteAccess/follow` 恒放行。 | +| `allows(resource)` | 读访问本服务的 Context 上的 invocation,因此列表方法对每一项调用 `this.ctx.remoteAccess.allows(resource)`。不带参数判定该调用的端点 action 在该资源上是否被允许,每次判定都重新读取 binding。owner 与 Remote 调用之外恒为 true。 | +| `snapshotFor(principal)` | 页面打开时的 baseline:owner 为 `{ type: 'baseline', owner: true }`,否则为该 principal 当前的 grant。 | +| `follow(signal)` | Remote stream `remoteAccess/follow`:一个 `baseline`,之后是 `upsert` 与 `remove` 帧。owner 只收到 `{ type: 'baseline', owner: true }`。`signal` abort 或服务卸载时流结束,因此重载后的服务从新的 baseline 回答每个读者。 | + +role `{ name, resource, actions }` 是普通值,表示限定一种资源种类(`global` 或在 `TypertResourceMap` 中声明的种类)的 action 集合。binding `{ role, resource, condition? }` 在该 role 种类的一个资源上授予该 role。policy 根据预先载入内存的状态同步回答 `bindings(principal)` 与可选的 `explain(request)`。只有在每个已解析资源上都有某个 binding 指向该资源、其 role 列出该 action、且没有 condition 或 condition 放行时,请求才被放行。资源之间相互独立:`global` 上的授权不蕴含任何其他资源上的授权,任何资源也不蕴含另一个资源。condition 收到 `{ action, resource, args? }`,只对资源与 role 都匹配的 binding 调用;事件、快照与列表过滤时没有 `args`。`bindings()` 在每次 `decide()`、每次快照计算、每次经 `allows()` 过滤的 Remote 调用中各调用一次,且在其 principal 每次被 `invalidate()` 或 policy 注册、撤回后再调用一次,不跨判定缓存,因此依赖时间的状态对下一次调用即时生效。 + +Gateway 依次从 `@Remote` 与 `@RemoteScope` 的 `resources` 读取函数、lookup 与 Host Context adapter 的 `resource` 声明、`typert.resources` 中的默认字段解析资源,解析顺序由 [API Gateway](../gateway/README.zh.md) 负责;本包不读取任何参数字段。不指明任何资源的调用只访问 `global` 资源,因此只有 `global` 级 role 能放行;把某个 action 放进 `global` 级 role,等于允许该 principal 在调用未指明的任何资源上调用它。资源非法的调用由 Gateway 以 `resource-invalid` 拒绝,不询问 `decide()`。 + +`bindings()`、condition 或 `explain()` 抛错时,本次判定以 `policy-failed` 拒绝;计算快照时,`bindings()` 抛错即该 principal 没有 grant,condition 抛错即该 action 不放行。有问题的规则因此不会破坏共享的 `$events` 事件源、快照流或页面渲染。每次 `invalidate()` 以及每次 policy 注册或撤回之后,服务日志对每个 policy 只报告第一次失败;此后该 policy 的失败不再记录,直到下一次上述变化。 + +转发事件按其转发条目的读取函数指明的资源(条目未声明时为 `global`)判定 action `event:`,对非 owner 整条送达或整条不送,从不裁剪 payload。事件携带其声明的每个资源,因此读取函数返回 `undefined` 时该事件的资源非法,Gateway 不询问 `decide()`,不送达任何非 owner。一个事件至多指明一个资源,因此集合类变化走 follow 类流(`session/follow`、`session/control`、`workspace/follow`),由这些方法内联经 `allows()` 逐项判定。 + +本服务经 `webserver/index-inject` 为 webServer 渲染的每个 index 加入名为 `REMOTE_ACCESS_SNAPSHOT_KEY`(`__DSH_REMOTE_ACCESS__`)的 `global` 行,值为 owner baseline `{ type: 'baseline', owner: true }`。渲染非 owner 页面的业务包(例如在 owner Host 上渲染 liveshare 读者页)在所有 index 注入行之后放置一条把该全局赋值为 `ctx.remoteAccess.snapshotFor(principal)` 的语句,例如追加到 `ctx.webServer.collectIndexInjections()` 末尾的一条 `global` 行。后写的赋值覆盖 owner baseline,Client 入口在挂载时读取该全局,因此页面以读者的 grant 而非 owner 的快照打开。 + +| `remote-access/denied` 的 `details.reason` | 原因 | +|---|---| +| `resource-invalid` | 由 Gateway 在 `decide()` 之前给出:资源读取函数抛错或返回空列表,或某个资源 id(含 Context 身份)不是非空字符串。 | +| `no-allow` | 该 principal kind 没有 policy,或在某个资源上没有 binding 放行该 action,且 condition 与 `explain()` 都没有给出原因。 | +| `policy-failed` | `bindings()`、condition 或 `explain()` 抛错;该失败按上文规则记录日志。 | +| condition 或 `explain()` 的原因 | 在第一个没有 binding 放行的资源上,取匹配 binding 中第一个拒绝的 condition 的原因;没有时取 `explain(request)` 的返回值。 | + +快照 grant 按该 principal 的 binding 所指的每个资源,列出这些 binding 的 role 中、经 `decide()` 在该资源上不带调用参数放行的端点 action(已排序),因此在同一 principal、action 与资源上快照与 `decide()` 一致;没有此类 action 的资源不出 grant,放行集合变空时推送 `remove`。`event:` action 不进入快照。快照只在 `invalidate()` 与 policy 注册或撤回之后重算。 + + +## Client 面 + +Client 入口同步读取 index 注入在 `REMOTE_ACCESS_SNAPSHOT_KEY` 下的 baseline,经 `mountAccess(ctx, contribution, initial)` 自行挂载生成的 `remoteAccess` contribution,订阅快照流,并提供: + +- Client 服务 `ctx.remoteAccess.source(kind, id, action)`,为已声明种类的某个资源上的每个 `(kind, id, action)` 提供一个身份稳定的布尔 source; +- 标准 Hook `useRemoteAccess(action)`,回答 `global` 资源上的访问。 + +领域 UI 包在 `source()` 之上发布各自资源的 Hook:[ui-session](../../client/ui-session/README.zh.md) 提供 Session 资源上的 `useSessionAccess(sessionId, action)` 与会话作用域的 `useAccess(action)`(与 `global` 资源上的 `useRemoteAccess` 不同名),[ui-workspace](../../client/ui-workspace/README.zh.md) 提供 Workspace 资源上的 `useWorkspaceAccess(workspaceId, action)`。Hook 的 `action` 类型为 `RemoteEndpoint`,即某个 Client Remote namespace 声明的端点。非 owner 的答案是该资源的 grant 是否列出该 action,不回退 `global` 答案,因此没有 grant 或 grant 未列出任何 action 的资源回答 false。owner 的所有答案为 true;没有注入 baseline 的非 owner 页面、快照流终止失败的非 owner 页面,所有答案为 false;终止失败会连同错误一并报告,非 owner 附“刷新页面重连”的提示,owner 附“答案不受影响”的说明;重连期间保留上次快照。注入的 baseline 格式错误时挂载失败。Hook 从不发请求,Host 仍检查每次调用。[`dsh-client-test-runtime`](../../test-support/client-runtime/README.zh.md) 在 Client spec 中以全允许的访问替换本行。 + + +## 模型体验 + +无:本包为 Client 调用方判别 Remote 访问,不注册任何面向模型的内容。 + +#### KV Cache 影响 + +无直接影响。 + +## 已知限制与暂缓事项 + + + +- 快照与列表过滤的判定不带调用参数,因此按 `args` 细化结论的 condition 可能拒绝快照显示为允许的调用。 +- 资源读取函数与自动解析必须覆盖方法访问的全部资源;调用未指明的资源不受检查,持有已指明资源授权的 principal 可以访问它。 +- 默认资源解析按 wire 字段名匹配;给方法新增与已登记种类同名的字段,会让调用多访问该种类的资源。 +- exact Connection Fetch route 不经过 Gateway;在各路由声明自己的访问规则之前,共享 Fetch handler 对所有非 owner Peer 返回 403。 +- 交互参与(回答转发的 waterfall)仅限 owner:非 owner 的 `$events` 流只收到 policy 在事件资源上放行其 `event:` action 的广播帧。 +- `api-session/added` 携带完整的 `SessionSummary`,因此被允许接收它的非 owner 也会得知其 `parentSessionId`。 +- 被授权 Session 的 cwd 与工具路径本就包含 owner 的绝对路径,因此 `$events` ready 帧向非 owner 提供完整的 `host`(含 `home`);Gateway 不单独隐藏 `home`。 +- policy 撤销某个 Workspace 的可见性后,`workspace/follow` 只在该 Workspace 或 Workspace 顺序下一次变化时察觉并重推非 owner 的 baseline;仅访问变化不会发送帧。 + + +### 开发备注 + +
+维护者工作上下文——点击展开 + +无。 + +
diff --git a/packages/api/remote-access/package.json b/packages/api/remote-access/package.json new file mode 100644 index 0000000000..74d0b741db --- /dev/null +++ b/packages/api/remote-access/package.json @@ -0,0 +1,83 @@ +{ + "name": "@deepseek-ai/dsh-api-remote-access", + "description": "Method-level Remote access control for non-owner Peers and the per-Peer access snapshot", + "version": "0.2.1-alpha.2", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/api/remote-access" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./types": { + "types": "./lib/types/types.d.ts", + "default": "./lib/types/types.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./typert": { + "types": "./lib/typert.host.d.ts", + "default": "./lib/typert.host.js" + }, + "./remote": { + "types": "./lib/typert.remote-client.d.ts", + "default": "./lib/typert.remote-client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dsh": { + "client": { + "external": [ + "@deepseek-ai/dsh-api-gateway/client" + ], + "inject": [ + "@deepseek-ai/dsh-api-gateway", + "@deepseek-ai/dsh-client-ui-renderer" + ], + "platform": "web" + } + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "files": [ + "lib/index.js", + "lib/client.js", + "lib/types/**/*.js", + "lib/types/**/*.d.ts", + "lib/typert.host.js", + "lib/typert.host.d.ts", + "lib/typert.remote-client.js", + "lib/typert.remote-client.d.ts" + ], + "license": "MIT", + "dependencies": { + "@deepseek-ai/dsh-typert-protocol": "workspace:*", + "zod": "4.4.3" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:~" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-api-gateway": "workspace:*", + "@deepseek-ai/dsh-client-connection": "workspace:*", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:*", + "@deepseek-ai/dsh-client-ui-slots": "workspace:*", + "@deepseek-ai/dsh-host-webserver": "workspace:*", + "@deepseek-ai/dsh-typert-registry": "workspace:*" + } +} diff --git a/packages/api/remote-access/src/client/index.ts b/packages/api/remote-access/src/client/index.ts new file mode 100644 index 0000000000..396f56a16a --- /dev/null +++ b/packages/api/remote-access/src/client/index.ts @@ -0,0 +1,33 @@ +/** + * Client face: starts from the access snapshot the index injected, follows + * the calling Peer's snapshot stream, provides the `remoteAccess` Client + * service whose `source(kind, id, action)` answers for any declared-kind + * resource, and publishes `useRemoteAccess(action)` on the global resource. + * Domain UI adapters publish their own resource Hooks over `source()`. Every + * answer is a boolean: true for the owner, false without a snapshot or after + * a non-owner stream fails; the Host enforces every call regardless. + * @module @deepseek-ai/dsh-api-remote-access/client + */ + +import type { Context } from '@deepseek-ai/cordis' +import remoteAccessRemote from '@deepseek-ai/dsh-api-remote-access/remote' +import { REMOTE_ACCESS_SNAPSHOT_KEY } from '../snapshot.ts' +import { mountAccess } from './mount.ts' +import { parseInjectedBaseline } from './state.ts' + +export type { + RemoteAccessAction, RemoteAccessEndpoint, RemoteAccessFrame, RemoteAccessGrant, RemoteAccessResource, TypertResourceKind, +} from '../types.ts' +export type { ClientRemoteAccess, RemoteEndpoint } from './mount.ts' + +/** Required Client services: the Remote mount face and the Slot registry. */ +export const inject = ['remote', 'slots'] + +/** + * Mount the generated `remoteAccess` namespace and publish the access Hooks. + * @param ctx - Client root Context. + * @returns disposer that withdraws the namespace after the stream stops. + */ +export function apply(ctx: Context): Promise<() => Promise> { + return mountAccess(ctx, remoteAccessRemote, parseInjectedBaseline(Reflect.get(globalThis, REMOTE_ACCESS_SNAPSHOT_KEY))) +} diff --git a/packages/api/remote-access/src/client/mount.ts b/packages/api/remote-access/src/client/mount.ts new file mode 100644 index 0000000000..257aa396c2 --- /dev/null +++ b/packages/api/remote-access/src/client/mount.ts @@ -0,0 +1,132 @@ +/** + * Client access mount: follows the calling Peer's access snapshot, provides + * the `remoteAccess` Client service, and publishes the global-resource `useRemoteAccess` Hook. + * The entry module supplies the generated `remoteAccess` contribution, so + * specs mount a fake one. + * @module @deepseek-ai/dsh-api-remote-access/src/client/mount + */ + +import { Service, type Context } from '@deepseek-ai/cordis' +import { + RemoteSnapshotStream, + RemoteStreamCarrierError, + type ClientRemote, +} from '@deepseek-ai/dsh-api-gateway/client' +import type {} from '@deepseek-ai/dsh-api-remote-access/remote' +import type { HostObservable, RootStandardSourceContribution } from '@deepseek-ai/dsh-client-ui-slots' +// Type-only: the SlotRegistry (`ctx.slots`) merge. +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +import type { TypertRemoteContribution, TypertRemoteMap, TypertResourceKind } from '@deepseek-ai/dsh-typert-protocol' +import type { RemoteAccessFrame } from '../types.ts' +import { ClientAccessState, type Baseline } from './state.ts' + +/** Every endpoint the Client Remote namespaces declare. */ +export type RemoteEndpoint = Extract + +/** Client access answers over the calling Peer's snapshot; it never issues a request. */ +export interface ClientRemoteAccess { + /** + * Whether the current Peer may call `action` on one resource. + * True for the owner; false without a baseline and after a non-owner + * stream fails; otherwise whether the resource's grant lists the action. + * @param kind - resource kind declared in `TypertResourceMap`. + * @param id - resource identity. + * @param action - endpoint action the caller would invoke. + * @returns identity-stable boolean source for this `(kind, id, action)`. + */ + source(kind: TypertResourceKind, id: string, action: string): HostObservable +} + +declare module '@deepseek-ai/cordis' { + interface Context { + /** Client access answers for the calling Peer. */ + remoteAccess: ClientRemoteAccess + } +} + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface GlobalStandardProps { + /** Whether the current Peer may call `action` on the global resource. */ + useRemoteAccess: (action: RemoteEndpoint) => boolean + } +} + +type IncrementFrame = Exclude + +/** The `remoteAccess` Client service over one snapshot owner. */ +class ClientRemoteAccessService extends Service implements ClientRemoteAccess { + /** + * @param ctx - Client Context the service is provided on. + * @param state - snapshot owner answering every source. + */ + constructor(ctx: Context, private readonly state: ClientAccessState) { + super(ctx, 'remoteAccess') + } + + source(kind: TypertResourceKind, id: string, action: string): HostObservable { + return this.state.resource(kind, id, action) + } +} + +/** + * Mount the `remoteAccess` namespace, follow the access snapshot, provide the + * `remoteAccess` Client service, and publish the global-resource `useRemoteAccess` Hook. + * Answers start from the page's injected baseline, or false without one, + * and follow the stream's frames from then on. + * @param ctx - Client root Context injecting `remote` and `slots`. + * @param contribution - generated `remoteAccess` Remote contribution. + * @param initial - the page's injected opening baseline, read before the stream connects. + * @returns disposer that withdraws the namespace after the stream stops. + */ +export async function mountAccess( + ctx: Context, + contribution: TypertRemoteContribution, + initial?: Baseline, +): Promise<() => Promise> { + const state = new ClientAccessState(initial) + new ClientRemoteAccessService(ctx, state) + ctx.slots.provideRoot({ + keyedHooks: { remoteAccess: action => state.global(action) }, + } satisfies RootStandardSourceContribution) + const unmount = await ctx.remote.$mount(contribution) + const follow = ctx.inject(['remote.remoteAccess'], (remoteCtx) => { + const control = createAccessStream(remoteCtx.remote, state, remoteCtx.logger) + control.start() + remoteCtx.effect(() => async () => { await control.dispose() }, 'remote-access.client.follow') + }) + return async () => { + await follow.dispose() + await unmount() + } +} + +/** + * Create the reconnecting access snapshot stream. A carrier loss keeps the + * last snapshot while the stream reconnects; a terminal failure keeps the + * owner's answers and turns a non-owner's answers false. + */ +function createAccessStream( + remote: ClientRemote, + state: ClientAccessState, + logger: Context['logger'], +): RemoteSnapshotStream { + const stream = remote.$stream({ + name: 'Remote access stream', + open: signal => remote.remoteAccess.follow(signal), + ended: accepted => accepted + ? new RemoteStreamCarrierError('Remote access stream ended without a terminal result') + : new Error('Remote access stream ended before its opening snapshot'), + }) + return new RemoteSnapshotStream(stream, { + name: 'Remote access stream', + isSnapshot: (frame): frame is Baseline => frame.type === 'baseline', + replace: (frame) => { state.replace(frame) }, + update: (frame) => { state.update(frame) }, + failed: (error) => { + // Only a non-owner's answers turn false after a terminal failure, so only its report names the recovery. + if (state.owner) logger.error('access snapshot stream failed; owner access answers are unaffected', error) + else logger.error('access snapshot stream failed; reload the page to reconnect', error) + state.unavailable() + }, + }) +} diff --git a/packages/api/remote-access/src/client/state.ts b/packages/api/remote-access/src/client/state.ts new file mode 100644 index 0000000000..b0ee02b400 --- /dev/null +++ b/packages/api/remote-access/src/client/state.ts @@ -0,0 +1,208 @@ +/** + * React-free Client access state: the latest `remoteAccess/follow` snapshot + * and identity-stable boolean sources per resource and action. + * @module @deepseek-ai/dsh-api-remote-access/src/client/state + */ + +import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots' +import type { RemoteAccessFrame, RemoteAccessGrant, RemoteAccessResource } from '../types.ts' + +/** Actions allowed per resource identity, for one declared resource kind. */ +type KindGrants = ReadonlyMap> + +type AccessSnapshot = + /** No baseline, or a non-owner snapshot stream failed: every answer is false. */ + | { readonly kind: 'closed' } + | { readonly kind: 'owner' } + | { + readonly kind: 'granted' + readonly global: ReadonlySet + readonly resources: ReadonlyMap + } + +type GrantedSnapshot = Extract + +const CLOSED: AccessSnapshot = Object.freeze({ kind: 'closed' }) +const OWNER_SNAPSHOT: AccessSnapshot = Object.freeze({ kind: 'owner' }) +const GLOBAL_KEY = '\u0000global' + +/** The opening frame of a snapshot generation. */ +export type Baseline = Extract + +/** + * Validate the opening baseline an index injection row assigned to the page. + * @param value - the injected global; `undefined` when the page carries none. + * @returns the baseline, or `undefined` when none was injected. + * @throws TypeError when the injected value is not a baseline frame. + */ +export function parseInjectedBaseline(value: unknown): Baseline | undefined { + if (value === undefined) return undefined + if (!isRecord(value) || value['type'] !== 'baseline') throw new TypeError('remote access: the injected access snapshot is not a baseline frame') + if (value['owner'] === true) return { type: 'baseline', owner: true } + const grants = value['grants'] + if (value['owner'] !== false || !Array.isArray(grants) || !grants.every(isGrant)) { + throw new TypeError('remote access: the injected access snapshot carries malformed grants') + } + return { type: 'baseline', owner: false, grants } +} + +function isGrant(value: unknown): value is RemoteAccessGrant { + if (!isRecord(value) || !isResource(value['resource'])) return false + const actions = value['actions'] + return Array.isArray(actions) && actions.every(action => typeof action === 'string') +} + +function isResource(value: unknown): value is RemoteAccessResource { + if (!isRecord(value) || typeof value['kind'] !== 'string') return false + return value['kind'] === 'global' || typeof value['id'] === 'string' +} + +function isRecord(value: unknown): value is Readonly> { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** + * Snapshot owner for one Client generation. Sources are cached per + * `(kind, id, action)` so Hook bindings keep their identity; each source + * computes its boolean from the current snapshot. + */ +export class ClientAccessState { + private snapshot: AccessSnapshot = CLOSED + + /** @param initial - the page's injected opening baseline; without one every answer is false until a baseline arrives. */ + constructor(initial?: Baseline) { + if (initial !== undefined) this.replace(initial) + } + private readonly listeners = new Set<() => void>() + private readonly sources = new Map>() + + /** + * Answer for one action on the global resource. + * @param action - endpoint action the caller may invoke. + * @returns identity-stable boolean source. + */ + global(action: string): HostObservable { + return this.cached(GLOBAL_KEY, '', action) + } + + /** + * Answer for one action on one resource of a declared kind. + * @param kind - declared resource kind. + * @param id - resource identity. + * @param action - endpoint action the caller may invoke. + * @returns identity-stable boolean source. + */ + resource(kind: string, id: string, action: string): HostObservable { + return this.cached(kind, id, action) + } + + /** + * Replace the snapshot from one generation's opening frame. + * @param frame - owner or non-owner baseline. + */ + replace(frame: Baseline): void { + if (frame.owner) { + this.publish(OWNER_SNAPSHOT) + return + } + let global: ReadonlySet = new Set() + const resources = new Map>>() + // The baseline is unpublished, so its maps are filled in place. + for (const { resource, actions } of frame.grants) { + if (resource.kind === 'global') { + global = new Set(actions) + continue + } + let grants = resources.get(resource.kind) + if (grants === undefined) { + grants = new Map() + resources.set(resource.kind, grants) + } + grants.set(resource.id, new Set(actions)) + } + this.publish({ kind: 'granted', global, resources }) + } + + /** + * Apply one increment of the current generation. + * @param frame - `upsert` or `remove`. + */ + update(frame: Exclude): void { + const current = this.snapshot + if (current.kind !== 'granted') return + switch (frame.type) { + case 'upsert': + this.publish(withGrant(current, frame.grant)) + return + case 'remove': + this.publish(withGrant(current, { resource: frame.resource, actions: [] }, true)) + return + /* v8 ignore next -- the generated Remote codec validates this closed union. */ + default: + return assertNever(frame) + } + } + + /** Whether the current snapshot is the owner's, whose answers never depend on the stream. */ + get owner(): boolean { + return this.snapshot.kind === 'owner' + } + + /** The stream failed terminally: the owner keeps every answer; a non-owner's answers become false. */ + unavailable(): void { + if (this.snapshot.kind !== 'owner') this.publish(CLOSED) + } + + private cached(kind: string, id: string, action: string): HostObservable { + const key = `${kind}\u0000${id}\u0000${action}` + let source = this.sources.get(key) + if (source === undefined) { + source = { + getSnapshot: () => answer(this.snapshot, kind, id, action), + subscribe: (listener) => { + this.listeners.add(listener) + return () => { this.listeners.delete(listener) } + }, + } + this.sources.set(key, source) + } + return source + } + + private publish(next: AccessSnapshot): void { + this.snapshot = next + for (const listener of [...this.listeners]) listener() + } +} + +function answer(snapshot: AccessSnapshot, kind: string, id: string, action: string): boolean { + switch (snapshot.kind) { + case 'closed': + return false + case 'owner': + return true + case 'granted': { + const allowed = kind === GLOBAL_KEY ? snapshot.global : snapshot.resources.get(kind)?.get(id) + return allowed?.has(action) ?? false + } + /* v8 ignore next -- closed union. */ + default: + return assertNever(snapshot) + } +} + +function withGrant(snapshot: GrantedSnapshot, grant: RemoteAccessGrant, remove = false): GrantedSnapshot { + const resource = grant.resource + if (resource.kind === 'global') return { ...snapshot, global: new Set(grant.actions) } + const resources = new Map(snapshot.resources) + const grants = new Map(resources.get(resource.kind)) + if (remove) grants.delete(resource.id) + else grants.set(resource.id, new Set(grant.actions)) + resources.set(resource.kind, grants) + return { ...snapshot, resources } +} + +/* v8 ignore next 3 -- closed-union backstop. */ +function assertNever(value: never): never { + throw new Error(`unreachable access frame: ${JSON.stringify(value)}`) +} diff --git a/packages/api/remote-access/src/index.ts b/packages/api/remote-access/src/index.ts new file mode 100644 index 0000000000..5b3cf41e0f --- /dev/null +++ b/packages/api/remote-access/src/index.ts @@ -0,0 +1,424 @@ +/** + * Host method-level access control. The Gateway asks `decide()` before it + * dispatches a non-owner call or forwards an event to a non-owner Peer; list + * methods filter items through `allows()`; every Peer reads its own access + * snapshot, first from the index and then from `follow()`. Business packages + * register one policy per non-owner principal kind. + * @module @deepseek-ai/dsh-api-remote-access + */ + +import type { Context } from '@deepseek-ai/cordis' +import type {} from '@deepseek-ai/dsh-host-webserver' +import { + Remote, + TypertRemoteService, + principalOf, + type Principal, + type PrincipalMap, + type RemoteAccessResource, +} from '@deepseek-ai/dsh-typert-protocol' +import { REMOTE_ACCESS_SNAPSHOT_KEY } from './snapshot.ts' +import type { + RemoteAccessAction, + RemoteAccessDecision, + RemoteAccessFrame, + RemoteAccessGrant, + RemoteAccessPolicy, + RemoteAccessRequest, +} from './types.ts' + +export type * from './types.ts' +export { REMOTE_ACCESS_SNAPSHOT_KEY } from './snapshot.ts' + +/** The opening frame of a snapshot generation. */ +export type RemoteAccessBaseline = Extract + +declare module '@deepseek-ai/cordis' { + interface Context { + /** Host method-level access control for non-owner Peers. */ + remoteAccess: RemoteAccessService + } +} + +/** The snapshot endpoint every Peer may read for itself. */ +const FOLLOW_ACTION: RemoteAccessAction = 'remoteAccess/follow' +const ALLOW: RemoteAccessDecision = Object.freeze({ kind: 'allow' }) +const NO_ALLOW: RemoteAccessDecision = Object.freeze({ kind: 'deny', reason: 'no-allow' }) +const POLICY_FAILED: RemoteAccessDecision = Object.freeze({ kind: 'deny', reason: 'policy-failed' }) +const OWNER: Principal = Object.freeze({ kind: 'owner' }) +const OWNER_BASELINE: RemoteAccessBaseline = Object.freeze({ type: 'baseline', owner: true }) + +/** + * A binding seen without its resource kind. Method syntax keeps the condition + * of every kind assignable; each condition is called only with a resource + * equal to its own binding's resource. + */ +interface ErasedBinding { + readonly role: { readonly actions: readonly RemoteAccessAction[] } + readonly resource: RemoteAccessResource + condition?(request: { + readonly action: RemoteAccessAction + readonly resource: RemoteAccessResource + readonly args?: Readonly> + }): RemoteAccessDecision +} + +/** One registered policy with the principal kind it serves. */ +interface PolicyEntry { + readonly kind: string + readonly policy: RemoteAccessPolicy +} + +/** A condition's refusal. */ +type Refusal = Extract + +/** The answer for one action on one resource, before any `explain()`. */ +type Evaluation = + | { readonly kind: 'allow' } + /** No binding allows; `refusal` is the first refusing condition's decision, if any condition ran. */ + | { readonly kind: 'refuse'; readonly refusal: Refusal | undefined } + | { readonly kind: 'failed' } + +const ALLOWED: Evaluation = Object.freeze({ kind: 'allow' }) +const FAILED: Evaluation = Object.freeze({ kind: 'failed' }) + +/** One open non-owner snapshot generation; changes coalesce until its reader wakes. */ +class AccessFollower { + private dirty = false + private wake: (() => void) | undefined + + /** @param principal - the generation's caller identity; never the owner. */ + constructor(readonly principal: Principal) {} + + /** Mark the generation stale and wake its reader. */ + touch(): void { + this.dirty = true + this.wake?.() + } + + /** + * Wait until the generation is stale or cancelled. + * @param signal - generation cancellation. + * @returns true when stale; false once cancelled. + */ + async changed(signal: AbortSignal): Promise { + const abort = (): void => { this.wake?.() } + signal.addEventListener('abort', abort, { once: true }) + try { + while (!this.dirty && !signal.aborted) { + await new Promise((resolve) => { this.wake = resolve }) + this.wake = undefined + } + } finally { + signal.removeEventListener('abort', abort) + } + this.dirty = false + return !signal.aborted + } +} + +/** + * Registry of access policies, the Gateway's access decisions, list + * filtering, and the per-Peer access snapshot. Every composition that mounts + * the Gateway mounts this service too. + */ +export class RemoteAccessService extends TypertRemoteService { + private readonly policies = new Map() + /** Policies whose failure was logged since the last `invalidate()`; their later failures stay silent. */ + private readonly reported = new Set() + private readonly followers = new Set() + /** Aborted when this instance unloads; every open snapshot generation ends with it. */ + private readonly lifetime = new AbortController() + + /** @param ctx - owning Host plugin Context. */ + constructor(ctx: Context) { + super(ctx, 'remoteAccess') + ctx.effect(() => () => { this.lifetime.abort() }, 'remoteAccess: snapshot generations') + // Every index the webServer renders belongs to the owner; snapshotFor documents non-owner pages. + ctx.inject(['webServer'], (webCtx) => { + webCtx.on('webserver/index-inject', (table) => { + table.push({ kind: 'global', name: REMOTE_ACCESS_SNAPSHOT_KEY, value: OWNER_BASELINE }) + }) + }) + } + + /** + * Register the policy of one non-owner principal kind in the caller's + * fiber; open snapshots are recomputed on registration and removal. + * @param kind - the principal kind the policy serves; never `owner`. + * @param policy - business-owned bindings and refusal reasons for principals of `kind`. + * @returns disposer removing this policy. + * @throws Error when `kind` already has a policy. + */ + registerPolicy>(kind: K, policy: RemoteAccessPolicy): () => void { + const policies = this.policies + if (policies.has(kind)) throw new Error(`remote access: principal kind ${JSON.stringify(kind)} already has a policy`) + const entry: PolicyEntry = { kind, policy } + const dispose = this.ctx.effect(() => { + policies.set(kind, entry) + this.invalidate() + return () => { + policies.delete(kind) + this.invalidate() + } + }, `remoteAccess.registerPolicy(${JSON.stringify(kind)})`) + return () => { void dispose() } + } + + /** + * Report that the bindings or condition answers for `principal` may have + * changed. Business packages call this on every grant, revocation, mode + * change, and expiry; open snapshots re-push, and the next failure of each + * policy is logged again. + * @param principal - affected principal; omitted for every principal. + */ + invalidate(principal?: Principal): void { + this.reported.clear() + const key = principal === undefined ? undefined : canonicalJson(principal) + for (const follower of this.followers) { + if (key === undefined || canonicalJson(follower.principal) === key) follower.touch() + } + } + + /** + * Gateway-only: decide one request whose resources the Gateway resolved. + * The owner is allowed, and so is `remoteAccess/follow`: every Peer reads + * its own snapshot. Otherwise the action is allowed only when, on every + * listed resource, some binding of the principal's policy names that + * resource, has a role listing the action, and has no condition or a + * condition that allows. `bindings()` is called once per decision. + * @param request - the caller or recipient, the action, the resources it + * touches, and a call's arguments. + * @returns `allow`; or `deny` with `no-allow` when the principal kind has + * no policy, the first refusing condition's reason on the first resource + * no binding allows, else `explain()`'s reason, else `no-allow`; + * `policy-failed` when `bindings()`, a condition, or `explain()` throws, + * logged once per policy until the next `invalidate()`. The Gateway + * throws `remote-access/denied` for a denied call and drops a denied event. + */ + decide(request: RemoteAccessRequest): RemoteAccessDecision { + const { principal, action } = request + // oxlint-disable-next-line typescript/no-unnecessary-condition -- other packages merge non-owner kinds into PrincipalMap. + if (principal.kind === 'owner' || action === FOLLOW_ACTION) return ALLOW + const entry = this.policies.get(principal.kind) + if (entry === undefined) return NO_ALLOW + const bindings = this.bindingsOf(entry, principal, action) + if (bindings === undefined) return POLICY_FAILED + for (const resource of request.resources) { + const evaluation = this.evaluate(entry, bindings, action, resource, request.args) + switch (evaluation.kind) { + case 'allow': + continue + case 'failed': + return POLICY_FAILED + case 'refuse': + return evaluation.refusal ?? this.explain(entry, request) + /* v8 ignore next -- closed union. */ + default: + return assertNever(evaluation) + } + } + return ALLOW + } + + /** + * Filter items inside a Remote method. The invocation is read from the + * Context that accessed this service, so call it as + * `this.ctx.remoteAccess.allows(resource)` from the Remote method. Each + * check reads the policy's bindings again, so a long-running stream sees + * binding changes from the next check on. + * @param resource - resource of one result item. + * @returns true for the owner and outside a Remote call; otherwise whether + * the invocation's endpoint action is allowed on `resource` without call + * arguments, false when the policy is absent or fails. + */ + allows(resource: RemoteAccessResource): boolean { + const invocation = this.ctx.invocation + const principal = principalOf(invocation) + if (invocation === undefined || principal === undefined) return true + const action: RemoteAccessAction = `${invocation.request.namespace}/${invocation.request.method}` + const entry = this.policies.get(principal.kind) + if (entry === undefined) return false + const bindings = this.bindingsOf(entry, principal, action) + return bindings !== undefined && this.evaluate(entry, bindings, action, resource, undefined).kind === 'allow' + } + + /** + * The baseline a page opens with before its snapshot stream connects. A + * business package rendering a non-owner page assigns this value to + * {@link REMOTE_ACCESS_SNAPSHOT_KEY} after every index injection row, + * replacing the owner baseline the webServer index carries. + * @param principal - the page's caller identity. + * @returns the owner baseline for the owner; otherwise the principal's current grants. + */ + snapshotFor(principal: Principal): RemoteAccessBaseline { + // oxlint-disable-next-line typescript/no-unnecessary-condition -- other packages merge non-owner kinds into PrincipalMap. + if (principal.kind === 'owner') return OWNER_BASELINE + return { type: 'baseline', owner: false, grants: this.grantsFor(principal) } + } + + /** + * Stream the calling Peer's access snapshot: one baseline, then `upsert` + * and `remove` frames after each `invalidate()` and each policy + * registration or removal that changes a grant. The owner receives only + * `{ owner: true }`. The generation ends when `signal` aborts or this + * service unloads, so a reloaded service starts every reader from a fresh + * baseline. + * @param signal - generation cancellation. + * @returns the access frames of one generation. + */ + @Remote({ mode: 'stream' }) + async *follow(signal: AbortSignal): AsyncIterable { + signal.throwIfAborted() + const generation = AbortSignal.any([signal, this.lifetime.signal]) + const principal = principalOf(this.ctx.invocation) + if (principal === undefined) { + yield this.snapshotFor(OWNER) + await abortion(generation) + return + } + const follower = new AccessFollower(principal) + this.followers.add(follower) + try { + let sent = this.grantsFor(principal) + yield { type: 'baseline', owner: false, grants: sent } + while (await follower.changed(generation)) { + const next = this.grantsFor(principal) + const previous = sent + sent = next + for (const grant of next) { + const before = previous.find(other => sameResource(other.resource, grant.resource)) + if (before === undefined || !sameActions(before.actions, grant.actions)) yield { type: 'upsert', grant } + } + for (const grant of previous) { + if (!next.some(other => sameResource(other.resource, grant.resource))) yield { type: 'remove', resource: grant.resource } + } + } + } finally { + this.followers.delete(follower) + } + } + + /** One `bindings()` call; `undefined` after a logged failure. */ + private bindingsOf(entry: PolicyEntry, principal: Principal, purpose: string): readonly ErasedBinding[] | undefined { + try { + return entry.policy.bindings(principal) + } catch (error) { + // A policy failure is contained to this question, so one faulty rule cannot break a shared event stream. + this.report(entry, `list bindings for ${purpose}`, error) + return undefined + } + } + + /** Find a binding on `resource` whose role lists `action` and whose condition, if any, allows. */ + private evaluate( + entry: PolicyEntry, + bindings: readonly ErasedBinding[], + action: RemoteAccessAction, + resource: RemoteAccessResource, + args: Readonly> | undefined, + ): Evaluation { + let refusal: Refusal | undefined + for (const binding of bindings) { + if (!sameResource(binding.resource, resource) || !binding.role.actions.includes(action)) continue + if (binding.condition === undefined) return ALLOWED + let decision: RemoteAccessDecision + try { + decision = binding.condition(args === undefined ? { action, resource } : { action, resource, args }) + } catch (error) { + this.report(entry, `evaluate a condition for ${action}`, error) + return FAILED + } + if (decision.kind === 'allow') return ALLOWED + refusal ??= decision + } + return { kind: 'refuse', refusal } + } + + /** The refusal reason `explain()` names, else `no-allow`; `policy-failed` when it throws. */ + private explain(entry: PolicyEntry, request: RemoteAccessRequest): RemoteAccessDecision { + let reason: string | undefined + try { + reason = entry.policy.explain?.(request) + } catch (error) { + this.report(entry, `explain a refusal of ${request.action}`, error) + return POLICY_FAILED + } + return reason === undefined ? NO_ALLOW : { kind: 'deny', reason } + } + + /** + * One grant per bound resource, in first-binding order: the endpoint + * actions of the resource's bindings that `decide()` would allow there + * without call arguments. A resource with no such action gets no grant; + * event actions stay out of the snapshot. + */ + private grantsFor(principal: Principal): readonly RemoteAccessGrant[] { + const entry = this.policies.get(principal.kind) + if (entry === undefined) return [] + const bindings = this.bindingsOf(entry, principal, 'a snapshot') + if (bindings === undefined) return [] + const grants: RemoteAccessGrant[] = [] + const seen: RemoteAccessResource[] = [] + for (const { resource } of bindings) { + if (seen.some(other => sameResource(other, resource))) continue + seen.push(resource) + const candidates = new Set() + for (const binding of bindings) { + if (!sameResource(binding.resource, resource)) continue + for (const action of binding.role.actions) { + if (!action.startsWith('event:')) candidates.add(action) + } + } + const actions = [...candidates].filter(action => this.evaluate(entry, bindings, action, resource, undefined).kind === 'allow').sort() + if (actions.length > 0) grants.push(Object.freeze({ resource, actions: Object.freeze(actions) })) + } + return grants + } + + /** Log a policy failure once per policy until the next `invalidate()`; snapshots and lists ask many times per change. */ + private report(entry: PolicyEntry, action: string, error: unknown): void { + if (this.reported.has(entry)) return + this.reported.add(entry) + this.ctx.logger.warn( + `remote access: the ${JSON.stringify(entry.kind)} policy failed to ${action}; its further failures are not logged until access state changes`) + this.ctx.logger.warn(error) + } +} + +/* v8 ignore next 3 -- closed-union backstop. */ +function assertNever(value: never): never { + throw new Error(`remote access: unreachable value ${JSON.stringify(value)}`) +} + +/** Key-order-independent JSON text identifying one principal. */ +function canonicalJson(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]` + if (typeof value === 'object' && value !== null) { + // Own keys are unique, so no two entries compare equal. + const entries = Object.entries(value).sort(([left], [right]) => (left < right ? -1 : 1)) + return `{${entries.map(([key, item]) => `${JSON.stringify(key)}:${canonicalJson(item)}`).join(',')}}` + } + return JSON.stringify(value) +} + +/** Resources are equal when their kinds and ids are; `global` carries no id. */ +function sameResource( + left: { readonly kind: string; readonly id?: unknown }, + right: { readonly kind: string; readonly id?: unknown }, +): boolean { + return left.kind === right.kind && left.id === right.id +} + +function sameActions(left: readonly string[], right: readonly string[]): boolean { + return left.length === right.length && left.every((action, index) => action === right[index]) +} + +function abortion(signal: AbortSignal): Promise { + return new Promise((resolve) => { + if (signal.aborted) resolve() + else signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) +} + +export default RemoteAccessService diff --git a/packages/api/remote-access/src/snapshot.ts b/packages/api/remote-access/src/snapshot.ts new file mode 100644 index 0000000000..ad3a490205 --- /dev/null +++ b/packages/api/remote-access/src/snapshot.ts @@ -0,0 +1,8 @@ +/** + * The index global carrying a page's first access snapshot, shared by the + * Host row that writes it and the Client mount that reads it. + * @module @deepseek-ai/dsh-api-remote-access/src/snapshot + */ + +/** `globalThis` property an index injection row assigns to the page's opening baseline. */ +export const REMOTE_ACCESS_SNAPSHOT_KEY = '__DSH_REMOTE_ACCESS__' diff --git a/packages/api/remote-access/src/types.ts b/packages/api/remote-access/src/types.ts new file mode 100644 index 0000000000..fe38b77eef --- /dev/null +++ b/packages/api/remote-access/src/types.ts @@ -0,0 +1,137 @@ +/** + * Method-level access control contracts: actions, roles, bindings, policies, + * decision requests, and the access snapshot stream. + * @module @deepseek-ai/dsh-api-remote-access/types + */ + +import type { + Principal, + RemoteAccessResource, + RemoteAccessResourceKind, + RemoteAccessResourceOf, +} from '@deepseek-ai/dsh-typert-protocol' + +export type { + RemoteAccessResource, + RemoteAccessResourceKind, + RemoteAccessResourceOf, + TypertResourceKind, + TypertResourceMap, +} from '@deepseek-ai/dsh-typert-protocol' + +/** Canonical `/` Remote endpoint; its segments never contain `:`. */ +export type RemoteAccessEndpoint = `${string}/${string}` + +/** A Remote endpoint, or `event:` for one forwarded event. */ +export type RemoteAccessAction = RemoteAccessEndpoint | `event:${string}` + +/** A named action set over one resource kind; a plain value the business package owns. */ +export interface RemoteAccessRole { + /** Name for diagnostics; decisions read only `resource` and `actions`. */ + readonly name: string + /** The resource kind every binding of this role names. */ + readonly resource: K + /** Actions the role allows on its binding's resource. */ + readonly actions: readonly RemoteAccessAction[] +} + +/** Answer of `decide()` and of a condition. */ +export type RemoteAccessDecision = + | { readonly kind: 'allow' } + | { readonly kind: 'deny'; readonly reason: string } + +/** + * Refines one binding for one action on its resource. Called synchronously, + * only for a binding whose resource matches and whose role lists the action. + * @param request - the action, the binding's resource, and the named wire + * arguments of a concrete call; `args` is absent for events, snapshots, and + * list filtering, where the condition answers for the action alone. + * @returns allow, or deny with the reason a refused call reports. + */ +export type RemoteAccessCondition = (request: { + readonly action: RemoteAccessAction + readonly resource: RemoteAccessResourceOf + readonly args?: Readonly> +}) => RemoteAccessDecision + +/** One role on one resource of the role's kind; the resource type follows from the role. */ +export type RemoteAccessBinding = { + [K in RemoteAccessResourceKind]: { + readonly role: RemoteAccessRole + readonly resource: RemoteAccessResourceOf + /** Without a condition, the binding allows every action its role lists. */ + readonly condition?: RemoteAccessCondition + } +}[RemoteAccessResourceKind] + +/** One access question: may `principal` take `action` on `resources`. */ +export interface RemoteAccessRequest

{ + readonly principal: P + readonly action: RemoteAccessAction + /** + * Every resource the action touches, the global resource alone when it + * names none; allowed only when the action is allowed on every listed resource. + */ + readonly resources: readonly RemoteAccessResource[] + /** Named wire arguments of a concrete call, undecoded; absent for events, snapshots, and list filtering. */ + readonly args?: Readonly> +} + +/** + * Business rules for one non-owner principal kind. Every member answers + * synchronously from state the business package holds in memory. + */ +export interface RemoteAccessPolicy

{ + /** + * List the principal's bindings. Called once per `decide()`, once per + * `allows()`, and once per snapshot computation; never cached. + * @param principal - non-owner caller of this policy's kind. + * @returns every binding the principal holds now. + */ + bindings(principal: P): readonly RemoteAccessBinding[] + /** + * Name the reason for a request no binding allows; called only after the + * decision is already a deny, so it never changes the decision. + * @param request - the refused request. + * @returns the reason a refused call reports, or `undefined` for `no-allow`. + */ + explain?(request: RemoteAccessRequest

): string | undefined +} + +/** Details of a refused non-owner call. */ +export interface RemoteAccessDeniedDetails { + /** Canonical `/` endpoint of the refused call. */ + readonly action: string + /** + * A condition's or `explain()`'s reason, `no-allow`, or `policy-failed`; + * the Gateway refuses a call whose resources are invalid with + * `resource-invalid` before `decide()` is asked. + */ + readonly reason: string +} + +declare module '@deepseek-ai/dsh-typert-protocol' { + interface RemoteErrorDetailsMap { + 'remote-access/denied': RemoteAccessDeniedDetails + } +} + +/** The endpoint actions one principal may call on one resource. */ +export interface RemoteAccessGrant { + readonly resource: RemoteAccessResource + /** + * Endpoint actions allowed on this resource without call arguments, sorted; + * plain strings because Typert generates no codec for template literal types. + */ + readonly actions: readonly string[] +} + +/** + * One frame of `remoteAccess/follow`. A generation opens with one baseline; + * an owner baseline is the only frame of its generation. + */ +export type RemoteAccessFrame = + | { readonly type: 'baseline'; readonly owner: true } + | { readonly type: 'baseline'; readonly owner: false; readonly grants: readonly RemoteAccessGrant[] } + | { readonly type: 'upsert'; readonly grant: RemoteAccessGrant } + | { readonly type: 'remove'; readonly resource: RemoteAccessResource } diff --git a/packages/api/remote-access/tests/client-access.client.spec.ts b/packages/api/remote-access/tests/client-access.client.spec.ts new file mode 100644 index 0000000000..b73944ec58 --- /dev/null +++ b/packages/api/remote-access/tests/client-access.client.spec.ts @@ -0,0 +1,291 @@ +import { describe, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import type { HostObservable, RootStandardSourceContribution } from '@deepseek-ai/dsh-client-ui-slots' +import type { TypertRemoteContribution, TypertResource } from '@deepseek-ai/dsh-typert-protocol' +import type { RemoteAccessFrame, RemoteAccessGrant } from '../src/types.ts' +import { ClientAccessState, parseInjectedBaseline, type Baseline } from '../src/client/state.ts' +import { mountAccess } from '../src/client/mount.ts' + +declare module '@deepseek-ai/dsh-typert-protocol' { + interface TypertResourceMap { + /** Test-only resource kinds; the Client face itself names no kind. */ + 'test-shelf': TypertResource + 'test-desk': TypertResource + } +} + +const GRANTED: Extract = { + type: 'baseline', + owner: false, + grants: [ + { resource: { kind: 'global' }, actions: ['session/list'] }, + { resource: { kind: 'test-shelf', id: 's1' }, actions: ['session/fork', 'session/prompt'] }, + { resource: { kind: 'test-desk', id: 'd1' }, actions: ['workspace/follow'] }, + ], +} + +describe('ClientAccessState', () => { + it('answers false without a baseline and after a non-owner stream fails, and true for the owner', () => { + const state = new ClientAccessState() + expect(state.global('session/create').getSnapshot()).toBe(false) + expect(state.resource('test-shelf', 's9', 'session/fork').getSnapshot()).toBe(false) + state.replace({ type: 'baseline', owner: true }) + expect(state.resource('test-desk', 'd9', 'workspace/follow').getSnapshot()).toBe(true) + state.unavailable() + expect(state.resource('test-desk', 'd9', 'workspace/follow').getSnapshot()).toBe(true) + state.replace(GRANTED) + expect(state.global('session/list').getSnapshot()).toBe(true) + state.unavailable() + expect(state.global('session/list').getSnapshot()).toBe(false) + }) + + it('starts from the injected baseline before any stream frame', () => { + expect(new ClientAccessState({ type: 'baseline', owner: true }).global('session/create').getSnapshot()).toBe(true) + const granted = new ClientAccessState(GRANTED) + expect(granted.global('session/list').getSnapshot()).toBe(true) + expect(granted.resource('test-shelf', 's1', 'session/fork').getSnapshot()).toBe(true) + expect(granted.global('session/create').getSnapshot()).toBe(false) + }) + + it('validates the injected baseline and refuses a malformed one', () => { + expect(parseInjectedBaseline(undefined)).toBeUndefined() + expect(parseInjectedBaseline({ type: 'baseline', owner: true, extra: 1 })).toEqual({ type: 'baseline', owner: true }) + expect(parseInjectedBaseline(JSON.parse(JSON.stringify(GRANTED)))).toEqual(GRANTED) + for (const malformed of [ + null, 'baseline', [], { type: 'upsert' }, + { type: 'baseline' }, { type: 'baseline', owner: false }, { type: 'baseline', owner: false, grants: [7] }, + { type: 'baseline', owner: false, grants: [{ resource: { kind: 'test-shelf' }, actions: [] }] }, + { type: 'baseline', owner: false, grants: [{ resource: { kind: 7 }, actions: [] }] }, + { type: 'baseline', owner: false, grants: [{ resource: { kind: 'global' }, actions: 'session/list' }] }, + { type: 'baseline', owner: false, grants: [{ resource: { kind: 'global' }, actions: [7] }] }, + { type: 'baseline', owner: false, grants: [{ resource: { kind: 'global' } }] }, + { type: 'baseline', owner: false, grants: [{ scope: { kind: 'page' }, endpoints: ['session/list'] }] }, + ]) { + expect(() => parseInjectedBaseline(malformed)).toThrow(TypeError) + } + }) + + it('answers each resource from its own grant without falling back to the global resource', () => { + const state = new ClientAccessState() + state.replace(GRANTED) + expect(state.global('session/list').getSnapshot()).toBe(true) + expect(state.global('session/fork').getSnapshot()).toBe(false) + expect(state.resource('test-shelf', 's1', 'session/fork').getSnapshot()).toBe(true) + expect(state.resource('test-shelf', 's1', 'session/list').getSnapshot()).toBe(false) + expect(state.resource('test-shelf', 's2', 'session/fork').getSnapshot()).toBe(false) + expect(state.resource('test-desk', 'd1', 'workspace/follow').getSnapshot()).toBe(true) + expect(state.resource('test-desk', 'd2', 'workspace/follow').getSnapshot()).toBe(false) + }) + + it('applies upserts and removals and notifies subscribers until they unsubscribe', () => { + const state = new ClientAccessState() + state.replace(GRANTED) + const fork = state.resource('test-shelf', 's1', 'session/fork') + const listener = vi.fn() + const unsubscribe = fork.subscribe(listener) + state.update({ type: 'upsert', grant: { resource: { kind: 'test-shelf', id: 's1' }, actions: ['session/prompt'] } }) + expect(fork.getSnapshot()).toBe(false) + expect(listener).toHaveBeenCalledOnce() + state.update({ type: 'upsert', grant: { resource: { kind: 'global' }, actions: ['session/create'] } }) + expect(state.global('session/create').getSnapshot()).toBe(true) + state.update({ type: 'upsert', grant: { resource: { kind: 'test-desk', id: 'd2' }, actions: ['workspace/follow'] } }) + expect(state.resource('test-desk', 'd2', 'workspace/follow').getSnapshot()).toBe(true) + state.update({ type: 'remove', resource: { kind: 'test-desk', id: 'd2' } }) + expect(state.resource('test-desk', 'd2', 'workspace/follow').getSnapshot()).toBe(false) + state.update({ type: 'remove', resource: { kind: 'test-shelf', id: 's1' } }) + expect(state.resource('test-shelf', 's1', 'session/prompt').getSnapshot()).toBe(false) + // Removing a kind that never had a grant leaves the other kinds intact. + state.update({ type: 'remove', resource: { kind: 'test-shelf', id: 'absent' } }) + unsubscribe() + state.update({ type: 'remove', resource: { kind: 'global' } }) + expect(listener).toHaveBeenCalledTimes(6) + expect(state.global('session/create').getSnapshot()).toBe(false) + }) + + it('applies a baseline of many grants of one kind in one pass and notifies once', () => { + const state = new ClientAccessState() + const first = state.resource('test-shelf', 's0', 'session/fork') + const listener = vi.fn() + first.subscribe(listener) + const grants: RemoteAccessGrant[] = Array.from({ length: 20_000 }, (_, index) => ({ + resource: { kind: 'test-shelf', id: `s${index}` }, + actions: ['session/fork'], + })) + // Copying the kind's grants once per grant takes seconds at this size and exceeds the spec timeout. + state.replace({ type: 'baseline', owner: false, grants }) + expect(listener).toHaveBeenCalledOnce() + expect(first.getSnapshot()).toBe(true) + expect(state.resource('test-shelf', 's19999', 'session/fork').getSnapshot()).toBe(true) + expect(state.resource('test-shelf', 's20000', 'session/fork').getSnapshot()).toBe(false) + }) + + it('ignores increments outside a non-owner generation and keeps source identity stable', () => { + const state = new ClientAccessState() + state.update({ type: 'upsert', grant: { resource: { kind: 'global' }, actions: ['session/list'] } }) + expect(state.global('session/list').getSnapshot()).toBe(false) + state.replace({ type: 'baseline', owner: true }) + state.update({ type: 'remove', resource: { kind: 'global' } }) + expect(state.global('session/list').getSnapshot()).toBe(true) + expect(state.global('session/list')).toBe(state.global('session/list')) + expect(state.resource('test-shelf', 's1', 'session/fork')).toBe(state.resource('test-shelf', 's1', 'session/fork')) + expect(state.resource('test-shelf', 's1', 'session/fork')).not.toBe(state.resource('test-desk', 's1', 'session/fork')) + }) +}) + +interface FakeStreamOptions { + readonly name: string + readonly open: (signal: AbortSignal) => unknown + readonly ended: (accepted: boolean) => Error +} + +/** One controllable generation of `remoteAccess/follow`, standing in for the Gateway-owned carrier. */ +class FakeAccessStream { + private readonly frames: RemoteAccessFrame[] = [] + private wake: (() => void) | undefined + private done = false + private failure: Error | undefined + + constructor(readonly options: FakeStreamOptions) {} + + push(frame: RemoteAccessFrame): void { + this.frames.push(frame) + this.wake?.() + } + + fail(error: Error): void { + this.failure = error + this.wake?.() + } + + restart(): void {} + + async dispose(): Promise { + this.done = true + this.wake?.() + } + + async *[Symbol.asyncIterator](): AsyncIterator<{ + readonly generation: number + readonly value: RemoteAccessFrame + readonly accept: () => void + }> { + while (!this.done) { + if (this.failure !== undefined) throw this.failure + const frame = this.frames.shift() + if (frame !== undefined) { + yield { generation: 1, value: frame, accept: () => {} } + continue + } + await new Promise((resolve) => { this.wake = resolve }) + } + } +} + +function value(source: HostObservable | undefined): unknown { + if (source === undefined) throw new Error('missing source') + return source.getSnapshot() +} + +async function mounted(initial?: Baseline): Promise<{ + readonly ctx: Context + readonly stream: () => FakeAccessStream + readonly global: (action: string) => HostObservable | undefined + readonly follow: ReturnType + readonly mount: ReturnType + readonly unmount: ReturnType + readonly contribution: TypertRemoteContribution + readonly dispose: () => Promise +}> { + const ctx = new Context() + let stream: FakeAccessStream | undefined + const follow = vi.fn() + const unmount = vi.fn(async () => {}) + const remote = { + $mount: vi.fn(async () => unmount), + $stream: (options: FakeStreamOptions) => { + stream = new FakeAccessStream(options) + return stream + }, + remoteAccess: { follow }, + } + let root: RootStandardSourceContribution | undefined + ctx.provide('remote', remote as never) + ctx.provide('slots', { + provideRoot: (contribution: RootStandardSourceContribution) => { + root = contribution + return () => {} + }, + } as never) + const contribution: TypertRemoteContribution = { package: '@deepseek-ai/dsh-api-remote-access', descriptors: [] } + let dispose: (() => Promise) | undefined + await ctx.plugin({ + inject: ['remote', 'slots'], + apply: async (pluginCtx: Context) => { dispose = await mountAccess(pluginCtx, contribution, initial) }, + }) + // The generated namespace arrives after the mount, as `$mount` provides it. + ctx.provide('remote.remoteAccess', remote.remoteAccess as never) + await vi.waitFor(() => { expect(stream).toBeDefined() }) + return { + ctx, + stream: () => stream as FakeAccessStream, + global: action => root?.keyedHooks?.['remoteAccess']?.(action), + follow, + mount: remote.$mount, + unmount, + contribution, + dispose: async () => { await dispose?.() }, + } +} + +describe('remote-access Client mount', () => { + it('provides the global-resource Hook and the remoteAccess service over the followed snapshot', async () => { + const access = await mounted() + expect(access.mount).toHaveBeenCalledWith(access.contribution) + const globalFork = access.global('session/fork') + const shelfFork = access.ctx.remoteAccess.source('test-shelf', 's1', 'session/fork') + const deskFollow = access.ctx.remoteAccess.source('test-desk', 'd1', 'workspace/follow') + expect(access.ctx.remoteAccess.source('test-shelf', 's1', 'session/fork')).toBe(shelfFork) + expect([value(globalFork), value(shelfFork), value(deskFollow)]).toEqual([false, false, false]) + + access.stream().push(GRANTED) + await vi.waitFor(() => { expect(value(shelfFork)).toBe(true) }) + expect(value(globalFork)).toBe(false) + expect(value(deskFollow)).toBe(true) + access.stream().push({ type: 'remove', resource: { kind: 'test-shelf', id: 's1' } }) + await vi.waitFor(() => { expect(value(shelfFork)).toBe(false) }) + // Reading an answer never issues a request; only the stream factory reaches the namespace. + expect(access.follow).not.toHaveBeenCalled() + access.stream().options.open(new AbortController().signal) + expect(access.follow).toHaveBeenCalledOnce() + expect(access.stream().options.ended(true).message).toMatch(/without a terminal result/) + expect(access.stream().options.ended(false).message).toMatch(/before its opening snapshot/) + + await access.dispose() + expect(access.unmount).toHaveBeenCalledOnce() + }) + + it('answers from the injected baseline at once and false once a non-owner stream fails terminally', async () => { + const access = await mounted(GRANTED) + const report = vi.spyOn(access.ctx.logger, 'error').mockImplementation(() => undefined) + const globalList = access.global('session/list') + expect(value(globalList)).toBe(true) + const failure = new Error('carrier closed for good') + access.stream().fail(failure) + await vi.waitFor(() => { expect(value(globalList)).toBe(false) }) + expect(report).toHaveBeenCalledWith('access snapshot stream failed; reload the page to reconnect', failure) + await access.dispose() + }) + + it('keeps the owner\'s answers when its stream fails terminally', async () => { + const access = await mounted({ type: 'baseline', owner: true }) + const report = vi.spyOn(access.ctx.logger, 'error').mockImplementation(() => undefined) + const globalFork = access.global('session/fork') + expect(value(globalFork)).toBe(true) + const failure = new Error('carrier closed for good') + access.stream().fail(failure) + await vi.waitFor(() => { expect(report).toHaveBeenCalledOnce() }) + expect(report).toHaveBeenCalledWith('access snapshot stream failed; owner access answers are unaffected', failure) + expect(value(globalFork)).toBe(true) + await access.dispose() + }) +}) diff --git a/packages/api/remote-access/tests/remote-access.host.spec.ts b/packages/api/remote-access/tests/remote-access.host.spec.ts new file mode 100644 index 0000000000..f29d4d1744 --- /dev/null +++ b/packages/api/remote-access/tests/remote-access.host.spec.ts @@ -0,0 +1,610 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import TypertGatewayService from '@deepseek-ai/dsh-api-gateway' +import { Peer } from '@deepseek-ai/dsh-client-connection' +import type { IndexInjection } from '@deepseek-ai/dsh-host-webserver' +import { + Remote, + TypertRemoteService, + type PeerScope, + type Principal, + type PrincipalMap, + type RemoteInvocation, + type TypertResource, +} from '@deepseek-ai/dsh-typert-protocol' +import TypertRegistry from '@deepseek-ai/dsh-typert-registry' +import RemoteAccessService, { + REMOTE_ACCESS_SNAPSHOT_KEY, + type RemoteAccessBinding, + type RemoteAccessCondition, + type RemoteAccessFrame, + type RemoteAccessGrant, + type RemoteAccessPolicy, + type RemoteAccessRequest, + type RemoteAccessResource, + type RemoteAccessResourceOf, + type RemoteAccessRole, +} from '../src/index.ts' + +declare module '@deepseek-ai/dsh-typert-protocol' { + interface PrincipalMap { + /** Test-only non-owner caller shared by the access specs. */ + 'test-guest': { readonly kind: 'test-guest'; readonly guest: string } + /** Test-only non-owner caller whose identity carries a list. */ + 'test-crew': { readonly kind: 'test-crew'; readonly members: readonly string[] } + } + interface TypertResourceMap { + /** Test-only resource kinds; the service itself names no kind. */ + 'test-shelf': TypertResource + 'test-desk': TypertResource + } +} + +type Guest = PrincipalMap['test-guest'] + +const OWNER: Principal = { kind: 'owner' } +const GUEST: Guest = { kind: 'test-guest', guest: 'a' } +const OTHER: Guest = { kind: 'test-guest', guest: 'b' } +const CREW: Principal = { kind: 'test-crew', members: ['a', 'b'] } +const GLOBAL: RemoteAccessResourceOf<'global'> = { kind: 'global' } +const ROOM: RemoteAccessResourceOf<'test-shelf'> = { kind: 'test-shelf', id: 'r1' } +const ROOM2: RemoteAccessResourceOf<'test-shelf'> = { kind: 'test-shelf', id: 'r2' } +const DESK: RemoteAccessResourceOf<'test-desk'> = { kind: 'test-desk', id: 'd1' } + +const READER: RemoteAccessRole<'global'> = { + name: 'test:reader', + resource: 'global', + actions: ['notes/read', 'notes/rooms', 'notes/write', 'event:notes/changed'], +} +const SHELF_READER: RemoteAccessRole<'test-shelf'> = { + name: 'test:shelf-reader', + resource: 'test-shelf', + actions: ['notes/rooms', 'notes/read', 'event:notes/changed'], +} +const SHELF_WRITER: RemoteAccessRole<'test-shelf'> = { name: 'test:shelf-writer', resource: 'test-shelf', actions: ['notes/write'] } +const DESK_WRITER: RemoteAccessRole<'test-desk'> = { name: 'test:desk-writer', resource: 'test-desk', actions: ['notes/write'] } + +class Notes extends TypertRemoteService { + static inject = ['remoteAccess'] + + constructor(ctx: Context) { super(ctx, 'notes') } + + @Remote + read(): string { return 'read' } + + /** Resolves to the shelf its request names through the registered default field. */ + @Remote + write(request: { readonly shelfId: string; readonly text: string }): string { return request.text } + + @Remote + hidden(): string { return 'hidden' } + + /** A list method: a global call, then one `allows()` check per item. */ + @Remote + rooms(): string[] { + return ['r1', 'r2'].filter(id => this.ctx.remoteAccess.allows({ kind: 'test-shelf', id })) + } +} + +/** A policy whose bindings live in memory per guest, the way a business package preloads them. */ +class GuestPolicy implements RemoteAccessPolicy { + readonly held = new Map() + readonly bindings = vi.fn((guest: Guest): readonly RemoteAccessBinding[] => this.held.get(guest.guest) ?? []) + + hold(guest: Guest, ...bindings: RemoteAccessBinding[]): void { + this.held.set(guest.guest, bindings) + } +} + +const roots: Context[] = [] + +afterEach(async () => { + await Promise.all(roots.splice(0).map(ctx => ctx.fiber.dispose())) +}) + +async function setup(): Promise<{ readonly ctx: Context; readonly access: RemoteAccessService }> { + const ctx = new Context() + roots.push(ctx) + await ctx.plugin(TypertRegistry) + await ctx.plugin(RemoteAccessService) + await ctx.plugin(TypertGatewayService) + ctx.typert.resources.register('test-shelf', { field: 'shelfId' }) + await ctx.plugin(Notes) + return { ctx, access: ctx.remoteAccess } +} + +function call(ctx: Context, principal: Principal, method: string, args: Record = {}): Promise { + return ctx.typertGateway.invoke({ peer: new Peer(ctx, { principal }), namespace: 'notes', method, args }) +} + +/** A request by `principal` for `action` on `resources`. */ +function ask(principal: Principal, action: RemoteAccessRequest['action'], ...resources: RemoteAccessResource[]): RemoteAccessRequest { + return { principal, action, resources } +} + +const NEVER = new AbortController().signal + +/** The invocation a Gateway binds while dispatching `remoteAccess/follow` for one principal. */ +function invocationOf(ctx: Context, principal: Principal, method = 'follow', namespace = 'remoteAccess'): RemoteInvocation { + return { + request: { namespace, method, args: {} }, + service: namespace, + peer: new Peer(ctx, { principal }), + signal: NEVER, + uplink: (): AsyncIterable => ({ [Symbol.asyncIterator]: () => ({ next: async () => ({ done: true, value: undefined }) }) }), + } +} + +async function follow(ctx: Context, principal: Principal): Promise<{ + readonly frames: RemoteAccessFrame[] + readonly next: () => Promise + readonly stop: () => Promise +}> { + const abort = new AbortController() + const peer: PeerScope = new Peer(ctx, { principal }) + const stream = await ctx.typertGateway.stream({ peer, namespace: 'remoteAccess', method: 'follow', args: {}, signal: abort.signal }) + const iterator = stream[Symbol.asyncIterator]() + const frames: RemoteAccessFrame[] = [] + return { + frames, + next: async () => { + const result = await iterator.next() + if (result.done === true) throw new Error('access stream ended') + const frame = result.value as RemoteAccessFrame + frames.push(frame) + return frame + }, + stop: async () => { + abort.abort() + await iterator.return?.() + }, + } +} + +interface SpiedListenerMethod { + readonly mock: { readonly calls: readonly unknown[][]; readonly contexts: readonly unknown[] } +} + +/** `abort` registrations a spied `EventTarget` method received on AbortSignals. */ +function abortListenerCalls(spy: SpiedListenerMethod): number { + return spy.mock.calls.filter((args, index) => args[0] === 'abort' && spy.mock.contexts[index] instanceof AbortSignal).length +} + +describe('RemoteAccessService registrations', () => { + it('removes a policy with its disposer and with the plugin fiber that registered it', async () => { + const { ctx, access } = await setup() + const policy = new GuestPolicy() + policy.hold(GUEST, { role: READER, resource: GLOBAL }) + const dispose = access.registerPolicy('test-guest', policy) + await expect(call(ctx, GUEST, 'read')).resolves.toBe('read') + dispose() + await expect(call(ctx, GUEST, 'read')).rejects.toMatchObject({ code: 'remote-access/denied', details: { reason: 'no-allow' } }) + const fiber = ctx.plugin({ + inject: ['remoteAccess'], + apply: (pluginCtx: Context) => { pluginCtx.remoteAccess.registerPolicy('test-guest', policy) }, + }) + await fiber + await expect(call(ctx, GUEST, 'read')).resolves.toBe('read') + await fiber.dispose() + await expect(call(ctx, GUEST, 'read')).rejects.toMatchObject({ code: 'remote-access/denied', details: { reason: 'no-allow' } }) + }) + + it('accepts one policy per principal kind', async () => { + const { access } = await setup() + access.registerPolicy('test-guest', new GuestPolicy()) + expect(() => access.registerPolicy('test-guest', new GuestPolicy())) + .toThrow('remote access: principal kind "test-guest" already has a policy') + access.registerPolicy('test-crew', { bindings: () => [{ role: READER, resource: GLOBAL }] }) + expect(access.decide(ask(CREW, 'notes/read', GLOBAL))).toEqual({ kind: 'allow' }) + }) +}) + +describe('RemoteAccessService.decide', () => { + it('allows the owner without any policy and refuses a kind without a policy', async () => { + const { ctx, access } = await setup() + expect(access.decide(ask(OWNER, 'notes/hidden', ROOM))).toEqual({ kind: 'allow' }) + await expect(call(ctx, OWNER, 'hidden')).resolves.toBe('hidden') + await expect(call(ctx, GUEST, 'read')).rejects.toMatchObject({ + code: 'remote-access/denied', details: { action: 'notes/read', reason: 'no-allow' }, + }) + }) + + it('allows only actions a bound role lists on the resource the binding names', async () => { + const { ctx, access } = await setup() + const policy = new GuestPolicy() + access.registerPolicy('test-guest', policy) + policy.hold(GUEST, { role: READER, resource: GLOBAL }, { role: SHELF_WRITER, resource: ROOM }) + await expect(call(ctx, GUEST, 'read')).resolves.toBe('read') + await expect(call(ctx, GUEST, 'hidden')).rejects.toMatchObject({ details: { action: 'notes/hidden', reason: 'no-allow' } }) + await expect(call(ctx, GUEST, 'write', { request: { shelfId: 'r1', text: 'hi' } })).resolves.toBe('hi') + await expect(call(ctx, GUEST, 'write', { request: { shelfId: 'r2', text: 'hi' } })).rejects.toMatchObject({ details: { reason: 'no-allow' } }) + await expect(call(ctx, OTHER, 'read')).rejects.toMatchObject({ details: { reason: 'no-allow' } }) + // Resources are independent: a global grant implies nothing on a shelf, and a shelf grant nothing on global. + expect(access.decide(ask(GUEST, 'notes/read', ROOM))).toEqual({ kind: 'deny', reason: 'no-allow' }) + expect(access.decide(ask(GUEST, 'notes/write', GLOBAL))).toEqual({ kind: 'allow' }) + policy.hold(GUEST, { role: SHELF_WRITER, resource: ROOM }) + expect(access.decide(ask(GUEST, 'notes/write', GLOBAL))).toEqual({ kind: 'deny', reason: 'no-allow' }) + }) + + it('requires the action on every resource of the target', async () => { + const { access } = await setup() + const policy = new GuestPolicy() + access.registerPolicy('test-guest', policy) + const both = ask(GUEST, 'notes/write', ROOM, DESK) + policy.hold(GUEST, { role: SHELF_WRITER, resource: ROOM }) + expect(access.decide(both)).toEqual({ kind: 'deny', reason: 'no-allow' }) + policy.hold(GUEST, { role: SHELF_WRITER, resource: ROOM }, { role: DESK_WRITER, resource: DESK }) + expect(access.decide(both)).toEqual({ kind: 'allow' }) + expect(policy.bindings).toHaveBeenCalledTimes(2) + }) + + it('asks a matching binding\'s condition, which can refuse with its own reason', async () => { + const { ctx, access } = await setup() + const policy = new GuestPolicy() + access.registerPolicy('test-guest', policy) + const paused = vi.fn>(() => ({ kind: 'deny', reason: 'shelf-paused' })) + const locked = vi.fn>(() => ({ kind: 'deny', reason: 'shelf-locked' })) + const short = vi.fn>(({ args }) => { + const request: unknown = args?.['request'] + return typeof request === 'object' && request !== null && Reflect.get(request, 'text') === 'ok' + ? { kind: 'allow' } + : { kind: 'deny', reason: 'text-refused' } + }) + const unrelated = vi.fn>(() => ({ kind: 'allow' })) + policy.hold(GUEST, + { role: SHELF_READER, resource: ROOM, condition: unrelated }, + { role: SHELF_WRITER, resource: ROOM2, condition: unrelated }, + { role: SHELF_WRITER, resource: ROOM, condition: paused }, + { role: SHELF_WRITER, resource: ROOM, condition: locked }) + await expect(call(ctx, GUEST, 'write', { request: { shelfId: 'r1', text: 'x' } })).rejects.toMatchObject({ + code: 'remote-access/denied', details: { action: 'notes/write', reason: 'shelf-paused' }, + }) + expect(paused).toHaveBeenCalledWith({ action: 'notes/write', resource: ROOM, args: { request: { shelfId: 'r1', text: 'x' } } }) + expect(locked).toHaveBeenCalledOnce() + // A condition runs only for a binding whose resource matches and whose role lists the action. + expect(unrelated).not.toHaveBeenCalled() + + policy.hold(GUEST, { role: SHELF_WRITER, resource: ROOM, condition: short }) + await expect(call(ctx, GUEST, 'write', { request: { shelfId: 'r1', text: 'x' } })).rejects.toMatchObject({ details: { reason: 'text-refused' } }) + await expect(call(ctx, GUEST, 'write', { request: { shelfId: 'r1', text: 'ok' } })).resolves.toBe('ok') + + // Any binding without a condition, or with an allowing one, allows. + policy.hold(GUEST, { role: SHELF_WRITER, resource: ROOM, condition: paused }, { role: SHELF_WRITER, resource: ROOM }) + expect(access.decide(ask(GUEST, 'notes/write', ROOM))).toEqual({ kind: 'allow' }) + expect(paused).toHaveBeenLastCalledWith({ action: 'notes/write', resource: ROOM }) + }) + + it('names the reason explain() gives once no binding allows, without changing the decision', async () => { + const { access } = await setup() + const policy = new GuestPolicy() + const explain = vi.fn((request: { readonly action: string }) => (request.action === 'notes/write' ? 'share-not-writable' : undefined)) + access.registerPolicy('test-guest', { bindings: policy.bindings, explain }) + policy.hold(GUEST, { role: READER, resource: GLOBAL }) + expect(access.decide(ask(GUEST, 'notes/read', GLOBAL))).toEqual({ kind: 'allow' }) + expect(explain).not.toHaveBeenCalled() + const write = { ...ask(GUEST, 'notes/write', ROOM, DESK), args: { request: {} } } + expect(access.decide(write)).toEqual({ kind: 'deny', reason: 'share-not-writable' }) + expect(explain).toHaveBeenCalledWith({ principal: GUEST, action: 'notes/write', resources: [ROOM, DESK], args: { request: {} } }) + expect(access.decide(ask(GUEST, 'notes/hidden', GLOBAL))).toEqual({ kind: 'deny', reason: 'no-allow' }) + expect(explain).toHaveBeenLastCalledWith({ principal: GUEST, action: 'notes/hidden', resources: [GLOBAL] }) + // A refusing condition's reason comes first. + const paused: RemoteAccessCondition<'test-shelf'> = () => ({ kind: 'deny', reason: 'shelf-paused' }) + policy.hold(GUEST, { role: SHELF_WRITER, resource: ROOM, condition: paused }) + expect(access.decide(ask(GUEST, 'notes/write', ROOM))).toEqual({ kind: 'deny', reason: 'shelf-paused' }) + expect(explain).toHaveBeenCalledTimes(2) + }) + + it('refuses with policy-failed when bindings(), a condition, or explain() throws, logging each policy once per change', async () => { + const { ctx, access } = await setup() + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) + const failures = (): unknown[] => warn.mock.calls.flatMap(([value]) => (value instanceof Error ? [value.message] : [])) + let mode: 'bindings' | 'condition' | 'explain' = 'bindings' + access.registerPolicy('test-guest', { + bindings: () => { + if (mode === 'bindings') throw new Error('binding store unavailable') + return [{ role: SHELF_WRITER, resource: ROOM, condition: () => { throw new Error('condition failed') } }] + }, + explain: () => { throw new Error('explain failed') }, + }) + access.registerPolicy('test-crew', { bindings: () => { throw new Error('crew store unavailable') } }) + for (let round = 0; round < 3; round++) { + expect(access.decide(ask(GUEST, 'notes/write', ROOM))).toEqual({ kind: 'deny', reason: 'policy-failed' }) + expect(access.decide(ask(CREW, 'notes/write', ROOM))).toEqual({ kind: 'deny', reason: 'policy-failed' }) + } + await expect(call(ctx, GUEST, 'write', { request: { shelfId: 'r1', text: 'x' } })).rejects.toMatchObject({ + code: 'remote-access/denied', details: { action: 'notes/write', reason: 'policy-failed' }, + }) + expect(failures()).toEqual(['binding store unavailable', 'crew store unavailable']) + expect(warn).toHaveBeenCalledWith( + 'remote access: the "test-guest" policy failed to list bindings for notes/write; its further failures are not logged until access state changes') + + mode = 'condition' + access.invalidate(OTHER) + expect(access.decide(ask(GUEST, 'notes/write', ROOM))).toEqual({ kind: 'deny', reason: 'policy-failed' }) + expect(warn).toHaveBeenCalledWith( + 'remote access: the "test-guest" policy failed to evaluate a condition for notes/write; its further failures are not logged until access state changes') + + mode = 'explain' + access.invalidate() + expect(access.decide(ask(GUEST, 'notes/read', ROOM))).toEqual({ kind: 'deny', reason: 'policy-failed' }) + expect(warn).toHaveBeenCalledWith( + 'remote access: the "test-guest" policy failed to explain a refusal of notes/read; its further failures are not logged until access state changes') + expect(failures()).toEqual(['binding store unavailable', 'crew store unavailable', 'condition failed', 'explain failed']) + }) + + it('always lets a Peer open its own access snapshot', async () => { + const { ctx, access } = await setup() + access.registerPolicy('test-guest', { bindings: () => [] }) + const guest = await follow(ctx, GUEST) + await expect(guest.next()).resolves.toEqual({ type: 'baseline', owner: false, grants: [] }) + await guest.stop() + }) +}) + +describe('RemoteAccessService.allows', () => { + it('filters list items for non-owners on one resource without arguments', async () => { + const { ctx, access } = await setup() + const policy = new GuestPolicy() + access.registerPolicy('test-guest', policy) + const reader = vi.fn>(() => ({ kind: 'allow' })) + policy.hold(GUEST, { role: READER, resource: GLOBAL }, { role: SHELF_READER, resource: ROOM, condition: reader }) + await expect(call(ctx, GUEST, 'rooms')).resolves.toEqual(['r1']) + expect(reader).toHaveBeenCalledWith({ action: 'notes/rooms', resource: ROOM }) + // One call for the Gateway's decision and one per item check. + expect(policy.bindings).toHaveBeenCalledTimes(3) + policy.bindings.mockClear() + await expect(call(ctx, OWNER, 'rooms')).resolves.toEqual(['r1', 'r2']) + expect(access.allows(ROOM)).toBe(true) + expect(policy.bindings).not.toHaveBeenCalled() + }) + + it('reads the bindings on every check, so a running call sees binding changes from its next check on', async () => { + const { ctx, access } = await setup() + const policy = new GuestPolicy() + access.registerPolicy('test-guest', policy) + policy.hold(GUEST, { role: SHELF_READER, resource: ROOM }) + // One long-running call, such as a follow stream that filters every frame. + const stream = ctx.extend({ invocation: invocationOf(ctx, GUEST, 'rooms', 'notes') }).remoteAccess + expect(stream.allows(ROOM)).toBe(true) + expect(stream.allows(ROOM2)).toBe(false) + policy.hold(GUEST, { role: SHELF_READER, resource: ROOM2 }) + expect(stream.allows(ROOM)).toBe(false) + expect(stream.allows(ROOM2)).toBe(true) + }) + + it('allows nothing when the principal kind has no policy or the policy fails', async () => { + const { ctx, access } = await setup() + vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) + const crewCall = ctx.extend({ invocation: invocationOf(ctx, CREW, 'rooms', 'notes') }) + expect(crewCall.remoteAccess.allows(ROOM)).toBe(false) + const bindings = vi.fn((): readonly RemoteAccessBinding[] => { throw new Error('binding store unavailable') }) + access.registerPolicy('test-guest', { bindings }) + const guestCall = ctx.extend({ invocation: invocationOf(ctx, GUEST, 'rooms', 'notes') }) + expect(guestCall.remoteAccess.allows(ROOM)).toBe(false) + expect(guestCall.remoteAccess.allows(ROOM2)).toBe(false) + expect(bindings).toHaveBeenCalledTimes(2) + }) +}) + +describe('first snapshots', () => { + it('gives the owner the owner baseline and a non-owner its current grants', async () => { + const { access } = await setup() + const policy = new GuestPolicy() + policy.hold(GUEST, { role: READER, resource: GLOBAL }) + access.registerPolicy('test-guest', policy) + expect(access.snapshotFor(OWNER)).toEqual({ type: 'baseline', owner: true }) + expect(access.snapshotFor(GUEST)).toEqual({ + type: 'baseline', owner: false, grants: [{ resource: GLOBAL, actions: ['notes/read', 'notes/rooms', 'notes/write'] }], + }) + expect(access.snapshotFor(CREW)).toEqual({ type: 'baseline', owner: false, grants: [] }) + expect(REMOTE_ACCESS_SNAPSHOT_KEY).toBe('__DSH_REMOTE_ACCESS__') + }) + + it('injects the owner baseline into every index the webServer renders', async () => { + const { ctx } = await setup() + const render = (): IndexInjection[] => { + const table: IndexInjection[] = [] + ctx.emit('webserver/index-inject', table) + return table + } + expect(render()).toEqual([]) + ctx.provide('webServer', {} as Context['webServer']) + await vi.waitFor(() => { + expect(render()).toEqual([{ kind: 'global', name: '__DSH_REMOTE_ACCESS__', value: { type: 'baseline', owner: true } }]) + }) + }) + + it('lists per bound resource the endpoint actions decide() allows there without arguments, and no grant where none is allowed', async () => { + const { ctx, access } = await setup() + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) + const policy = new GuestPolicy() + access.registerPolicy('test-guest', policy) + const writeOnly: RemoteAccessCondition<'test-shelf'> = ({ action }) => + action === 'notes/write' ? { kind: 'allow' } : { kind: 'deny', reason: 'read-paused' } + policy.hold(GUEST, + { role: SHELF_READER, resource: ROOM, condition: writeOnly }, + { role: SHELF_WRITER, resource: ROOM }, + { role: DESK_WRITER, resource: DESK, condition: () => { throw new Error('condition failed') } }, + { role: READER, resource: GLOBAL }) + const grants: RemoteAccessGrant[] = [ + { resource: ROOM, actions: ['notes/write'] }, + { resource: GLOBAL, actions: ['notes/read', 'notes/rooms', 'notes/write'] }, + ] + expect(access.snapshotFor(GUEST)).toEqual({ type: 'baseline', owner: false, grants }) + for (const resource of [ROOM, GLOBAL]) { + for (const action of ['notes/read', 'notes/rooms', 'notes/write'] as const) { + const listed = grants.find(grant => grant.resource === resource)?.actions.includes(action) === true + expect(access.decide(ask(GUEST, action, resource)).kind).toBe(listed ? 'allow' : 'deny') + } + } + // Event actions are decided per notification and never enter the snapshot. + expect(access.decide(ask(GUEST, 'event:notes/changed', GLOBAL))).toEqual({ kind: 'allow' }) + expect(warn).toHaveBeenCalledWith(expect.objectContaining({ message: 'condition failed' })) + }) + + it('gives a principal whose bindings() throws no grant', async () => { + const { ctx, access } = await setup() + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) + access.registerPolicy('test-guest', { bindings: () => { throw new Error('binding store unavailable') } }) + expect(access.snapshotFor(GUEST)).toEqual({ type: 'baseline', owner: false, grants: [] }) + const guest = await follow(ctx, GUEST) + await expect(guest.next()).resolves.toEqual({ type: 'baseline', owner: false, grants: [] }) + expect(warn).toHaveBeenCalledWith( + 'remote access: the "test-guest" policy failed to list bindings for a snapshot; its further failures are not logged until access state changes') + await guest.stop() + }) +}) + +describe('remoteAccess/follow', () => { + it('sends the owner one owner baseline and nothing else', async () => { + const { ctx } = await setup() + const owner = await follow(ctx, OWNER) + await expect(owner.next()).resolves.toEqual({ type: 'baseline', owner: true }) + await owner.stop() + expect(owner.frames).toHaveLength(1) + + // Outside a Remote call the caller is trusted in-process code, answered as the owner. + for (const abortWhileWaiting of [false, true]) { + const abort = new AbortController() + const direct = ctx.remoteAccess.follow(abort.signal)[Symbol.asyncIterator]() + await expect(direct.next()).resolves.toEqual({ done: false, value: { type: 'baseline', owner: true } }) + if (!abortWhileWaiting) abort.abort() + const end = direct.next() + if (abortWhileWaiting) abort.abort() + await expect(end).resolves.toEqual({ done: true, value: undefined }) + } + }) + + it('pushes upserts and removals after invalidation and policy changes', async () => { + const { ctx, access } = await setup() + const policy = new GuestPolicy() + access.registerPolicy('test-guest', policy) + const guest = await follow(ctx, GUEST) + const other = await follow(ctx, OTHER) + const crew = await follow(ctx, CREW) + await expect(guest.next()).resolves.toEqual({ type: 'baseline', owner: false, grants: [] }) + await other.next() + await expect(crew.next()).resolves.toEqual({ type: 'baseline', owner: false, grants: [] }) + + policy.hold(GUEST, { role: SHELF_WRITER, resource: ROOM }) + access.invalidate(GUEST) + await expect(guest.next()).resolves.toEqual({ type: 'upsert', grant: { resource: ROOM, actions: ['notes/write'] } }) + + policy.hold(GUEST, { role: SHELF_WRITER, resource: ROOM }, { role: SHELF_READER, resource: ROOM }) + access.invalidate() + await expect(guest.next()).resolves.toEqual({ + type: 'upsert', grant: { resource: ROOM, actions: ['notes/read', 'notes/rooms', 'notes/write'] }, + }) + + policy.hold(GUEST, { role: READER, resource: GLOBAL }) + access.invalidate(GUEST) + await expect(guest.next()).resolves.toEqual({ + type: 'upsert', grant: { resource: GLOBAL, actions: ['notes/read', 'notes/rooms', 'notes/write'] }, + }) + await expect(guest.next()).resolves.toEqual({ type: 'remove', resource: ROOM }) + + // An unchanged grant is not pushed again. + policy.hold(GUEST, { role: READER, resource: GLOBAL }, { role: SHELF_WRITER, resource: ROOM2 }) + access.invalidate(GUEST) + await expect(guest.next()).resolves.toEqual({ type: 'upsert', grant: { resource: ROOM2, actions: ['notes/write'] } }) + policy.hold(GUEST, + { role: { ...READER, actions: ['notes/hidden', 'notes/read', 'notes/rooms'] }, resource: GLOBAL }, + { role: SHELF_WRITER, resource: ROOM2 }) + access.invalidate(GUEST) + await expect(guest.next()).resolves.toEqual({ + type: 'upsert', grant: { resource: GLOBAL, actions: ['notes/hidden', 'notes/read', 'notes/rooms'] }, + }) + // A resource on which no action stays allowed loses its grant. + const paused: RemoteAccessCondition<'test-shelf'> = () => ({ kind: 'deny', reason: 'shelf-paused' }) + policy.hold(GUEST, + { role: { ...READER, actions: ['notes/hidden', 'notes/read', 'notes/rooms'] }, resource: GLOBAL }, + { role: SHELF_WRITER, resource: ROOM2, condition: paused }) + access.invalidate(GUEST) + await expect(guest.next()).resolves.toEqual({ type: 'remove', resource: ROOM2 }) + + const disposeCrew = access.registerPolicy('test-crew', { bindings: () => [{ role: SHELF_WRITER, resource: ROOM2 }] }) + await expect(crew.next()).resolves.toEqual({ type: 'upsert', grant: { resource: ROOM2, actions: ['notes/write'] } }) + access.invalidate({ kind: 'test-crew', members: ['a', 'b'] }) + disposeCrew() + await expect(crew.next()).resolves.toEqual({ type: 'remove', resource: ROOM2 }) + + await guest.stop() + await crew.stop() + // Every recomputation for the other principal produced the same grants, so it received no increment. + expect(other.frames).toHaveLength(1) + await other.stop() + }) + + it('keeps no abort listener from a wait that a change ended', async () => { + const { ctx, access } = await setup() + const policy = new GuestPolicy() + access.registerPolicy('test-guest', policy) + const abort = new AbortController() + const stream = ctx.extend({ invocation: invocationOf(ctx, GUEST) }).remoteAccess.follow(abort.signal)[Symbol.asyncIterator]() + await stream.next() + const add = vi.spyOn(EventTarget.prototype, 'addEventListener') + const remove = vi.spyOn(EventTarget.prototype, 'removeEventListener') + try { + for (let round = 0; round < 30; round++) { + // The reader is already waiting when the change arrives. + const pending = stream.next() + policy.hold(GUEST, { role: round % 2 === 0 ? READER : { ...READER, actions: ['notes/read'] }, resource: GLOBAL }) + access.invalidate(GUEST) + await expect(pending).resolves.toMatchObject({ done: false, value: { type: 'upsert' } }) + } + expect(abortListenerCalls(add)).toBeGreaterThan(0) + expect(abortListenerCalls(add) - abortListenerCalls(remove)).toBe(0) + } finally { + add.mockRestore() + remove.mockRestore() + } + abort.abort() + await stream.return?.() + }) + + it('ends every open generation when the service unloads, so a reloaded service starts from a fresh baseline', async () => { + const ctx = new Context() + roots.push(ctx) + await ctx.plugin(TypertRegistry) + const service = ctx.plugin(RemoteAccessService) + await service + await ctx.plugin(TypertGatewayService) + await ctx.plugin(Notes) + const policy = new GuestPolicy() + policy.hold(GUEST, { role: READER, resource: GLOBAL }) + ctx.remoteAccess.registerPolicy('test-guest', policy) + const open = async (principal: Principal): Promise> => + (await ctx.typertGateway.stream({ + peer: new Peer(ctx, { principal }), namespace: 'remoteAccess', method: 'follow', args: {}, + }))[Symbol.asyncIterator]() + const guest = await open(GUEST) + const owner = await open(OWNER) + await expect(guest.next()).resolves.toMatchObject({ + value: { type: 'baseline', grants: [{ resource: GLOBAL, actions: ['notes/read', 'notes/rooms', 'notes/write'] }] }, + }) + await expect(owner.next()).resolves.toMatchObject({ value: { type: 'baseline', owner: true } }) + const guestEnd = guest.next() + await service.dispose() + await expect(guestEnd).resolves.toEqual({ done: true, value: undefined }) + await expect(owner.next()).resolves.toEqual({ done: true, value: undefined }) + + await ctx.plugin(RemoteAccessService) + await vi.waitFor(() => { expect(ctx.get('typertGateway')).toBeDefined() }) + const reopened = await follow(ctx, GUEST) + await expect(reopened.next()).resolves.toEqual({ type: 'baseline', owner: false, grants: [] }) + await reopened.stop() + }) + + it('stops a non-owner generation that is cancelled while waiting', async () => { + const { ctx, access } = await setup() + access.registerPolicy('test-guest', new GuestPolicy()) + const abort = new AbortController() + const stream = ctx.extend({ invocation: invocationOf(ctx, GUEST) }).remoteAccess.follow(abort.signal)[Symbol.asyncIterator]() + await expect(stream.next()).resolves.toMatchObject({ value: { type: 'baseline', owner: false } }) + const pending = stream.next() + abort.abort() + await expect(pending).resolves.toEqual({ done: true, value: undefined }) + // A cancelled generation no longer receives invalidations. + access.invalidate() + }) +}) diff --git a/packages/api/remote-access/tsconfig.client.json b/packages/api/remote-access/tsconfig.client.json new file mode 100644 index 0000000000..0ddeaeac30 --- /dev/null +++ b/packages/api/remote-access/tsconfig.client.json @@ -0,0 +1,22 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types", + "tsBuildInfoFile": "lib/tsconfig.client.tsbuildinfo" + }, + "files": [ + "src/client/index.ts", + "src/client/mount.ts", + "src/client/state.ts", + "src/snapshot.ts", + "src/types.ts" + ], + "references": [ + { "path": "../../../vendor/cordis" }, + { "path": "../gateway/tsconfig.client.json" }, + { "path": "../../client/ui-renderer" }, + { "path": "../../client/ui-slots" }, + { "path": "../../typert/protocol" } + ] +} diff --git a/packages/api/remote-access/tsconfig.host.json b/packages/api/remote-access/tsconfig.host.json new file mode 100644 index 0000000000..0d9e8a79c8 --- /dev/null +++ b/packages/api/remote-access/tsconfig.host.json @@ -0,0 +1,18 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types", + "tsBuildInfoFile": "lib/tsconfig.host.tsbuildinfo" + }, + "files": [ + "src/index.ts", + "src/snapshot.ts", + "src/types.ts" + ], + "references": [ + { "path": "../../../vendor/cordis" }, + { "path": "../../host/webserver" }, + { "path": "../../typert/protocol" } + ] +} diff --git a/packages/api/remote-access/tsconfig.json b/packages/api/remote-access/tsconfig.json new file mode 100644 index 0000000000..2a0b0e33f7 --- /dev/null +++ b/packages/api/remote-access/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.host.json" }, + { "path": "./tsconfig.client.json" } + ] +} diff --git a/packages/api/remote-access/tsdown.config.ts b/packages/api/remote-access/tsdown.config.ts new file mode 100644 index 0000000000..0e42a1163e --- /dev/null +++ b/packages/api/remote-access/tsdown.config.ts @@ -0,0 +1,7 @@ +import { clientBundle } from '../../client/tsdown.client.ts' + +export default clientBundle( + import.meta.dirname, + ['lib/types/index.js'], + { hostPhase: true }, +) diff --git a/packages/api/remotes/tests/built-lib.e2e.ts b/packages/api/remotes/tests/built-lib.e2e.ts index 991c1ac46e..5b8200c657 100644 --- a/packages/api/remotes/tests/built-lib.e2e.ts +++ b/packages/api/remotes/tests/built-lib.e2e.ts @@ -24,6 +24,7 @@ const requiredArtifacts = [ 'packages/goal/goal/lib/typert.host.js', 'packages/api/gateway/lib/client.js', 'packages/api/gateway/lib/index.js', + 'packages/api/remote-access/lib/index.js', 'packages/typert/registry/lib/client.js', 'packages/typert/registry/lib/index.js', 'packages/session/session-projection/lib/index.js', @@ -39,6 +40,7 @@ describe.skipIf(!requiredArtifacts)('Goal Remote built LIB chain', () => { connectionHost: 'packages/client/connection/lib/index.js', goal: 'packages/goal/goal/lib/index.js', goalTypert: 'packages/goal/goal/lib/typert.host.js', + remoteAccess: 'packages/api/remote-access/lib/index.js', registryClient: 'packages/typert/registry/lib/client.js', registryHost: 'packages/typert/registry/lib/index.js', remotesClient: 'packages/api/remotes/lib/client.js', @@ -55,6 +57,7 @@ describe.skipIf(!requiredArtifacts)('Goal Remote built LIB chain', () => { const { default: AgentRegistry } = await import(urls.agent) const connectionHost = await import(urls.connectionHost) const { default: TypertRemoteService } = await import(urls.apiGatewayHost) + const { default: RemoteAccessService } = await import(urls.remoteAccess) const { default: GoalService } = await import(urls.goal) const { default: SessionProjectionRegistry } = await import(urls.sessionProjections) const { TYPERT } = await import(urls.goalTypert) @@ -84,6 +87,7 @@ describe.skipIf(!requiredArtifacts)('Goal Remote built LIB chain', () => { await host.plugin({ inject: connectionHost.inject, apply: connectionHost.apply }) await host.plugin(TypertRegistry) await host.plugin(AgentRegistry) + await host.plugin(RemoteAccessService) await host.plugin(TypertRemoteService) await host.plugin(SessionProjectionRegistry) await host.plugin(GoalService) diff --git a/packages/api/workspace-files/package.json b/packages/api/workspace-files/package.json index f77a965ad0..5aebc36735 100644 --- a/packages/api/workspace-files/package.json +++ b/packages/api/workspace-files/package.json @@ -64,6 +64,7 @@ "devDependencies": { "@deepseek-ai/cordis": "workspace:~", "@deepseek-ai/dsh-api-gateway": "workspace:*", + "@deepseek-ai/dsh-api-remote-access": "workspace:*", "@deepseek-ai/dsh-client-connection": "workspace:*", "@deepseek-ai/dsh-client-resources": "workspace:*", "@deepseek-ai/dsh-client-ui-slots": "workspace:*", diff --git a/packages/api/workspace-files/tests/binary-rpc.spec.ts b/packages/api/workspace-files/tests/binary-rpc.spec.ts index 35058d58a1..dc42bfbe02 100644 --- a/packages/api/workspace-files/tests/binary-rpc.spec.ts +++ b/packages/api/workspace-files/tests/binary-rpc.spec.ts @@ -5,6 +5,7 @@ import { beforeEach, afterEach, expect, it, vi } from 'vitest' import SessionStore from '@deepseek-ai/dsh-session' import TypertRegistry from '@deepseek-ai/dsh-typert-registry' import TypertGateway from '@deepseek-ai/dsh-api-gateway' +import RemoteAccessService from '@deepseek-ai/dsh-api-remote-access' import { HostConnectionService } from '@deepseek-ai/dsh-client-connection/src/rpc-host.ts' import type { BrowserAuth } from '@deepseek-ai/dsh-client-connection/src/browser-auth.ts' import { createWebConnectionRpc } from '@deepseek-ai/dsh-client-connection/src/client/rpc.ts' @@ -19,12 +20,14 @@ beforeEach(async () => { const ctx = harness.ctx await ctx.plugin(SessionStore) await ctx.plugin(TypertRegistry) + await ctx.plugin(RemoteAccessService) await ctx.plugin(TypertGateway) await ctx.plugin((scope) => { new HostConnectionService(scope, [], {} as BrowserAuth) }) await ctx.plugin(WorkspaceFiles, { maxBytes: 8, maxFileBytes: 4, maxLines: 100, maxEntries: 100 }) ctx.sessions.create(harness.scope.sessionId, { meta: { cwd: harness.workspace, origin: 'subagent' } }) - const handler = (ctx.get('connection') as HostConnectionService).createSharedFetchHandler('/api') - rpc = createWebConnectionRpc(async (path, init) => handler.fetch(new Request(new URL(path, 'http://host'), init))) + const connection = ctx.get('connection') as HostConnectionService + const handler = connection.createSharedFetchHandler('/api') + rpc = createWebConnectionRpc(async (path, init) => handler.fetch(new Request(new URL(path, 'http://host'), init), connection.browserPeer)) }) afterEach(async () => { diff --git a/packages/boot/plugin-manager/src/index.ts b/packages/boot/plugin-manager/src/index.ts index ebfb72d6e2..6734d2872b 100644 --- a/packages/boot/plugin-manager/src/index.ts +++ b/packages/boot/plugin-manager/src/index.ts @@ -71,7 +71,7 @@ const protectedModules = new Set([ '@deepseek-ai/dsh-host-webserver', '@deepseek-ai/dsh-client-modules', '@deepseek-ai/dsh-client-ui-settings-plugin-inventory', '@deepseek-ai/dsh-client-ui-plugin-manager', '@deepseek-ai/dsh-host-plugin-inventory', '@deepseek-ai/dsh-typert-registry', - '@deepseek-ai/dsh-api-remotes', + '@deepseek-ai/dsh-api-remotes', '@deepseek-ai/dsh-api-remote-access', '@deepseek-ai/cordis-plugin-timer', '@deepseek-ai/dsh-client-connection', '@deepseek-ai/dsh-host-frontend-static', '@deepseek-ai/dsh-tools', '@deepseek-ai/dsh-hmr', diff --git a/packages/boot/plugin-manager/tests/manager.spec.ts b/packages/boot/plugin-manager/tests/manager.spec.ts index 0600956068..844d376b26 100644 --- a/packages/boot/plugin-manager/tests/manager.spec.ts +++ b/packages/boot/plugin-manager/tests/manager.spec.ts @@ -735,6 +735,7 @@ it.each([ '@deepseek-ai/dsh-host-plugin-inventory', '@deepseek-ai/dsh-typert-registry', '@deepseek-ai/dsh-api-remotes', + '@deepseek-ai/dsh-api-remote-access', ])('protects the management dependency %s and its containing bundle', async (name) => { const { ctx, manager, bundle, profile, dir } = await fixture('startup') bundle('extra', [{ id: 'dependency', name, disabled: true }]) diff --git a/packages/bundle/base/cordis.patch.yml b/packages/bundle/base/cordis.patch.yml index 21e24f3865..ee2f8f280b 100644 --- a/packages/bundle/base/cordis.patch.yml +++ b/packages/bundle/base/cordis.patch.yml @@ -52,6 +52,11 @@ - id: typert-gateway name: '@deepseek-ai/dsh-api-gateway' + # Method-level access control for non-owner Peers; the Gateway requires it, + # and its Client half publishes the standard access Hooks. + - id: remote-access + name: '@deepseek-ai/dsh-api-remote-access' + - id: session-title name: '@deepseek-ai/dsh-session-title' config: diff --git a/packages/bundle/base/package.json b/packages/bundle/base/package.json index fa80fcfb55..8ec635fde1 100644 --- a/packages/bundle/base/package.json +++ b/packages/bundle/base/package.json @@ -58,6 +58,7 @@ "@deepseek-ai/dsh-goal": "workspace:*", "@deepseek-ai/dsh-goal-round-driver": "workspace:*", "@deepseek-ai/dsh-api-gateway": "workspace:*", + "@deepseek-ai/dsh-api-remote-access": "workspace:*", "@deepseek-ai/dsh-llm": "workspace:*", "@deepseek-ai/dsh-llm-deepseek-api-key": "workspace:*", "@deepseek-ai/dsh-llm-deepseek-account": "workspace:*", diff --git a/packages/experimental/client-ui-cot-translation/package.json b/packages/experimental/client-ui-cot-translation/package.json index 22125d92bd..d302906ff2 100644 --- a/packages/experimental/client-ui-cot-translation/package.json +++ b/packages/experimental/client-ui-cot-translation/package.json @@ -86,6 +86,7 @@ "react-dom": "^18.2.0", "@testing-library/react": "^16.3.3", "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/dsh-api-remote-access": "workspace:*", "@deepseek-ai/dsh-api-session-controller": "workspace:*", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:*", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:*", diff --git a/packages/experimental/client-ui-cot-translation/tests/activation.host.spec.ts b/packages/experimental/client-ui-cot-translation/tests/activation.host.spec.ts index 715e149dd8..87b2c75895 100644 --- a/packages/experimental/client-ui-cot-translation/tests/activation.host.spec.ts +++ b/packages/experimental/client-ui-cot-translation/tests/activation.host.spec.ts @@ -6,6 +6,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { Context } from '@deepseek-ai/cordis' import { mountAgentLoopTestDependencies, mountAgentLoopTestHarness } from '@deepseek-ai/dsh-agent-loop-testkit' +import RemoteAccessService from '@deepseek-ai/dsh-api-remote-access' import SessionController from '@deepseek-ai/dsh-api-session-controller' import Translator from '@deepseek-ai/dsh-experimental-translator' import { SESSION_FORMAT_VERSION, SessionId, SessionSeq } from '@deepseek-ai/dsh-session' @@ -66,6 +67,7 @@ async function fixture(origin?: 'subagent') { saveSelection: async () => {}, } as never) ctx.provide('fileUploads', { registerAgentResolver: () => () => {} } as never) + await ctx.plugin(RemoteAccessService) const sessions = new SessionController(ctx, { nativeOpen: false }) await ctx.plugin(Translator, Translator.Config({ bingEndpoint: `http://127.0.0.1:${address.port}/bing` })) const ui = ctx.plugin(CotTranslationController, {}) diff --git a/packages/extensions/cordis-host-runner/package.json b/packages/extensions/cordis-host-runner/package.json index 986c902a48..98bdaccdea 100644 --- a/packages/extensions/cordis-host-runner/package.json +++ b/packages/extensions/cordis-host-runner/package.json @@ -65,6 +65,7 @@ "@deepseek-ai/cordis-plugin-timer": "workspace:~", "@deepseek-ai/dsh-agent": "workspace:*", "@deepseek-ai/dsh-api-gateway": "workspace:*", + "@deepseek-ai/dsh-api-remote-access": "workspace:*", "@deepseek-ai/dsh-brand": "workspace:*", "@deepseek-ai/dsh-llm": "workspace:*", "@deepseek-ai/dsh-scope": "workspace:*", diff --git a/packages/extensions/cordis-host-runner/tests/inspect-registry.spec.ts b/packages/extensions/cordis-host-runner/tests/inspect-registry.spec.ts index 321bc4cf13..2228fe462c 100644 --- a/packages/extensions/cordis-host-runner/tests/inspect-registry.spec.ts +++ b/packages/extensions/cordis-host-runner/tests/inspect-registry.spec.ts @@ -1,6 +1,8 @@ import { Context } from '@deepseek-ai/cordis' import type { Fiber } from '@deepseek-ai/cordis' import TypertGateway from '@deepseek-ai/dsh-api-gateway' +import RemoteAccessService from '@deepseek-ai/dsh-api-remote-access' +import type { PeerId, PeerScope } from '@deepseek-ai/dsh-typert-protocol' import TypertRegistry from '@deepseek-ai/dsh-typert-registry' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import DynamicCordisRunnerService from '../src/index.ts' @@ -77,12 +79,14 @@ function answer(query: ReturnType) { async function connectGatewayClient(): Promise { await ctx.plugin(TypertRegistry) + await ctx.plugin(RemoteAccessService) await ctx.plugin(TypertGateway) ctx.effect(() => ctx.typertGateway.registerRemoteEvents(async function* (signal) { await new Promise((resolve) => { signal.addEventListener('abort', () => { resolve() }, { once: true }) }) }, { home: '/fixture' })) const connection = new AbortController() - const stream = await ctx.typertGateway.wireStream.open('$events', { args: {} }, (async function* () {})(), undefined, connection.signal) + const peer: PeerScope = { id: 'inspect-registry-owner' as PeerId, ctx, principal: { kind: 'owner' }, signal: connection.signal, dispose: () => Promise.resolve() } + const stream = await ctx.typertGateway.wireStream.open('$events', { args: {} }, (async function* () {})(), peer, connection.signal) const client = stream[Symbol.asyncIterator]() ctx.effect(() => async () => { connection.abort() @@ -106,6 +110,7 @@ describe('Client inspect completion', () => { it('does not dispatch a query when the Gateway has no connected Client', async () => { await ctx.plugin(TypertRegistry) + await ctx.plugin(RemoteAccessService) await ctx.plugin(TypertGateway) const query = start() expect(requests).toEqual([]) 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 fbd46a38bc..0debc5a62a 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 @@ -53,5 +53,7 @@ export const remoteDefaultResponses: RemoteTable = { 'account/watch': openStream([{ status: 'signed-out', attempt: null, links: { usageUrl: 'https://platform.deepseek.com/usage', topUpUrl: 'https://platform.deepseek.com/top_up' } }]), // api-workspace-controller client `apply`: the follow stream's opening baseline, then open. 'workspace/follow': openStream([{ type: 'baseline', value: { items: [], archivedSessionIds: [], pinnedSessionIds: [] } }]), + // api-remote-access client `mountAccess`: the owner's access snapshot, then open. + 'remoteAccess/follow': openStream([{ type: 'baseline', owner: true }]), }, } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 52f30f185c..3ac8d8bddb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -956,6 +956,9 @@ importers: '@deepseek-ai/dsh-agent-preset-registry': specifier: workspace:* version: link:../packages/preset/agent-preset-registry + '@deepseek-ai/dsh-api-remote-access': + specifier: workspace:* + version: link:../packages/api/remote-access '@deepseek-ai/dsh-api-session-controller': specifier: workspace:* version: link:../packages/api/session-controller @@ -1201,6 +1204,9 @@ importers: '@deepseek-ai/cordis': specifier: workspace:~ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-api-remote-access': + specifier: workspace:* + version: link:../remote-access '@deepseek-ai/dsh-brand': specifier: workspace:* version: link:../../util/brand @@ -1269,6 +1275,37 @@ importers: specifier: workspace:* version: link:../../typert/registry + packages/api/remote-access: + dependencies: + '@deepseek-ai/dsh-typert-protocol': + specifier: workspace:* + version: link:../../typert/protocol + zod: + specifier: 4.4.3 + version: 4.4.3 + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:~ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-api-gateway': + specifier: workspace:* + version: link:../gateway + '@deepseek-ai/dsh-client-connection': + specifier: workspace:* + version: link:../../client/connection + '@deepseek-ai/dsh-client-ui-renderer': + specifier: workspace:* + version: link:../../client/ui-renderer + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:* + version: link:../../client/ui-slots + '@deepseek-ai/dsh-host-webserver': + specifier: workspace:* + version: link:../../host/webserver + '@deepseek-ai/dsh-typert-registry': + specifier: workspace:* + version: link:../../typert/registry + packages/api/remotes: dependencies: '@deepseek-ai/dsh-deque': @@ -1368,6 +1405,9 @@ importers: '@deepseek-ai/dsh-scope': specifier: workspace:* version: link:../../core/scope + '@deepseek-ai/dsh-session': + specifier: workspace:* + version: link:../../core/session '@deepseek-ai/dsh-session-reference': specifier: workspace:* version: link:../../context/session-reference @@ -1429,6 +1469,9 @@ importers: '@deepseek-ai/dsh-api-gateway': specifier: workspace:* version: link:../gateway + '@deepseek-ai/dsh-api-remote-access': + specifier: workspace:* + version: link:../remote-access '@deepseek-ai/dsh-attachment': specifier: workspace:* version: link:../../attachment/attachment @@ -1666,6 +1709,9 @@ importers: '@deepseek-ai/dsh-api-gateway': specifier: workspace:* version: link:../gateway + '@deepseek-ai/dsh-api-remote-access': + specifier: workspace:* + version: link:../remote-access '@deepseek-ai/dsh-client-connection': specifier: workspace:* version: link:../../client/connection @@ -1715,6 +1761,9 @@ importers: '@deepseek-ai/dsh-api-gateway': specifier: workspace:* version: link:../gateway + '@deepseek-ai/dsh-api-remote-access': + specifier: workspace:* + version: link:../remote-access '@deepseek-ai/dsh-client-connection': specifier: workspace:* version: link:../../client/connection @@ -2057,6 +2106,9 @@ importers: '@deepseek-ai/dsh-api-gateway': specifier: workspace:* version: link:../../api/gateway + '@deepseek-ai/dsh-api-remote-access': + specifier: workspace:* + version: link:../../api/remote-access '@deepseek-ai/dsh-attachment-local': specifier: workspace:* version: link:../../attachment/attachment-local @@ -4733,6 +4785,9 @@ importers: '@deepseek-ai/cordis': specifier: workspace:~ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-api-remote-access': + specifier: workspace:* + version: link:../../api/remote-access '@deepseek-ai/dsh-api-remotes': specifier: workspace:* version: link:../../api/remotes @@ -6188,6 +6243,9 @@ importers: '@deepseek-ai/dsh-agent-preset-registry': specifier: workspace:* version: link:../../preset/agent-preset-registry + '@deepseek-ai/dsh-api-remote-access': + specifier: workspace:* + version: link:../../api/remote-access '@deepseek-ai/dsh-api-remotes': specifier: workspace:* version: link:../../api/remotes @@ -7915,6 +7973,9 @@ importers: '@deepseek-ai/dsh-agent-loop-testkit': specifier: workspace:* version: link:../../test-support/agent-loop-testkit + '@deepseek-ai/dsh-api-remote-access': + specifier: workspace:* + version: link:../../api/remote-access '@deepseek-ai/dsh-api-remotes': specifier: workspace:* version: link:../../api/remotes @@ -9314,6 +9375,9 @@ importers: '@deepseek-ai/dsh-api-gateway': specifier: workspace:* version: link:../../api/gateway + '@deepseek-ai/dsh-api-remote-access': + specifier: workspace:* + version: link:../../api/remote-access '@deepseek-ai/dsh-brand': specifier: workspace:* version: link:../../util/brand @@ -14070,6 +14134,9 @@ importers: '@deepseek-ai/dsh-api-gateway': specifier: workspace:* version: link:../../api/gateway + '@deepseek-ai/dsh-api-remote-access': + specifier: workspace:* + version: link:../../api/remote-access '@deepseek-ai/dsh-api-remotes': specifier: workspace:* version: link:../../api/remotes @@ -14121,6 +14188,9 @@ importers: '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:* version: link:../../client/ui-slots + '@deepseek-ai/dsh-client-ui-workspace': + specifier: workspace:* + version: link:../../client/ui-workspace '@deepseek-ai/dsh-client-web': specifier: workspace:* version: link:../../client/web diff --git a/scripts/package-dependency-policy.ts b/scripts/package-dependency-policy.ts index bfd48aecda..733abda7bf 100644 --- a/scripts/package-dependency-policy.ts +++ b/scripts/package-dependency-policy.ts @@ -61,7 +61,6 @@ 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-scope': ['carrierKeyOf', 'createScope', 'scopeOf', 'scopeTarget'], '@deepseek-ai/dsh-session': ['SESSION_FORMAT_VERSION'], '@deepseek-ai/dsh-session-persistence': ['SessionPersistenceNotFoundError'], diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 848ef35b89..e9a1e01e1f 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -115,6 +115,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/client/connection': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, 'packages/client/file-upload': { kind: 'none', reason: 'Browser-side request-body transport; registers nothing model-facing.' }, 'packages/api/remotes': { kind: 'none', reason: 'The Remote BFF selects business methods and forwarded events; selected services own any model-visible effect.' }, + 'packages/api/remote-access': { kind: 'none', reason: 'Remote access decisions for Client callers; registers nothing model-facing.' }, 'packages/experimental/client-ui-agent-team': { kind: 'none', reason: 'The browser projection and task controls register no model-facing input.' }, 'packages/experimental/client-ui-claude-code-mods': { kind: 'none', reason: 'The browser draws Host-held mod trees and relays clicks; the mods\' own hooks own any model-visible effect.' }, 'packages/client/shortcuts': { kind: 'none', reason: 'Browser keyboard routing; registers nothing model-facing.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index 8e65cfb83d..6353fb86ce 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -119,6 +119,9 @@ "@deepseek-ai/dsh-api-session-controller/remote-events": ["./packages/api/session-controller/src/remote-events.ts"], "@deepseek-ai/dsh-api-settings-controller": ["./packages/api/settings-controller/src/index.ts"], "@deepseek-ai/dsh-api-settings-controller/types": ["./packages/api/settings-controller/src/types.ts"], + "@deepseek-ai/dsh-api-remote-access": ["./packages/api/remote-access/src/index.ts"], + "@deepseek-ai/dsh-api-remote-access/client": ["./packages/api/remote-access/src/client/index.ts"], + "@deepseek-ai/dsh-api-remote-access/types": ["./packages/api/remote-access/src/types.ts"], "@deepseek-ai/dsh-api-workspace-controller": ["./packages/api/workspace-controller/src/index.ts"], "@deepseek-ai/dsh-api-workspace-controller/client": ["./packages/api/workspace-controller/src/client/index.ts"], "@deepseek-ai/dsh-api-workspace-controller/default-workspace": ["./packages/api/workspace-controller/src/default-workspace.ts"], diff --git a/tsconfig.client.json b/tsconfig.client.json index b4c4e42041..fc87ea5394 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -84,6 +84,7 @@ { "path": "./packages/typert/registry" }, { "path": "./packages/api/gateway/tsconfig.client.json" }, { "path": "./packages/api/session-controller/tsconfig.client.json" }, + { "path": "./packages/api/remote-access/tsconfig.client.json" }, { "path": "./packages/api/workspace-controller/tsconfig.client.json" }, { "path": "./packages/api/remotes/tsconfig.client.json" }, { "path": "./packages/extensions/cordis-client-runner" }, diff --git a/tsconfig.host.json b/tsconfig.host.json index 93c52c2a4d..ec1bcc2316 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -318,6 +318,7 @@ { "path": "./packages/api/session-controller/tsconfig.host.json" }, { "path": "./packages/api/workspace-files/tsconfig.host.json" }, { "path": "./packages/api/settings-controller" }, + { "path": "./packages/api/remote-access/tsconfig.host.json" }, { "path": "./packages/api/workspace-controller/tsconfig.host.json" }, { "path": "./packages/typert/loader" }, { "path": "./packages/session/session-persistence" }, diff --git a/vitest.config.ts b/vitest.config.ts index 7722718045..c355d1d7db 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -333,6 +333,9 @@ export default defineConfig({ // The mods band entry imports the bridge's generated Remote contribution, which exists only in lib; // the Web snapshot exercises the built entry, while source tests cover mountModsBand. 'packages/experimental/client-ui-claude-code-mods/src/client/index.ts', + // The access entry imports its generated Remote contribution, which exists only in lib; + // the Web snapshot exercises the built entry, while source tests cover mountAccess. + 'packages/api/remote-access/src/client/index.ts', // Slash/command/input round: per-file gaps deferred with the same // client-lane debt. TODO(gui): cover and remove with the lane above. 'packages/client/ui-commands/src/index.ts', From 2640d3df8295348e0c8d01b633d10762adfadcce Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:46:53 +0800 Subject: [PATCH 19/35] feat(api): resolve endpoint resources and filter lists by access --- .../api/session-controller/README.i18n.yaml | 4 +- packages/api/session-controller/README.md | 2 + packages/api/session-controller/README.zh.md | 2 + packages/api/session-controller/package.json | 1 + packages/api/session-controller/src/index.ts | 41 +++- .../tests/list-scheduling.host.spec.ts | 8 +- .../tests/remote-access.host.spec.ts | 197 ++++++++++++++++++ .../session-controller/tests/test-remote.ts | 3 + .../api/session-controller/tsconfig.host.json | 3 + .../api/workspace-controller/README.i18n.yaml | 4 +- packages/api/workspace-controller/README.md | 2 + .../api/workspace-controller/README.zh.md | 2 + .../api/workspace-controller/package.json | 1 + .../workspace-controller/src/client/index.ts | 3 + packages/api/workspace-controller/src/feed.ts | 13 +- .../api/workspace-controller/src/index.ts | 76 ++++++- .../api/workspace-controller/src/types.ts | 7 +- .../tests/transport.client.spec.ts | 32 +++ .../tests/workspace-controller.host.spec.ts | 135 +++++++++++- .../workspace-controller/tsconfig.host.json | 1 + packages/goal/goal/tests/goal.spec.ts | 11 + 21 files changed, 527 insertions(+), 21 deletions(-) create mode 100644 packages/api/session-controller/tests/remote-access.host.spec.ts diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index 3d1a5b274d..8ae33a613e 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/README.i18n.yaml @@ -15,8 +15,8 @@ en: 1fdf707322fb6996 zh: 0204f7e9d4aa3cce /session-controller/use-this-package: - en: d8c0e2b2e45fa75d - zh: 025b50be4204580c + en: 807a42e6f5e5fb43 + zh: 23cf50e120c86c26 /session-controller/client-references: en: 41bdda1ac32df558 zh: aa5d0e325924066b diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index ed01827a70..11f04d6d6e 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -33,6 +33,8 @@ The Client journal validates current Session event envelopes before publishing f Each endpoint states its activation policy. List reads only stored headers and projection-cache rows: it never calls per-session stat or opens a cold Session body. A current-format cache identity may supply every list hint; a lifecycle-matching predecessor cache may supply only its version-compatible title as a stale display fact, never as an authoritative fold seed. Search, attachment, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Cancellation requires live state; queue mutation, model, rename, prompt, and file-reference operations may resolve or resume an ordinary Session. Prompt rejects content with neither non-whitespace text nor an attachment before resolving the Agent or appending Session events; queue edits accept only non-empty text content. Prompt admission consumes opaque receipts from the injected [`fileUploads`](../../client/file-upload/README.md) Host service and resolves every same-Agent receipt before sending the complete ordered content list through `ctx.attachments`. Prompt retries whose `requestId` is already queued or logged return the original acceptance without inserting another message. Create and fork are the only operations that create a new Agent directly. The service applies one preset-aware resume policy and subagent ownership fence to its own methods and to the Typert Agent and Session lookups used by other Remote namespaces. Queue mutation has one narrow exception: a live child whose current projected identity is continuable and comes from its own non-seed suffix accepts the ordinary Edit, Remove, and QueueDock Steer actions across both inbox destinations. One-shot, missing, unknown, corrupt, seed-only, or cold children remain rejected without resume. The skill catalog uses a live Agent's effective working directory and scoped registry when present. Cold listing uses the projected current directory, falling back to the recorded origin and then the configured directory default. It retains the preset standing scope without activating an Agent. The authenticated delivery routes use `workspaceDesktop()` for the serving Host name and file-manager behavior. `openWorkspacePath({ path, action: "reveal" })` delegates file-manager navigation to the native adapter; omitting `action` opens the file-type association, including HTML and SVG. Both operations require the composed filesystem to map the requested Host path to the same canonical process path; unmapped remote paths are refused before a native command runs. `session.projections` normally reads an exact baseline, including current-format cold logs, and returns `kind: sequenced`. Only a read requiring migration returns `kind: migration-required` hints without `asOfSeq`; missing keys remain absent. It returns null for a missing Session. Fork callers may set `allowMigration: false` to receive `session/migration-required` instead of starting source migration; omission retains automatic migration. The Client exposes shared values and explicit-read state through `projectionsBySession`; domains select their own keys. Session-list summaries carry `agentAvailable`, updated through existing summary and status events independently of durable projections. Sequenced reads and live projection frames use the same sequence ordering; cached reads fill hints without replacing sequenced values. +Every Remote endpoint is access-controlled: a Peer whose principal is not the owner reaches an endpoint only when, on every resource the call touches, a binding of that principal kind's policy in [`dsh-api-remote-access`](../remote-access/README.md) grants a role listing the action ([Peer principals Agent Note](../../../.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md)). The controller injects `remoteAccess` and registers the `session` resource kind with the default field `sessionId` through `typert.resources`. `session/page` and `session/follow` declare a `session` reader in `@Remote({ resources })` that reads `request.address.sessionId` for an ordinary Session address and `request.address.childSessionId` for a subagent address; whether access to the parent Session covers its children is the policy's rule. `session/projections` reads the Session resource from `request.sessionId`, so a reconnecting reader reloads only the projections of Sessions it may read. `session/list` and `session/control` read no resource, so their calls touch the `global` resource and need a `global` role; for a non-owner caller, `session/list` returns only the rows, and `session/control` only the baseline projections and projection frames, whose Session `ctx.remoteAccess.allows()` admits for the same action, so that action also belongs to a `session` role; owner results are unfiltered. `session/prompt` builds the durable `user-rpc` message source in its Remote method and records `principal` only for a non-owner caller, read through `principalOf(this.ctx.invocation)`; a source without `principal` belongs to the owner. + List rows carry `formatStatus` when available, so Clients can identify migration-required Sessions without requesting their projections. Host list generation yields between complete rows only when its configured work slice is exhausted; cheap remaining work returns without a forced yield. Request cancellation is checked per row and after each yield and rejects without partial results. Each row is synchronous; the list does not promise one cross-Session snapshot. A Session already queued as cold remains a cached row if it attaches during a yield: Client in-flight mutation replay corrects its availability, and sequenced projections supersede cached hints. diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index cc5a4ce4b2..b3cb5dccee 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -33,6 +33,8 @@ Client journal 在发布 follow 快照、live entry 或历史页之前验证当 每个 endpoint 都声明自己的激活策略。列表只读取持久化 header 与 projection cache row,绝不调用逐 Session stat 或打开冷 Session body。当前格式 cache identity 可以提供全部列表 hint;生命周期匹配的 predecessor cache 只能提供版本兼容的 title,作为可能过时的展示事实,绝不能作为权威 fold seed。搜索、附件、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。取消要求 live 状态;queue 变更、模型、重命名、prompt 和文件引用操作可以解析或恢复普通 Session。提示词会在解析 Agent 或追加 Session 事件前,拒绝既没有非空白文本也没有附件的 content;queue edit 只接受非空文本 content。prompt 准入从注入的 [`fileUploads`](../../client/file-upload/README.zh.md) Host 服务取得不透明凭证,在把完整有序内容列表交给 `ctx.attachments` 前解析每个属于同一 Agent 的凭证。`requestId` 已进入 queue 或日志时,prompt 重试直接返回原来的接受结果,不会重复插入消息。只有 create 与 fork 会直接创建新 Agent。该服务把同一套感知 preset 的恢复策略和 subagent ownership fence 同时用于自身方法,以及其他 Remote namespace 使用的 Typert Agent 与 Session lookup。Queue 变更只有一个狭窄例外:当前 projection identity 为 continuable 且来自自身非 seed suffix 的在线 child,可以在两个 inbox 目标上使用普通 Edit、Remove 与 QueueDock Steer action。One-shot、缺失、未知、损坏、仅含 seed identity 或冷 child 继续被拒绝,且不会恢复。skill 目录优先使用已有 live Agent 的有效工作目录与限定范围内的注册表。冷态列表使用投影中的当前目录,依次回退到记录的起始目录与配置的默认目录。它保留 preset 常驻 scope,不会激活 Agent。经过鉴权的文件交付路由通过 `workspaceDesktop()` 获取提供服务的 Host 名称和文件管理器行为。`openWorkspacePath({ path, action: "reveal" })` 将文件管理器导航委托给原生适配器;省略 `action` 时按文件类型关联打开,包括 HTML 和 SVG。两种操作都要求当前文件系统将请求的 Host 路径映射到同一个规范进程路径;无法映射的远端路径会在执行原生命令之前被拒绝。 `session.projections` 正常读取准确基线,包括当前格式的冷日志,返回 `kind: sequenced`。只有需要迁移时才返回不带 `asOfSeq` 的 `kind: migration-required` 提示,缺少的键保持缺席。Session 不存在时返回 null。Fork 调用者可设置 `allowMigration: false`,在源历史需要迁移时收到 `session/migration-required`;省略则保持自动迁移。Client 通过 `projectionsBySession` 暴露共享值和显式读取状态,由领域选择自身的 key。Session 列表摘要携带 `agentAvailable`,通过已有摘要与状态事件更新,与持久化 projection 相互独立。有序读取与实时 projection 帧使用相同的序号排序规则;缓存读取只补充提示,不替换有序值。 +每个 Remote 端点都受访问控制:principal 不是 owner 的 Peer 只有在本次调用访问的每个资源上,该 principal kind 在 [`dsh-api-remote-access`](../remote-access/README.zh.md) 中的 policy 都有某个 binding 授予列出该 action 的 role 时,才能调用该端点([Peer 身份属性 Agent Note](../../../.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md))。控制器注入 `remoteAccess`,并经 `typert.resources` 以默认字段 `sessionId` 登记 `session` 资源种类。`session/page` 与 `session/follow` 在 `@Remote({ resources })` 中声明 `session` 读取函数:对普通 Session 地址读 `request.address.sessionId`,对 subagent 地址读 `request.address.childSessionId`;父会话的访问权是否覆盖其子会话由 policy 决定。`session/projections` 从 `request.sessionId` 读取会话资源,因此重连的读者只会重新读取它可读的会话的 projections。`session/list` 与 `session/control` 不读取资源,其调用访问 `global` 资源,需要 `global` role;对非 owner 调用方,`session/list` 只返回其 Session 被 `ctx.remoteAccess.allows()` 按同一 action 允许的行,`session/control` 也只返回这类 Session 的 baseline projection 与 projection 帧,因此该 action 也要列在 `session` role 中;owner 的结果不过滤。`session/prompt` 在其 Remote 方法内构造持久的 `user-rpc` 消息 source,且只为非 owner 调用方记录经 `principalOf(this.ctx.invocation)` 读取的 `principal`;不带 `principal` 的 source 属于 owner。 + 列表行在状态可用时携带 `formatStatus`,Client 无需请求其投影即可识别需要迁移的 Session。 Host 仅在配置的工作时间片耗尽时,于完整条目之间让出事件循环;剩余工作较少时直接返回,不强制让出。每行及每次让出之后检查请求取消,取消时拒绝返回部分结果。每行同步计算;列表不承诺跨 Session 的统一快照。已经进入冷态队列的 Session 若在让出期间接入,仍返回缓存行:Client 对进行中请求的变更重放会纠正其可用性,带序号的投影会覆盖缓存提示。 diff --git a/packages/api/session-controller/package.json b/packages/api/session-controller/package.json index 436b151bbb..1bcdd19004 100644 --- a/packages/api/session-controller/package.json +++ b/packages/api/session-controller/package.json @@ -122,6 +122,7 @@ } }, "devDependencies": { + "@deepseek-ai/dsh-api-remote-access": "workspace:*", "@deepseek-ai/cordis": "workspace:~", "@deepseek-ai/dsh-agent": "workspace:*", "@deepseek-ai/dsh-agent-default-model": "workspace:*", diff --git a/packages/api/session-controller/src/index.ts b/packages/api/session-controller/src/index.ts index d3d5234e34..81fca45b9b 100644 --- a/packages/api/session-controller/src/index.ts +++ b/packages/api/session-controller/src/index.ts @@ -15,6 +15,8 @@ import type {} from '@deepseek-ai/dsh-session-projection-cache' import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query' import { Remote, RemoteError, TypertRemoteService, principalOf } from '@deepseek-ai/dsh-typert-protocol' import { canonicalClientTimeZone } from '@deepseek-ai/dsh-util-time' +import { assertNever } from '@deepseek-ai/dsh-util-values' +import type {} from '@deepseek-ai/dsh-api-remote-access' import { ApiSessionAgentController, inspectApiSession, @@ -33,6 +35,7 @@ import { ArchivedSessionGate } from './archived-session-gate.ts' import type { ModelCatalog, SessionWorkspacePathApplication, + SessionAddress, SessionAttachmentRequest, SessionAttachmentValue, SessionCancelRequest, @@ -77,6 +80,16 @@ declare module '@deepseek-ai/cordis' { } } +/** + * Read the Session resource a history address names: the Session itself, or + * the child Session whose transcript a subagent address reads. + * @param request - history request carrying the durable address. + * @returns the Session whose access the read requires. + */ +function addressedSession(request: { readonly address: SessionAddress }): SessionId { + return request.address.kind === 'session' ? request.address.sessionId : request.address.childSessionId +} + /** Session Controller deployment policy. */ export interface Config { /** Override platform desktop-opener detection. */ @@ -111,6 +124,7 @@ export class SessionController extends TypertRemoteService { 'fileUploads', 'fs', 'llm', + 'remoteAccess', 'sessions', 'sessionProjections', 'sessionQuery', @@ -142,6 +156,7 @@ export class SessionController extends TypertRemoteService { */ constructor(ctx: Context, config: Config, internals: SessionControllerInternals = {}) { super(ctx, 'sessionController', { namespace: 'session' }) + ctx.typert.resources.register('session', { field: 'sessionId' }) const resolved = SessionController.Config(config) installModelSelectionProjection(ctx) this.agents = new ApiSessionAgentController(ctx) @@ -259,7 +274,8 @@ export class SessionController extends TypertRemoteService { */ @Remote('list') async list(_request: SessionListRequest, signal: AbortSignal): Promise { - return { items: await this.listState.list(signal) } + const items = await this.listState.list(signal) + return { items: items.filter(item => this.ctx.remoteAccess.allows({ kind: 'session', id: item.sessionId })) } } /** @@ -502,7 +518,7 @@ export class SessionController extends TypertRemoteService { * @param signal - cancellation for persistence reads. * @returns one chronological page. */ - @Remote('page') + @Remote({ name: 'page', resources: { session: addressedSession } }) page(request: SessionPageRequest, signal: AbortSignal): Promise { return this.history.page(request, signal) } @@ -514,7 +530,7 @@ export class SessionController extends TypertRemoteService { * @returns a complete opening snapshot followed by gap-free durable event * frames and optional cursorless assistant-stream frames. */ - @Remote({ mode: 'stream' }) + @Remote({ mode: 'stream', resources: { session: addressedSession } }) follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable { return this.history.follow(request, signal) } @@ -577,8 +593,23 @@ export class SessionController extends TypertRemoteService { * @returns one complete baseline followed by live replacement frames. */ @Remote({ mode: 'stream' }) - control(signal: AbortSignal): AsyncIterable { - return this.controlState.control(signal) + async *control(signal: AbortSignal): AsyncIterable { + for await (const frame of this.controlState.control(signal)) { + switch (frame.type) { + case 'baseline': { + const projections = Object.fromEntries(Object.entries(frame.value.projections) + .filter(([sessionId]) => this.ctx.remoteAccess.allows({ kind: 'session', id: sessionId as SessionId }))) + yield { type: 'baseline', value: { ...frame.value, projections } } + break + } + case 'projection': + if (this.ctx.remoteAccess.allows({ kind: 'session', id: frame.sessionId })) yield frame + break + /* v8 ignore next -- closed control-frame union. */ + default: + assertNever(frame) + } + } } diff --git a/packages/api/session-controller/tests/list-scheduling.host.spec.ts b/packages/api/session-controller/tests/list-scheduling.host.spec.ts index eefea76713..b3e412ec9f 100644 --- a/packages/api/session-controller/tests/list-scheduling.host.spec.ts +++ b/packages/api/session-controller/tests/list-scheduling.host.spec.ts @@ -8,6 +8,7 @@ import type { SessionRecord } from '@deepseek-ai/dsh-session-query' import { resolve } from 'node:path' import { performance } from 'node:perf_hooks' import { scheduler } from 'node:timers/promises' +import RemoteAccessService from '@deepseek-ai/dsh-api-remote-access' import SessionController from '../src/index.ts' import { afterEach, describe, expect, it, vi } from 'vitest' import { ApiSessionList } from '../src/list.ts' @@ -181,10 +182,15 @@ describe('Session-list work slices', () => { it('routes an overridden budget through the public Session Controller', async () => { const h = await harness([{ id: 'first', phase: 'cold' }, { id: 'middle', phase: 'cold' }, { id: 'last', phase: 'cold' }], 4) const dispose = (): void => {} - h.ctx.provide('typert', { lookups: { configure: () => dispose }, contexts: { configureHost: () => dispose } } as never) + h.ctx.provide('typert', { + lookups: { configure: () => dispose }, + contexts: { configureHost: () => dispose }, + resources: { register: () => dispose }, + } as never) h.ctx.provide('fileUploads', { registerAgentResolver: () => dispose } as never) h.ctx.provide('agentDefaultModel', { currentSelection: () => ({ provider: 'test', model: 'test' }) } as never) h.ctx.provide('workspaceRegistry', { list: () => [], archivedSessionIds: [] } as never) + new RemoteAccessService(h.ctx) const controller = new SessionController(h.ctx, { nativeOpen: false, listWorkSliceMs: 4 }, { canOpenPath: () => false }) const yieldWork = vi.spyOn(scheduler, 'yield') const result = await controller.list({}, new AbortController().signal) diff --git a/packages/api/session-controller/tests/remote-access.host.spec.ts b/packages/api/session-controller/tests/remote-access.host.spec.ts new file mode 100644 index 0000000000..04fa3b860f --- /dev/null +++ b/packages/api/session-controller/tests/remote-access.host.spec.ts @@ -0,0 +1,197 @@ +/** + * Session Controller under non-owner callers: the Session resource kind it + * registers, the Session readers of `session/page` and `session/follow`, + * `session/list` and `session/control` called through a `global` role and + * filtered per Session through `remoteAccess.allows()`, the caller recorded + * on prompt sources, and `session/projections` admitted per Session through + * the Gateway. + */ + +import { Context } from '@deepseek-ai/cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { mountAgentLoopTestDependencies, mountAgentLoopTestHarness } from '@deepseek-ai/dsh-agent-loop-testkit' +import TypertGatewayService from '@deepseek-ai/dsh-api-gateway' +import type { RemoteAccessBinding, RemoteAccessRole } from '@deepseek-ai/dsh-api-remote-access' +import { createUserMessage } from '@deepseek-ai/dsh-llm' +import { SessionId } from '@deepseek-ai/dsh-session' +import { remoteMethods, type PeerId, type PeerScope, type Principal, type RemoteInvocation } from '@deepseek-ai/dsh-typert-protocol' +import TypertRegistry from '@deepseek-ai/dsh-typert-registry' +import { afterEach, describe, expect, it } from 'vitest' +import type SessionController from '../src/index.ts' +import type { SessionControlFrame, SessionRequestId } from '../src/types.ts' +import { createSessionTestController } from './test-remote.ts' + +declare module '@deepseek-ai/dsh-typert-protocol' { + interface PrincipalMap { + /** Test-only non-owner caller shared by the access specs. */ + 'test-guest': { readonly kind: 'test-guest'; readonly guest: string } + } +} + +const OWNER: Principal = { kind: 'owner' } +const GUEST: Principal = { kind: 'test-guest', guest: 'session-controller' } +const SHARED = SessionId('shared-session') +const PRIVATE = SessionId('private-session') + +/** Calls the Session list and control methods; each item still needs a Session role. */ +const LISTER: RemoteAccessRole<'global'> = { name: 'test:session-lister', resource: 'global', actions: ['session/list', 'session/control'] } +/** Reads one Session: its list row, its control projections, and its stored projections. */ +const READER: RemoteAccessRole<'session'> = { + name: 'test:session-reader', resource: 'session', actions: ['session/list', 'session/control', 'session/projections'], +} +const LIST_CALLS: RemoteAccessBinding = { role: LISTER, resource: { kind: 'global' } } +const READS_SHARED: RemoteAccessBinding = { role: READER, resource: { kind: 'session', id: SHARED } } + +const owned = new Set() +afterEach(async () => { + await Promise.all([...owned].map(ctx => ctx.fiber.dispose())) + owned.clear() +}) + +/** + * Register the guest policy. + * @returns the bindings the policy answers with; edits apply from the next Remote call. + */ +function guestPolicy(ctx: Context, ...bindings: RemoteAccessBinding[]): RemoteAccessBinding[] { + const held = [...bindings] + ctx.remoteAccess.registerPolicy('test-guest', { bindings: () => held }) + return held +} + +/** Invoke `session/` as the guest through the Gateway. */ +function guestCall(ctx: Context, method: string, args: Readonly>): Promise { + const peer: PeerScope = { id: 'session-access-peer' as PeerId, ctx, principal: GUEST, signal: new AbortController().signal, dispose: async () => {} } + return ctx.typertGateway.invoke({ peer, namespace: 'session', method, args }) +} + +async function harness(): Promise<{ + readonly ctx: Context + readonly controller: SessionController + readonly shared: Agent + readonly hidden: Agent + /** The Context a Gateway dispatch of `session/` binds for `principal`. */ + readonly as: (principal: Principal, method: string) => Context +}> { + const ctx = new Context() + owned.add(ctx) + await ctx.plugin(TypertRegistry) + await mountAgentLoopTestDependencies(ctx) + const loop = await mountAgentLoopTestHarness(ctx) + const shared = await loop.create(SHARED) + const hidden = await loop.create(PRIVATE) + const controller = createSessionTestController(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + const as = (principal: Principal, method: string): Context => { + const invocation: RemoteInvocation = { + request: { namespace: 'session', method, args: {} }, + service: 'sessionController', + peer: { id: 'session-access-peer' as PeerId, ctx, principal, signal: new AbortController().signal, dispose: async () => {} }, + signal: new AbortController().signal, + uplink: (): AsyncIterable => ({ [Symbol.asyncIterator]: () => ({ next: async () => ({ done: true, value: undefined }) }) }), + } + return ctx.extend({ invocation }) + } + return { ctx, controller, shared, hidden, as } +} + +function message(text: string) { + return createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } }) +} + +describe('Session Controller access', () => { + it('owns the Session resource kind and its default sessionId field', async () => { + const { ctx } = await harness() + expect(() => ctx.typert.resources.register('session', { field: 'sessionId' })).toThrow(/already registered/) + }) + + it('reads the Session resource of page and follow requests from the addressed Session', async () => { + const { controller } = await harness() + for (const method of ['page', 'follow']) { + const reader = remoteMethods(controller).find(marker => marker.method === method)?.resources?.['session'] + expect(reader?.({ address: { kind: 'session', sessionId: SHARED } })).toBe(SHARED) + // A subagent address reads the child transcript; whether the parent's access covers it is the policy's rule. + expect(reader?.({ address: { kind: 'subagent', parentSessionId: SHARED, childSessionId: PRIVATE } })).toBe(PRIVATE) + } + }) + + it('lets a global role call session/list and lists only the Sessions a Session role admits', async () => { + const { ctx, as } = await harness() + await ctx.plugin(TypertGatewayService) + const held = guestPolicy(ctx, LIST_CALLS, READS_SHARED) + const rows = async (): Promise => { + const value = await guestCall(ctx, 'list', { _request: {} }) as { readonly items: readonly { readonly sessionId: SessionId }[] } + return value.items.map(item => item.sessionId) + } + await expect(rows()).resolves.toEqual([SHARED]) + // The global role admits the call, not any row. + held.splice(0, held.length, LIST_CALLS) + await expect(rows()).resolves.toEqual([]) + // A Session role alone does not admit the call: the global resource does not inherit from it. + held.splice(0, held.length, READS_SHARED) + await expect(rows()).rejects.toMatchObject({ + code: 'remote-access/denied', details: { action: 'session/list', reason: 'no-allow' }, + }) + const owner = await as(OWNER, 'list').sessionController.list({}, new AbortController().signal) + expect(owner.items.map(item => item.sessionId).sort()).toEqual([PRIVATE, SHARED].sort()) + }) + + it('filters session/control baselines and projection frames for a non-owner', async () => { + const { ctx, shared, hidden, as } = await harness() + guestPolicy(ctx, LIST_CALLS, READS_SHARED) + const abort = new AbortController() + const guest = as(GUEST, 'control').sessionController.control(abort.signal)[Symbol.asyncIterator]() + const owner = as(OWNER, 'control').sessionController.control(abort.signal)[Symbol.asyncIterator]() + const baselineSessions = async (iterator: AsyncIterator): Promise => { + const next = await iterator.next() + if (next.done === true || next.value.type !== 'baseline') throw new Error('control stream opened without a baseline') + return Object.keys(next.value.value.projections).sort() + } + expect(await baselineSessions(guest)).toEqual([SHARED]) + expect(await baselineSessions(owner)).toEqual([PRIVATE, SHARED].sort()) + + hidden.inbox.append('next-turn', message('private work')) + shared.inbox.append('next-turn', message('shared work')) + const projected = async (iterator: AsyncIterator): Promise => { + for (;;) { + const next = await iterator.next() + if (next.done === true) throw new Error('control stream ended') + if (next.value.type === 'projection' && next.value.key === 'inbox') return next.value.sessionId + } + } + // The owner sees the private Session's frame first; the guest's first Inbox frame is the shared one. + expect(await projected(owner)).toBe(PRIVATE) + expect(await projected(guest)).toBe(SHARED) + abort.abort() + await Promise.all([guest.return?.(), owner.return?.()]) + }) + + it('records a non-owner caller on the prompt source and leaves owner sources unchanged', async () => { + const { shared, as } = await harness() + const signal = new AbortController().signal + const prompt = (principal: Principal, requestId: string) => as(principal, 'prompt').sessionController.prompt({ + sessionId: SHARED, + requestId: requestId as SessionRequestId, + mode: 'queue', + content: [{ type: 'text', text: `from ${principal.kind}` }], + }, signal) + await expect(prompt(GUEST, 'guest-1')).resolves.toEqual({ accepted: true }) + await expect(prompt(OWNER, 'owner-1')).resolves.toEqual({ accepted: true }) + // The durable Inbox splice records each accepted message with its source. + const logged = shared.session.snapshotEvents() + .flatMap(event => event.type === 'agent/inbox/spliced' ? event.data.inserted.map(item => item.source) : []) + expect(logged).toEqual([ + { kind: 'user', rpcId: 'guest-1', principal: GUEST }, + { kind: 'user', rpcId: 'owner-1' }, + ]) + }) + + it('lets a non-owner read projections only in a Session its Session role names', async () => { + const { ctx } = await harness() + await ctx.plugin(TypertGatewayService) + guestPolicy(ctx, READS_SHARED) + const projections = (sessionId: SessionId): Promise => guestCall(ctx, 'projections', { request: { sessionId } }) + await expect(projections(SHARED)).resolves.toMatchObject({ kind: 'sequenced' }) + await expect(projections(PRIVATE)).rejects.toMatchObject({ + code: 'remote-access/denied', details: { action: 'session/projections', reason: 'no-allow' }, + }) + }) +}) diff --git a/packages/api/session-controller/tests/test-remote.ts b/packages/api/session-controller/tests/test-remote.ts index 71db06b22b..586afcde7b 100644 --- a/packages/api/session-controller/tests/test-remote.ts +++ b/packages/api/session-controller/tests/test-remote.ts @@ -31,6 +31,7 @@ import { remoteErrorOf, type RemoteResult, } from '@deepseek-ai/dsh-typert-protocol' +import RemoteAccessService from '@deepseek-ai/dsh-api-remote-access' import SessionController from '../src/index.ts' import type { ModelCatalog, @@ -243,6 +244,7 @@ function installControllers( ctx.provide('typert', { lookups: { configure: () => dispose }, contexts: { configureHost: () => dispose }, + resources: { register: () => dispose }, } as never) } if (ctx.get('agentDefaultModel') === undefined) { @@ -289,6 +291,7 @@ function installControllers( } as never) } installSessionReadTestServices(ctx) + if (ctx.get('remoteAccess') === undefined) new RemoteAccessService(ctx) const cwd = vi.spyOn(process, 'cwd').mockReturnValue(defaults.cwd) let controller: SessionController try { diff --git a/packages/api/session-controller/tsconfig.host.json b/packages/api/session-controller/tsconfig.host.json index 6376b437ca..8f90ca7a6a 100644 --- a/packages/api/session-controller/tsconfig.host.json +++ b/packages/api/session-controller/tsconfig.host.json @@ -104,6 +104,9 @@ { "path": "../../typert/protocol" }, + { + "path": "../remote-access/tsconfig.host.json" + }, { "path": "../../typert/registry" }, diff --git a/packages/api/workspace-controller/README.i18n.yaml b/packages/api/workspace-controller/README.i18n.yaml index d804399b83..e3cb08a719 100644 --- a/packages/api/workspace-controller/README.i18n.yaml +++ b/packages/api/workspace-controller/README.i18n.yaml @@ -15,8 +15,8 @@ en: 4e53164159b5ce41 zh: 89582dc0294d0776 /workspace-controller/use-this-package: - en: aa0d93826ab685fe - zh: 144e48d49020f5a4 + en: 8276c5edb7d0db7d + zh: cda0c91f8c3776e6 /workspace-controller/use-this-package/first-use-workspace: en: 61032e060e7213f0 zh: 437eb982a8738539 diff --git a/packages/api/workspace-controller/README.md b/packages/api/workspace-controller/README.md index b24053384f..27c26c7d9b 100644 --- a/packages/api/workspace-controller/README.md +++ b/packages/api/workspace-controller/README.md @@ -24,6 +24,8 @@ English | [中文](README.zh.md) The Host controller serializes mutations whose correctness depends on current registry state and throws `RemoteError` with a stable error code for expected failures. Its `follow()` stream synchronously attaches to durable Workspace changes, emits one complete baseline first, then emits ordered `upsert`, `remove`, `order`, `archived`, and `pinned` increments. Archive and pin sets are Session id arrays, with the most recently pinned id first in the pin array. A reconnect starts another generation with a replacement baseline, so consumers do not depend on receiving every increment while disconnected. `archiveSession` without `stopActivity` refuses a Session with running work as `workspace/session-active`, whose details list that work by family (`turn`, `subagent`, `job`, `schedule`) with item ids and labels; with `stopActivity: true` the registry's providers stop the work first and the response arrives once the archive set is durable, while the stops settle in the background. +The controller injects `remoteAccess` and registers the `workspace` resource kind with the default field `workspaceId` through `typert.resources`. `workspace/follow` reads no resource, so its call touches the `global` resource: a Peer whose principal is not the owner may follow it only when its principal kind's policy in [`dsh-api-remote-access`](../remote-access/README.md) binds a `global` role listing the action. For that caller, the baseline, `upsert` frames, and `order` ids keep only the Workspaces `ctx.remoteAccess.allows()` admits as `workspace` resources, and only the Session ids it admits as `session` resources, within each Workspace and in the archived and pinned sets, so `workspace` and `session` roles list the same action. The stream tracks the Workspaces it delivered. When one is no longer admitted at its next change or in a new order, the stream sends a `replace` increment carrying the complete filtered state instead of a `remove`, because a removed id is never resurrected on the Client; `replace` is an ordinary increment that the Client applies like an opening baseline, not the start of a new generation, so it neither reports an error nor reconnects; once access returns, the Workspace reappears through an ordinary `upsert`. A `remove` frame reports a deletion and reaches only a caller that received that Workspace. Owner frames are unfiltered. + 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. `WorkspaceController.archiveSession(sessionId, { stopActivity })` throws `WorkspaceArchiveError` with the Host's `rpcError`, so a surface can tell the running-work refusal from a missing Session or a carrier fault and offer to stop the work. diff --git a/packages/api/workspace-controller/README.zh.md b/packages/api/workspace-controller/README.zh.md index dc240950cc..4772d0c9b1 100644 --- a/packages/api/workspace-controller/README.zh.md +++ b/packages/api/workspace-controller/README.zh.md @@ -24,6 +24,8 @@ kind: "package-reference" Host 控制器会串行执行正确性取决于当前注册表状态的变更,并为预期失败抛出带有稳定错误码的 `RemoteError`。它的 `follow()` 流会同步订阅持久 Workspace 变更,先发出一份完整 baseline,再按顺序发出 `upsert`、`remove`、`order`、`archived` 和 `pinned` 增量。归档与置顶集合都是会话 id 数组,置顶数组把最近置顶的 id 放在前面。重连会以替换 baseline 开始新一代,因此消费方不依赖收到断线期间的每个增量。不带 `stopActivity` 的 `archiveSession` 会以 `workspace/session-active` 拒绝仍有工作在跑的会话,其 details 按族(`turn`、`subagent`、`job`、`schedule`)列出这些工作及各项的 id 与名称;带 `stopActivity: true` 时注册表的提供方先停止这些工作,归档集合持久化后即返回响应,停止在后台收敛。 +控制器注入 `remoteAccess`,并经 `typert.resources` 以默认字段 `workspaceId` 登记 `workspace` 资源种类。`workspace/follow` 不读取资源,其调用访问 `global` 资源:principal 不是 owner 的 Peer 只有在其 principal kind 于 [`dsh-api-remote-access`](../remote-access/README.zh.md) 中的 policy 绑定了列出该 action 的 `global` role 时才能订阅它。对这类调用方,baseline、`upsert` 帧与 `order` 中的 id 只保留 `ctx.remoteAccess.allows()` 作为 `workspace` 资源允许的 Workspace,以及它作为 `session` 资源允许的会话 id(各 Workspace 内的会话以及归档与置顶集合),因此 `workspace` role 与 `session` role 也列出同一 action。流记录已下发的 Workspace。某个 Workspace 在下一次变化或新顺序中已不被允许时,流发送一个携带完整过滤后状态的 `replace` 增量帧而不是 `remove`,因为 Client 不会复活已移除的 id;`replace` 是普通增量帧,Client 像开场 baseline 一样整体替换状态,但它不开启新的一代,因此既不报告错误也不重连;访问恢复后,该 Workspace 经普通 `upsert` 重新出现。`remove` 帧表示删除,只发给收到过该 Workspace 的调用方。owner 的帧不过滤。 + Client 入口提供 `ClientWorkspaceModel` 和 `createWorkspaceStateStream()`。该模型拥有 Workspace 行、registry 顺序、归档与置顶会话身份、一元变更回显,以及流与一元调用的竞态处理。较新的 Host 行按 `updatedAt` 获胜;已提交的流顺序优先于较旧的一元响应;已经移除的 Workspace id 不会被延迟数据复活。置顶快照仅在会话身份或顺序变化时更新。该包公开与框架无关的快照和订阅,把导航策略与 React 钩子留给 UI owner。`WorkspaceController.archiveSession(sessionId, { stopActivity })` 抛出携带 Host `rpcError` 的 `WorkspaceArchiveError`,界面因此能区分"仍有工作在跑"的拒绝与会话缺失或载体故障,并提议停止这些工作。 diff --git a/packages/api/workspace-controller/package.json b/packages/api/workspace-controller/package.json index ac9d45a3e1..a6105a5c1c 100644 --- a/packages/api/workspace-controller/package.json +++ b/packages/api/workspace-controller/package.json @@ -89,6 +89,7 @@ "devDependencies": { "@deepseek-ai/cordis": "workspace:~", "@deepseek-ai/dsh-api-gateway": "workspace:*", + "@deepseek-ai/dsh-api-remote-access": "workspace:*", "@deepseek-ai/dsh-client-connection": "workspace:*", "@deepseek-ai/dsh-client-store": "workspace:*", "@deepseek-ai/dsh-client-test-runtime": "workspace:*", diff --git a/packages/api/workspace-controller/src/client/index.ts b/packages/api/workspace-controller/src/client/index.ts index c3182102a7..3bbf4670a0 100644 --- a/packages/api/workspace-controller/src/client/index.ts +++ b/packages/api/workspace-controller/src/client/index.ts @@ -98,6 +98,9 @@ export function createWorkspaceStateStream( function acceptIncrement(accept: WorkspaceFollowSink, frame: WorkspaceFollowIncrement): void { switch (frame.type) { + case 'replace': + accept.replaceBaseline(frame.value) + return case 'upsert': accept.upsertView(frame.workspace) return diff --git a/packages/api/workspace-controller/src/feed.ts b/packages/api/workspace-controller/src/feed.ts index 709f17fa5d..be1cbdb21c 100644 --- a/packages/api/workspace-controller/src/feed.ts +++ b/packages/api/workspace-controller/src/feed.ts @@ -15,6 +15,9 @@ import type { WorkspaceView, } from './types.ts' +/** The frames the feed produces: every follow frame except `replace`, which only access filtering sends. */ +type WorkspaceFeedFrame = Exclude + /** * Project one authoritative Workspace entity into its Remote value. * @param workspace - authoritative registry entity. @@ -82,7 +85,7 @@ export class WorkspaceFeed { * @param signal - generation cancellation. * @returns baseline followed by ordered Workspace increments. */ - async *follow(signal: AbortSignal): AsyncIterable { + async *follow(signal: AbortSignal): AsyncIterable { signal.throwIfAborted() const follower = new WorkspaceFollower() this.followers.add(follower) @@ -138,7 +141,7 @@ export class WorkspaceFeed { }) } - private publish(frame: Exclude): void { + private publish(frame: Exclude): void { for (const follower of this.followers) follower.push(frame) } } @@ -148,11 +151,11 @@ function sameStrings(left: readonly string[], right: readonly string[]): boolean } class WorkspaceFollower { - private readonly frames = new Deque() + private readonly frames = new Deque() private waiting: (() => void) | undefined private closed = false - push(frame: WorkspaceFollowFrame): void { + push(frame: WorkspaceFeedFrame): void { /* v8 ignore next -- closed followers are removed before later publication can reach them. */ if (this.closed) return this.frames.pushBack(frame) @@ -165,7 +168,7 @@ class WorkspaceFollower { this.waiting?.() } - async *read(signal: AbortSignal): AsyncIterable { + async *read(signal: AbortSignal): AsyncIterable { while (!this.closed && !signal.aborted) { const frame = this.frames.popFront() if (frame !== undefined) { diff --git a/packages/api/workspace-controller/src/index.ts b/packages/api/workspace-controller/src/index.ts index 0b23517921..f275eb3d12 100644 --- a/packages/api/workspace-controller/src/index.ts +++ b/packages/api/workspace-controller/src/index.ts @@ -3,6 +3,8 @@ import { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' +import type {} from '@deepseek-ai/dsh-api-remote-access' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import { WorkspaceCommands } from './commands.ts' import { DirectoryPickerController } from './directory-picker.ts' import { WorkspaceFeed, workspaceView } from './feed.ts' @@ -10,11 +12,13 @@ import { defaultWorkspaceDirectory, validateDocumentsDirectory } from './default import type { WorkspaceArchiveSessionRequest, WorkspaceArchiveValue, + WorkspaceBaseline, WorkspaceCreateRequest, WorkspaceCreateValue, WorkspaceDeleteRequest, WorkspaceDeleteValue, WorkspaceFollowFrame, + WorkspaceId, WorkspaceInsertBeforeRequest, WorkspaceInsertSessionBeforeRequest, WorkspaceOrderValue, @@ -49,7 +53,7 @@ declare module '@deepseek-ai/cordis' { /** Host service backing the generated `ctx.remote.workspace` namespace. */ export class WorkspaceController extends TypertRemoteService { - static inject = ['typert', 'workspaceRegistry'] + static inject = ['remoteAccess', 'typert', 'workspaceRegistry'] static Config: z = z.object({ documentsDirectory: z.string(), @@ -66,6 +70,7 @@ export class WorkspaceController extends TypertRemoteService { */ constructor(ctx: Context, config: Config = {}) { super(ctx, 'workspaceController', { namespace: 'workspace' }) + ctx.typert.resources.register('workspace', { field: 'workspaceId' }) this.config = WorkspaceController.Config(config) if (this.config.documentsDirectory !== undefined) validateDocumentsDirectory(this.config.documentsDirectory) this.commands = new WorkspaceCommands(ctx) @@ -187,14 +192,77 @@ export class WorkspaceController extends TypertRemoteService { } /** - * Stream a complete Workspace baseline followed by ordered increments. + * Stream a complete Workspace baseline followed by ordered increments. A + * non-owner receives only the Workspaces and Sessions `allows()` admits. + * When a delivered Workspace is no longer admitted at its next change or + * the next order change, the stream sends a `replace` frame with the + * complete filtered state instead of a `remove`, because a Client never + * resurrects a removed Workspace; a `remove` frame reports a deletion and + * reaches only a Client that received that Workspace. * @param signal - generation cancellation. * @returns baseline followed by ordered Workspace increments. */ @Remote({ mode: 'stream' }) - follow(signal: AbortSignal): AsyncIterable { - return this.feed.follow(signal) + async *follow(signal: AbortSignal): AsyncIterable { + const access = this.ctx.remoteAccess + const visible = (workspaceId: WorkspaceId): boolean => access.allows({ kind: 'workspace', id: workspaceId }) + const sessions = (sessionIds: readonly SessionId[]): SessionId[] => + sessionIds.filter(sessionId => access.allows({ kind: 'session', id: sessionId })) + /** Workspaces this generation has delivered and not yet removed. */ + const delivered = new Set() + /** The admitted part of `value`; it becomes the delivered set. */ + const filtered = (value: WorkspaceBaseline): WorkspaceBaseline => { + const items = value.items.filter(item => visible(item.workspaceId)) + delivered.clear() + for (const item of items) delivered.add(item.workspaceId) + return { + items: items.map(item => ({ ...item, sessionIds: sessions(item.sessionIds) })), + archivedSessionIds: sessions(value.archivedSessionIds), + pinnedSessionIds: sessions(value.pinnedSessionIds), + } + } + for await (const frame of this.feed.follow(signal)) { + switch (frame.type) { + case 'baseline': + yield { type: 'baseline', value: filtered(frame.value) } + break + case 'upsert': { + const workspaceId = frame.workspace.workspaceId + if (visible(workspaceId)) { + delivered.add(workspaceId) + yield { type: 'upsert', workspace: { ...frame.workspace, sessionIds: sessions(frame.workspace.sessionIds) } } + } else if (delivered.has(workspaceId)) { + yield { type: 'replace', value: filtered(this.feed.baseline()) } + } + break + } + case 'remove': + if (delivered.delete(frame.workspaceId)) yield frame + break + case 'order': + if (frame.workspaceIds.some(workspaceId => delivered.has(workspaceId) && !visible(workspaceId))) { + yield { type: 'replace', value: filtered(this.feed.baseline()) } + } else { + yield { type: 'order', workspaceIds: frame.workspaceIds.filter(visible) } + } + break + case 'archived': + yield { type: 'archived', archivedSessionIds: sessions(frame.archivedSessionIds) } + break + case 'pinned': + yield { type: 'pinned', pinnedSessionIds: sessions(frame.pinnedSessionIds) } + break + /* v8 ignore next -- closed follow-frame union. */ + default: + assertNever(frame) + } + } } } +/* v8 ignore next 3 -- closed-union backstop. */ +function assertNever(value: never): never { + throw new Error(`unreachable Workspace follow frame: ${JSON.stringify(value)}`) +} + export default WorkspaceController diff --git a/packages/api/workspace-controller/src/types.ts b/packages/api/workspace-controller/src/types.ts index d5b98746be..cdf9adf073 100644 --- a/packages/api/workspace-controller/src/types.ts +++ b/packages/api/workspace-controller/src/types.ts @@ -157,8 +157,13 @@ export interface WorkspaceBaseline { readonly pinnedSessionIds: readonly SessionId[] } -/** One ordered Workspace change after a generation's baseline. */ +/** + * One ordered Workspace change after a generation's baseline. `replace` + * carries the complete state that supersedes everything delivered before it + * in the generation; it is a change, not a new generation's opening frame. + */ export type WorkspaceFollowIncrement = + | { readonly type: 'replace'; readonly value: WorkspaceBaseline } | { readonly type: 'upsert'; readonly workspace: WorkspaceView } | { readonly type: 'remove'; readonly workspaceId: WorkspaceId } | { readonly type: 'order'; readonly workspaceIds: readonly WorkspaceId[] } diff --git a/packages/api/workspace-controller/tests/transport.client.spec.ts b/packages/api/workspace-controller/tests/transport.client.spec.ts index b74ddfb394..b1d6ca2a9b 100644 --- a/packages/api/workspace-controller/tests/transport.client.spec.ts +++ b/packages/api/workspace-controller/tests/transport.client.spec.ts @@ -183,6 +183,38 @@ describe('Workspace state stream', () => { expect(streamStates(mock)).toEqual(['cancelled']) }) + it('applies a replace increment in place, so a Workspace hidden and shown again never errors or reconnects', async ({ mock, start }) => { + const { remote } = await gatewayClient(mock, start) + mock.stream(FOLLOW, openStream([baseline('one', 'two')])) + const model = new ClientWorkspaceModel(remote.workspace) + const carrierFailed = vi.fn(() => { model.handleCarrierFailure() }) + const failed = vi.fn((error: unknown) => { model.handleStreamFailure(error) }) + const stream = createWorkspaceStateStream(remote, { accept: model, carrierFailed, failed }) + const states = new Set() + model.subscribe(() => { states.add(model.getSnapshot().state) }) + const ids = (): string[] => model.getSnapshot().items.map(item => item.workspaceId).sort() + const replace = (...workspaceIds: string[]): WorkspaceFollowFrame => ({ type: 'replace', value: baseline(...workspaceIds).value }) + + stream.start() + await vi.waitFor(() => { expect(ids()).toEqual(['one', 'two']) }) + // Access to `two` is revoked, restored through a replace, revoked again, and restored through an upsert. + mock.streams.push(FOLLOW, replace('one')) + await vi.waitFor(() => { expect(ids()).toEqual(['one']) }) + mock.streams.push(FOLLOW, replace('one', 'two')) + await vi.waitFor(() => { expect(ids()).toEqual(['one', 'two']) }) + mock.streams.push(FOLLOW, replace('one')) + await vi.waitFor(() => { expect(ids()).toEqual(['one']) }) + mock.streams.push(FOLLOW, { type: 'upsert', workspace: workspace('two') }) + await vi.waitFor(() => { expect(ids()).toEqual(['one', 'two']) }) + + expect(states).not.toContain('error') + expect(model.getSnapshot()).toMatchObject({ phase: 'ready', state: 'idle', error: null }) + expect(carrierFailed).not.toHaveBeenCalled() + expect(failed).not.toHaveBeenCalled() + expect(streamStates(mock)).toEqual(['open']) + await stream.dispose() + }) + it('retains the old state across carrier loss and applies the replacement baseline', async ({ mock, start }) => { const { remote } = await gatewayClient(mock, start) const carrier = new RemoteStreamCarrierError('socket lost') 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 35158490f2..f284f35155 100644 --- a/packages/api/workspace-controller/tests/workspace-controller.host.spec.ts +++ b/packages/api/workspace-controller/tests/workspace-controller.host.spec.ts @@ -6,9 +6,10 @@ import { Context } from '@deepseek-ai/cordis' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import Storage from '@deepseek-ai/dsh-storage' import { DomainFacility } from '@deepseek-ai/dsh-storage-domain' -import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' +import { RemoteError, type PeerId, type Principal, type RemoteInvocation } from '@deepseek-ai/dsh-typert-protocol' import WorkspaceRegistry from '@deepseek-ai/dsh-workspace' import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' +import RemoteAccessService, { type RemoteAccessCondition, type RemoteAccessRole } from '@deepseek-ai/dsh-api-remote-access' import WorkspaceController from '../src/index.ts' import { DEFAULT_WORKSPACE_DIRECTORY } from '../src/default-workspace.ts' import { WorkspaceFeed } from '../src/feed.ts' @@ -67,11 +68,39 @@ async function harness(options: { systemDocuments?: boolean } = {}) { ctx.provide('typert', { lookups: { configure: () => dispose }, contexts: { configureHost: () => dispose }, + resources: { register: vi.fn(() => dispose) }, } as never) + new RemoteAccessService(ctx) const controller = new WorkspaceController(ctx, options.systemDocuments === true ? {} : { documentsDirectory: root }) return { controller, ctx, root, storageDomain } } +declare module '@deepseek-ai/dsh-typert-protocol' { + interface PrincipalMap { + /** Test-only non-owner caller shared by the access specs. */ + 'test-guest': { readonly kind: 'test-guest'; readonly guest: string } + } +} + +/** Opens the Workspace follow stream; each Workspace and Session in it still needs its own role. */ +const FOLLOWER: RemoteAccessRole<'global'> = { name: 'test:workspace-follower', resource: 'global', actions: ['workspace/follow'] } +/** Shows one Workspace in the follow stream. */ +const WORKSPACE_VIEWER: RemoteAccessRole<'workspace'> = { name: 'test:workspace-viewer', resource: 'workspace', actions: ['workspace/follow'] } +/** Shows one Session id in the follow stream. */ +const SESSION_VIEWER: RemoteAccessRole<'session'> = { name: 'test:session-viewer', resource: 'session', actions: ['workspace/follow'] } + +/** The Context a Gateway dispatch of `workspace/follow` binds for `principal`. */ +function followerContext(ctx: Context, principal: Principal): Context { + const invocation: RemoteInvocation = { + request: { namespace: 'workspace', method: 'follow', args: {} }, + service: 'workspaceController', + peer: { id: 'workspace-access-peer' as PeerId, ctx, principal, signal: new AbortController().signal, dispose: async () => {} }, + signal: new AbortController().signal, + uplink: (): AsyncIterable => ({ [Symbol.asyncIterator]: () => ({ next: async () => ({ done: true, value: undefined }) }) }), + } + return ctx.extend({ invocation }) +} + function stageDir(root: string, name: string): string { const path = join(root, name) mkdirSync(path, { recursive: true }) @@ -416,6 +445,110 @@ describe('WorkspaceController follow', () => { await expect(iterator.next()).resolves.toEqual({ done: true, value: undefined }) }) + it('follows only the Workspaces and Sessions a non-owner may follow', async () => { + const { controller, ctx, root } = await harness() + // oxlint-disable-next-line typescript/unbound-method -- the assertion inspects the spy and never calls it. + expect(ctx.typert.resources.register).toHaveBeenCalledWith('workspace', { field: 'workspaceId' }) + const shown = await controller.create({ path: stageDir(root, 'shown') }) + const hiddenWorkspace = await controller.create({ path: stageDir(root, 'hidden') }) + const shownId = shown.workspace.workspaceId + const hiddenId = hiddenWorkspace.workspace.workspaceId + const shared = ctx.sessions.create(SessionId('shared'), { meta: { cwd: shown.workspace.path } }) + const secret = ctx.sessions.create(SessionId('secret'), { meta: { cwd: shown.workspace.path } }) + const workspace = ctx.workspaceRegistry.get(shownId) + if (workspace === undefined) throw new Error('fixture Workspace disappeared') + await workspace.attachSession(shared.id) + await workspace.attachSession(secret.id) + await controller.pinSession({ sessionId: shared.id }) + await controller.pinSession({ sessionId: secret.id }) + ctx.remoteAccess.registerPolicy('test-guest', { + bindings: () => [ + { role: FOLLOWER, resource: { kind: 'global' } }, + { role: WORKSPACE_VIEWER, resource: { kind: 'workspace', id: shownId } }, + { role: SESSION_VIEWER, resource: { kind: 'session', id: shared.id } }, + ], + }) + const guest: Principal = { kind: 'test-guest', guest: 'workspace-controller' } + const abort = new AbortController() + const iterator = followerContext(ctx, guest).workspaceController.follow(abort.signal)[Symbol.asyncIterator]() + await expect(nextFrame(iterator)).resolves.toEqual({ + type: 'baseline', + value: { + items: [expect.objectContaining({ workspaceId: shownId, sessionIds: [shared.id] })], + archivedSessionIds: [], + pinnedSessionIds: [shared.id], + }, + }) + + await controller.rename({ workspaceId: hiddenId, title: 'still hidden' }) + await controller.rename({ workspaceId: shownId, title: 'renamed' }) + await expect(nextFrame(iterator)).resolves.toMatchObject({ + type: 'upsert', workspace: { workspaceId: shownId, title: 'renamed', sessionIds: [shared.id] }, + }) + await controller.unpinSession({ sessionId: secret.id }) + await expect(nextFrame(iterator)).resolves.toEqual({ type: 'pinned', pinnedSessionIds: [shared.id] }) + await controller.unpinSession({ sessionId: shared.id }) + await controller.archiveSession({ sessionId: secret.id }) + await controller.archiveSession({ sessionId: shared.id }) + await expect(nextFrame(iterator)).resolves.toEqual({ type: 'pinned', pinnedSessionIds: [] }) + await expect(nextFrame(iterator)).resolves.toEqual({ type: 'archived', archivedSessionIds: [] }) + await expect(nextFrame(iterator)).resolves.toEqual({ type: 'archived', archivedSessionIds: [shared.id] }) + await controller.delete({ workspaceId: hiddenId }) + await expect(nextFrame(iterator)).resolves.toEqual({ type: 'order', workspaceIds: [shownId] }) + await controller.delete({ workspaceId: shownId }) + await expect(nextFrame(iterator)).resolves.toEqual({ type: 'order', workspaceIds: [] }) + await expect(nextFrame(iterator)).resolves.toEqual({ type: 'remove', workspaceId: shownId }) + + abort.abort() + await expect(iterator.next()).resolves.toEqual({ done: true, value: undefined }) + }) + + it('sends a non-owner a replace increment when a delivered Workspace loses visibility, so it can reappear', async () => { + const { controller, ctx, root } = await harness() + const first = (await controller.create({ path: stageDir(root, 'first') })).workspace.workspaceId + const second = (await controller.create({ path: stageDir(root, 'second') })).workspace.workspaceId + // One Remote call reads its bindings once, so visibility changes during the stream come from the condition. + const allowedWorkspaces = new Set([first, second]) + const stillShared: RemoteAccessCondition<'workspace'> = ({ resource }) => + allowedWorkspaces.has(resource.id) ? { kind: 'allow' } : { kind: 'deny', reason: 'test:unshared' } + ctx.remoteAccess.registerPolicy('test-guest', { + bindings: () => [ + { role: FOLLOWER, resource: { kind: 'global' } }, + ...[first, second].map(id => ({ role: WORKSPACE_VIEWER, resource: { kind: 'workspace', id } as const, condition: stillShared })), + ], + }) + const abort = new AbortController() + const iterator = followerContext(ctx, { kind: 'test-guest', guest: 'withdrawn' }).workspaceController.follow(abort.signal)[Symbol.asyncIterator]() + const ids = (frame: WorkspaceFollowFrame): string[] => + frame.type === 'baseline' || frame.type === 'replace' ? frame.value.items.map(item => item.workspaceId).sort() : [] + const opening = await nextFrame(iterator) + expect(opening.type).toBe('baseline') + expect(ids(opening)).toEqual([first, second].sort()) + + // Revoked: the next change replaces the state instead of sending a `remove` the Client would never undo. + allowedWorkspaces.delete(first) + await controller.rename({ workspaceId: first, title: 'revoked' }) + const withoutFirst = await nextFrame(iterator) + expect(withoutFirst.type).toBe('replace') + expect(ids(withoutFirst)).toEqual([second]) + allowedWorkspaces.delete(second) + const third = (await controller.create({ path: stageDir(root, 'third') })).workspace.workspaceId + const empty = await nextFrame(iterator) + expect(empty.type).toBe('replace') + expect(ids(empty)).toEqual([]) + // A deletion reaches only a Client that received the Workspace. + await controller.delete({ workspaceId: third }) + await expect(nextFrame(iterator)).resolves.toEqual({ type: 'order', workspaceIds: [] }) + + // Restored: the Workspace reappears through an ordinary upsert. + allowedWorkspaces.add(first) + await controller.rename({ workspaceId: first, title: 'restored' }) + await expect(nextFrame(iterator)).resolves.toMatchObject({ type: 'upsert', workspace: { workspaceId: first, title: 'restored' } }) + + abort.abort() + await expect(iterator.next()).resolves.toEqual({ done: true, value: undefined }) + }) + it('ignores unrelated domain writes and closes active followers on disposal', async () => { const { controller, ctx, root } = await harness() const abort = new AbortController() diff --git a/packages/api/workspace-controller/tsconfig.host.json b/packages/api/workspace-controller/tsconfig.host.json index ff1ca31080..e9d5104e98 100644 --- a/packages/api/workspace-controller/tsconfig.host.json +++ b/packages/api/workspace-controller/tsconfig.host.json @@ -21,6 +21,7 @@ { "path": "../../host/directory-picker" }, { "path": "../../storage/storage-domain" }, { "path": "../../typert/protocol" }, + { "path": "../remote-access/tsconfig.host.json" }, { "path": "../../util/deque" }, { "path": "../../util/native-command" }, { "path": "../../workspace/workspace" } diff --git a/packages/goal/goal/tests/goal.spec.ts b/packages/goal/goal/tests/goal.spec.ts index fe9c2d0030..5bcdcf1fcb 100644 --- a/packages/goal/goal/tests/goal.spec.ts +++ b/packages/goal/goal/tests/goal.spec.ts @@ -14,6 +14,7 @@ import GoalService, { } from '@deepseek-ai/dsh-goal' import type { GoalChangeMeta, GoalRef, GoalSnapshotChangeMeta } from '@deepseek-ai/dsh-goal' import { createInboxStub } from '@deepseek-ai/dsh-agent-loop-testkit' +import { remoteMethods } from '@deepseek-ai/dsh-typert-protocol' declare module '@deepseek-ai/dsh-llm' { interface MessageSourceMap { @@ -181,6 +182,16 @@ describe('GoalService creation and replay', () => { }) }) + it('declares no resource readers on goals/get, leaving its Session resource to the Agent lookup', async () => { + const ctx = new Context() + await ctx.plugin(AgentRegistry) + await ctx.plugin(SessionProjectionRegistry) + const goals = new GoalService(ctx) + const marker = remoteMethods(goals).find(method => method.method === 'get') + expect(marker).toMatchObject({ method: 'get' }) + expect(marker?.resources).toBeUndefined() + }) + it('rejects invalid direct configuration', async () => { const ctx = new Context() await ctx.plugin(AgentRegistry) From b967475c66fe864f931624e742a8e0ff984cdd03 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:47:03 +0800 Subject: [PATCH 20/35] feat(client-ui): add remote access hooks --- .../locale/tests/language-row.client.spec.tsx | 3 +- .../create-plugin-menu-item.client.spec.tsx | 3 +- .../tests/section.client.spec.tsx | 3 +- .../src/client/chat/TurnTailNodeView.tsx | 5 +- .../ui-chat/src/client/contract/slots.ts | 2 + .../ui-chat/tests/chat-view.client.spec.tsx | 14 ++++- .../tests/transcript-view-row.client.spec.tsx | 3 +- .../tests/enter-behavior-row.client.spec.tsx | 3 +- .../tests/input-bar.client.spec.tsx | 3 +- .../tests/input-matrix.client.spec.tsx | 3 +- .../tests/input-scenarios.client.spec.tsx | 3 +- .../tests/queue-dock.client.spec.tsx | 3 +- .../tests/skeleton.client.spec.tsx | 7 ++- .../ui-layout/tests/app-frame.client.spec.tsx | 3 +- .../permission-presets-row.client.spec.tsx | 3 +- .../tests/browser-plugin.client.spec.tsx | 4 +- .../tests/components.client.spec.tsx | 3 +- .../ui-renderer/src/client/bindings.tsx | 56 ++++++++++++----- .../tests/keyed-hook-member.client.spec.tsx | 61 +++++++++++++++++++ .../tests/task-detail.client.spec.tsx | 3 +- .../tests/task-manager-page.client.spec.tsx | 4 +- packages/client/ui-session/README.i18n.yaml | 7 ++- packages/client/ui-session/README.md | 6 ++ packages/client/ui-session/README.zh.md | 6 ++ packages/client/ui-session/package.json | 2 + .../client/ui-session/src/client/index.ts | 20 +++++- .../tests/ui-session.client.spec.ts | 26 +++++++- packages/client/ui-session/tsconfig.json | 3 + .../tests/onboarding.client.spec.tsx | 3 +- .../tests/components.client.spec.tsx | 3 +- .../tests/settings-root.client.spec.tsx | 3 +- .../tests/onboarding-dialog.client.spec.tsx | 3 +- .../tests/welcome-notice.client.spec.tsx | 3 +- .../tests/expand-button.client.spec.tsx | 2 + .../tests/guide-body.client.spec.tsx | 3 +- .../tests/cleanup.client.spec.tsx | 3 +- .../tests/terminal-guide.client.spec.tsx | 3 +- .../tests/pointer-scrollbars.client.spec.tsx | 2 + .../tests/sidebar-root.client.spec.tsx | 8 ++- packages/client/ui-slots/README.i18n.yaml | 4 +- packages/client/ui-slots/README.md | 2 + packages/client/ui-slots/README.zh.md | 2 + packages/client/ui-slots/src/renderer.ts | 14 ++++- .../tests/conversation-ui.client.spec.tsx | 3 +- .../tests/appearance-row.client.spec.tsx | 3 +- .../tests/font-family-row.client.spec.tsx | 3 +- .../tests/font-settings-group.client.spec.tsx | 3 +- .../tests/font-size-row.client.spec.tsx | 3 +- .../ui-trajectory/tests/views.client.spec.tsx | 4 +- .../tests/plan-review-panel.client.spec.tsx | 2 + .../user-questions-composer.client.spec.tsx | 2 + .../tests/workflow-run.client.spec.tsx | 3 +- packages/client/ui-workspace/README.i18n.yaml | 8 +-- packages/client/ui-workspace/README.md | 4 +- packages/client/ui-workspace/README.zh.md | 4 +- packages/client/ui-workspace/package.json | 2 + .../client/ui-workspace/src/client/index.ts | 16 ++++- .../client/session-actions/ForkSession.tsx | 11 ++-- .../ui-workspace/tests/apply.client.spec.ts | 22 ++++++- .../tests/session-actions.client.spec.tsx | 13 +++- .../tests/workspace-browser.client.spec.tsx | 3 +- .../tests/workspace-picker.client.spec.tsx | 5 +- packages/client/ui-workspace/tsconfig.json | 3 + .../client-runtime/README.i18n.yaml | 12 ++-- .../test-support/client-runtime/README.md | 5 +- .../test-support/client-runtime/README.zh.md | 5 +- .../test-support/client-runtime/package.json | 2 + .../test-support/client-runtime/src/access.ts | 53 ++++++++++++++++ .../src/assembly/test-client.ts | 30 ++++++--- .../test-support/client-runtime/src/index.ts | 4 ++ .../assembly-test-client-node.client.spec.ts | 20 ++++-- .../tests/helpers.client.spec.tsx | 22 +++++++ .../test-support/client-runtime/tsconfig.json | 6 ++ 73 files changed, 498 insertions(+), 98 deletions(-) create mode 100644 packages/client/ui-renderer/tests/keyed-hook-member.client.spec.tsx create mode 100644 packages/test-support/client-runtime/src/access.ts diff --git a/packages/client/locale/tests/language-row.client.spec.tsx b/packages/client/locale/tests/language-row.client.spec.tsx index 776542c0f2..ecf23dd6e9 100644 --- a/packages/client/locale/tests/language-row.client.spec.tsx +++ b/packages/client/locale/tests/language-row.client.spec.tsx @@ -5,7 +5,7 @@ import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' -import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' +import { allowAllAccess, bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' import { LanguageRow } from '../src/client/LanguageRow.tsx' import type { LanguageRowComponentProps } from '../src/client/LanguageRow.tsx' import { createLanguageRowStore } from '../src/client/settings-store.ts' @@ -44,6 +44,7 @@ function mount(active = 'en') { useSessionStatus, usePanelInfo, useSessionRetainInfo: () => undefined, useResource, useWorkspaces: emptyWorkspaces(), + ...allowAllAccess, useStore: bindSnapshotSelector(store), actions: store.actions, t: (key: string) => key === 'language.title' ? 'Language' : key, diff --git a/packages/client/ui-agent-preset/tests/create-plugin-menu-item.client.spec.tsx b/packages/client/ui-agent-preset/tests/create-plugin-menu-item.client.spec.tsx index d1ecedaa66..578234c898 100644 --- a/packages/client/ui-agent-preset/tests/create-plugin-menu-item.client.spec.tsx +++ b/packages/client/ui-agent-preset/tests/create-plugin-menu-item.client.spec.tsx @@ -2,7 +2,7 @@ import { useState, type ComponentProps } from 'react' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' -import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' +import { allowAllAccess, bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { Menu, MenuItemButton } from '@deepseek-ai/dsh-client-ui-primitives' import { CreatePluginMenuItem } from '../src/client/CreatePluginMenuItem.tsx' @@ -29,6 +29,7 @@ function view(roster: Partial = {}, inMenu = false) { useAgentPresets: bindSnapshotSelector(agentPresets), usePanelInfo: unusedHook, useSessions: unusedHook, useSessionStatus: unusedHook, useSessionRetainInfo: unusedHook, useWorkspaces: unusedHook, useResource: unusedHook, + ...allowAllAccess, load, startCreatorDraft, onDismiss, } function TestMenu() { 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 6505bceb7f..78a4f599e3 100644 --- a/packages/client/ui-agent-preset/tests/section.client.spec.tsx +++ b/packages/client/ui-agent-preset/tests/section.client.spec.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import { afterEach, expect, it, vi } from 'vitest' -import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' +import { allowAllAccess, bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { Modal } from '@deepseek-ai/dsh-client-ui-primitives' import { AgentPresetSection, type AgentPresetSectionProps } from '../src/client/AgentPresetSection.tsx' @@ -23,6 +23,7 @@ function view(partial: Partial = {}, startCreatorDraft? ...(startCreatorDraft === undefined ? {} : { startCreatorDraft }), usePanelInfo: unusedHook, useSessions: unusedHook, useSessionStatus: unusedHook, useSessionRetainInfo: unusedHook, useWorkspaces: unusedHook, useResource: unusedHook, + ...allowAllAccess, useAgentPresetSection: bindSnapshotSelector(store), useDeveloperTools: bindSnapshotSelector(developerTools), t: key => translations.get(key) ?? key } diff --git a/packages/client/ui-chat/src/client/chat/TurnTailNodeView.tsx b/packages/client/ui-chat/src/client/chat/TurnTailNodeView.tsx index a3d58a8cfe..c29b831ce8 100644 --- a/packages/client/ui-chat/src/client/chat/TurnTailNodeView.tsx +++ b/packages/client/ui-chat/src/client/chat/TurnTailNodeView.tsx @@ -26,9 +26,10 @@ function lastContent(snapshot: ChatSnapshot, turn: number, skipWarning: boolean) /** Turn-local actions and feature tail over the Location index, independent of Assistant placement. */ export const TurnTailNodeView = memo(function TurnTailNodeView({ - node, openFile, forkAt, renderSlot, t, useChat, usePerformanceUsage, + node, openFile, forkAt, renderSlot, t, useAccess, useChat, usePerformanceUsage, }: TurnTailNodeViewProps) { const detailed = usePerformanceUsage(mode => mode) === 'detailed' + const forkAllowed = useAccess('session/fork') const data = node.data const hasLaterChatNode = useChat(snapshot => (lastContent(snapshot, data.turn, true)?.anchorSeq ?? -1) > (data.closing?.finalNode.seq ?? data.seq)) @@ -66,7 +67,7 @@ export const TurnTailNodeView = memo(function TurnTailNodeView({ clock="end" // The branch action owns boundary resolution: it sends the real // turn/end seq it already has, and the Host cuts exactly there. - onBranch={() => { forkAt(data.seq) }} + onBranch={forkAllowed ? () => { forkAt(data.seq) } : undefined} branchUnavailable={data.branchUnavailable || hasLaterChatNode} className={css.actions} extraActions={assistantActions} diff --git a/packages/client/ui-chat/src/client/contract/slots.ts b/packages/client/ui-chat/src/client/contract/slots.ts index 9bce65619b..a85e28798d 100644 --- a/packages/client/ui-chat/src/client/contract/slots.ts +++ b/packages/client/ui-chat/src/client/contract/slots.ts @@ -17,6 +17,8 @@ import type { import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import type { MarkdownFileMentions, MarkdownLabels } from '@deepseek-ai/dsh-client-ui-primitives' import type {} from '@deepseek-ai/dsh-client-ui-layout/client' +// Type-only: the Session-scope access Hook (`useAccess`) the Chat entries read. +import type {} from '@deepseek-ai/dsh-client-ui-session/client' import type { createChatStore } from '../stores.ts' import type { ChatPresentationPolicy } from '../presentation-policy.ts' import type { FlowMotionRows } from '../chat/flow-motion.ts' diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx index e334bbc039..e97d0e56dc 100644 --- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx @@ -27,7 +27,7 @@ import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controlle import { SessionSeq, type SessionId } from '@deepseek-ai/dsh-session/types' import type { SessionStatusSnapshot } from '@deepseek-ai/dsh-client-ui-session/client' import type { KeyedSnapshotSelectorHook, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' -import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { allowAllAccess, bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { createSnapshotStore, type ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import { EMPTY_CONVERSATION_SNAPSHOT } from '@deepseek-ai/dsh-client-ui-conversation/client' import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts' @@ -479,6 +479,7 @@ function makeHarness( createSnapshotStore(new Map()), ), useWorkspaces: emptyWorkspaces(), + ...allowAllAccess, useProjection: (key: string) => { const inbox = useTestSession(snapshot => snapshot.testInbox) return key === 'inbox' ? inbox : outlineValue @@ -3376,6 +3377,17 @@ describe('ChatView', () => { expect(h.forkAt).not.toHaveBeenCalled() }) + it('omits the Turn Tail branch action when the Session access snapshot excludes session/fork', () => { + const h = makeHarness({ nodes: [user(1, 'question'), assistant(2, 'answer')], turnEnds: new Map([[1, 3]]) }) + const useAccess = vi.fn((endpoint: string) => endpoint !== 'session/fork') + // Chat Node renderers read the harness's standard props object, so the override goes there. + Object.assign(h.props, { useAccess }) + const view = render() + expect(view.getAllByRole('button', { name: '复制' }).length).toBeGreaterThan(0) + expect(view.queryByRole('button', { name: '在新对话中分支' })).toBeNull() + expect(useAccess).toHaveBeenCalledWith('session/fork') + }) + it('renders assistant Markdown across history, streaming, final, and interrupted states while user text stays literal', () => { const markdown = '# Rendered\n\n- **one**\n- `two`' const h = makeHarness({ nodes: [user(1, markdown), assistant(2, markdown)] }) diff --git a/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx b/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx index d195d284e2..b8f3b5abd0 100644 --- a/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx +++ b/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx @@ -6,7 +6,7 @@ import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controlle import type { SessionStatusSnapshot } from '@deepseek-ai/dsh-client-ui-session/client' import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' -import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { allowAllAccess, bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { TranscriptViewRow, type TranscriptViewRowProps } from '../src/client/settings/TranscriptViewRow.tsx' import { PerformanceUsageRow } from '../src/client/settings/PerformanceUsageRow.tsx' import type { LinkOpening, PerformanceUsageMode, TranscriptViewMode } from '../src/chat-settings.ts' @@ -47,6 +47,7 @@ function mount(mode: TranscriptViewMode = 'standard', dictionary: typeof en | ty useWorkspaces: emptyWorkspaces(), useSessionRetainInfo: () => undefined, useResource, + ...allowAllAccess, useTranscriptView: bindSnapshotSelector(source), useCollapseTiming: bindSnapshotSelector(collapseTiming), setTranscriptView, diff --git a/packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx b/packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx index 3d1b833896..f4b80c891c 100644 --- a/packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx +++ b/packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx @@ -2,7 +2,7 @@ import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots' import { afterEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' -import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { allowAllAccess, bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client' import type { SessionStatusSnapshot } from '@deepseek-ai/dsh-client-ui-session/client' @@ -46,6 +46,7 @@ function mount() { useSessionRetainInfo: () => undefined, useResource, useWorkspaces: emptyWorkspaces(), + ...allowAllAccess, useBusyEnter: bindSnapshotSelector(policy.busyEnter), setBusyEnter, t: makeTranslate(en), 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 17b9b87282..b18486372d 100644 --- a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx @@ -16,7 +16,7 @@ import { act, cleanup, fireEvent, render } from '@testing-library/react' import { createPortal } from 'react-dom' import { $getRoot, $isTextNode } from 'lexical' import { - bindSnapshotSelector, conversationSnapshot as conversationFixture, makeTranslate, RemoteError, + allowAllAccess, bindSnapshotSelector, conversationSnapshot as conversationFixture, makeTranslate, RemoteError, sessionSnapshot as sessionFixture, } from '@deepseek-ai/dsh-client-test-runtime' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' @@ -191,6 +191,7 @@ function bench(over?: BenchOptions) { useWorkspaces: bindSnapshotSelector(createSnapshotStore({ items: [], archivedSessionIds: [], pinnedSessionIds: [], state: 'idle', phase: 'ready', error: null, })), + ...allowAllAccess, useProjection: ((key: string, selector?: (v: unknown) => unknown) => (selector ?? (v => v))(key === 'plan' ? over?.plan 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 e7c997cb61..f1aba8e4e7 100644 --- a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx @@ -13,7 +13,7 @@ import type { Context } from '@deepseek-ai/cordis' import type { SessionSnapshot } from '@deepseek-ai/dsh-api-session-controller/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { - bindSnapshotSelector, conversationSnapshot, sessionSnapshot, + allowAllAccess, bindSnapshotSelector, conversationSnapshot, sessionSnapshot, } from '@deepseek-ai/dsh-client-test-runtime' import type { SessionStatusSnapshot } from '@deepseek-ai/dsh-client-ui-session/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' @@ -66,6 +66,7 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled items: [], archivedSessionIds: [], pinnedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, })), + ...allowAllAccess, useProjection: (() => undefined), useConversation: bindSnapshotSelector(createSnapshotStore(conversationSnapshot())), useInput: bindSnapshotSelector(shell.state), 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 ca03589a52..bb089654dd 100644 --- a/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx @@ -23,7 +23,7 @@ import type { CommandClaim, PickOutcome, SubmitAttachment, SubmitOutcome, } from '../src/client/contract/input.ts' import { - bindSnapshotSelector, conversationSnapshot, makeTranslate, sessionSnapshot, SlotTestRuntime, + allowAllAccess, bindSnapshotSelector, conversationSnapshot, makeTranslate, sessionSnapshot, SlotTestRuntime, } from '@deepseek-ai/dsh-client-test-runtime' import type { SessionStatusSnapshot } from '@deepseek-ai/dsh-client-ui-session/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' @@ -165,6 +165,7 @@ async function scopedBench(register?: (inputTriggers: InputTriggerService) => vo items: [], archivedSessionIds: [], pinnedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, })), + ...allowAllAccess, useProjection: (() => undefined), useConversation: bindSnapshotSelector(createSnapshotStore(conversationSnapshot())), useInput: bindSnapshotSelector(shell.state), diff --git a/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx b/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx index be31f70fb4..ec93676ff5 100644 --- a/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx +++ b/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx @@ -17,7 +17,7 @@ import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { - bindSnapshotSelector, conversationSnapshot, makeTranslate, + allowAllAccess, bindSnapshotSelector, conversationSnapshot, makeTranslate, } from '@deepseek-ai/dsh-client-test-runtime' import type { SessionStatusSnapshot } from '@deepseek-ai/dsh-client-ui-session/client' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' @@ -117,6 +117,7 @@ function kitFor(snapshot: SessionSnapshot, injected: Partial createSnapshotStore(new Map()), ), useWorkspaces: (() => { throw new Error('unused') }) as never, + ...allowAllAccess, useProjection: (() => undefined) as never, useConversation: bindSnapshotSelector(createSnapshotStore(conversationSnapshot())), useChat: (() => { throw new Error('unused') }) as QueueDockProps['useChat'], diff --git a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx index 7f0225ff33..00a784ac96 100644 --- a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx @@ -8,7 +8,7 @@ import type { SessionListState, SessionSnapshot } from '@deepseek-ai/dsh-api-ses import type { WorkspaceSnapshot, WorkspaceView } from '@deepseek-ai/dsh-api-workspace-controller/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { - bindSnapshotSelector, makeTranslate, RemoteError, sessionSnapshot as sessionFixture, + allowAllAccess, bindSnapshotSelector, makeTranslate, RemoteError, sessionSnapshot as sessionFixture, } from '@deepseek-ai/dsh-client-test-runtime' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { SessionStatusSnapshot } from '@deepseek-ai/dsh-client-ui-session/client' @@ -223,6 +223,7 @@ function mount( useSessionStatus={useSessionStatus} useSessionRetainInfo={() => undefined} useWorkspaces={props.useWorkspaces} + {...allowAllAccess} useProjection={(() => undefined)} useInput={useInput} inputActions={inputActions} @@ -252,6 +253,7 @@ function mount( useSessionStatus={useSessionStatus} useSessionRetainInfo={() => undefined} useWorkspaces={props.useWorkspaces} + {...allowAllAccess} useProjection={(() => undefined)} useInput={useInput} inputActions={inputActions} @@ -279,6 +281,7 @@ function mount( useSessionStatus={useSessionStatus} useSessionRetainInfo={() => undefined} useWorkspaces={props.useWorkspaces} + {...allowAllAccess} useProjection={(() => undefined)} useInput={useInput} inputActions={inputActions} @@ -337,6 +340,7 @@ function mount( useSessionStatus, useSessionRetainInfo: () => undefined, useWorkspaces: bindSnapshotSelector(workspaces), + ...allowAllAccess, useProjection: (() => undefined), useComposerBlock: select => select(options.composerBlock), useInput, @@ -367,6 +371,7 @@ function mount( useSessionRetainInfo: () => undefined, useResource, useWorkspaces: bindSnapshotSelector(workspaces), + ...allowAllAccess, useProjection: (() => undefined), useInput, inputActions, diff --git a/packages/client/ui-layout/tests/app-frame.client.spec.tsx b/packages/client/ui-layout/tests/app-frame.client.spec.tsx index f82cec372a..1dc7cc85b8 100644 --- a/packages/client/ui-layout/tests/app-frame.client.spec.tsx +++ b/packages/client/ui-layout/tests/app-frame.client.spec.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom /** Frame interactions with a real store and explicitly driven browser measurements. */ import type { GlobalStandardProps, RenderOpts } from '@deepseek-ai/dsh-client-ui-slots' -import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' +import { allowAllAccess, bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, render } from '@testing-library/react' import { AppFrame } from '../src/client/AppFrame.tsx' @@ -99,6 +99,7 @@ function mountFrame(windowWidth = frameWidth) { useSessionRetainInfo={() => undefined} useResource={useResource} useWorkspaces={sel => sel(workspaceState)} + {...allowAllAccess} t={key => key === 'brand.localBuild' ? 'DSH Local Build' : key} /> ) diff --git a/packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx b/packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx index aec73b8415..160bd49d80 100644 --- a/packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx +++ b/packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx @@ -3,7 +3,7 @@ import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' -import { bindSnapshotSelector, RemoteError } from '@deepseek-ai/dsh-client-test-runtime' +import { allowAllAccess, bindSnapshotSelector, RemoteError } from '@deepseek-ai/dsh-client-test-runtime' import type { SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client' import { PermissionRow, type PermissionRowProps } from '../src/client/PermissionRow.tsx' import { zh } from '../src/client/locales.ts' @@ -63,6 +63,7 @@ const runtime = { useSessionStatus, usePanelInfo, useSessionRetainInfo: () => undefined, useResource, useWorkspaces: (() => { throw new Error('unused') }) as never, + ...allowAllAccess, } function mount(controller: PermissionPresetSettingsController) { diff --git a/packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx b/packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx index 8dbe936512..dbce0d098f 100644 --- a/packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx +++ b/packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx @@ -8,7 +8,7 @@ import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import type { ILayout, PanelInfo } from '@deepseek-ai/dsh-client-ui-layout/client' import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' -import { TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' +import { allowAllAccess, TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' import * as settings from '@deepseek-ai/dsh-client-ui-settings/client' import { apply, inject, NS, PANEL_ID } from '../src/client/index.ts' import { PluginManagerPage } from '../src/client/PluginManagerPage.tsx' @@ -147,7 +147,7 @@ describe('ui-plugin-manager browser plugin', () => { const unread = () => { throw new Error('The sidebar icon must not read application state') } const glyph = render() + useWorkspaces={unread} useResource={unread} {...allowAllAccess} />) expect(glyph.container.querySelector('svg')?.getAttribute('width')).toBe('18') expect(icon.options).toMatchObject({ id: PANEL_ID, order: 0 }) expect(icon.locale).toBe(NS) diff --git a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx index fc2751b4bb..a0055f2a56 100644 --- a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx +++ b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx @@ -5,7 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from import { Context } from '@deepseek-ai/cordis' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import type { PluginEntryId, PluginInstallRequestId } from '@deepseek-ai/dsh-api-remotes/client' -import { bindSnapshotSelector, stubConfigForm } from '@deepseek-ai/dsh-client-test-runtime' +import { allowAllAccess, bindSnapshotSelector, stubConfigForm } from '@deepseek-ai/dsh-client-test-runtime' import type { ConfigForm, ConfigFormSnapshot, SettingsMirrorSnapshot } from '@deepseek-ai/dsh-client-ui-settings/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { MenuItemButton } from '@deepseek-ai/dsh-client-ui-primitives' @@ -139,6 +139,7 @@ function renderTab( useSessionStatus: unusedStandardHook, useSessionRetainInfo: unusedStandardHook, useResource: unusedStandardHook, + ...allowAllAccess, } const navigation = createNavigationStore().create() const props: PluginManagerPageProps = { diff --git a/packages/client/ui-renderer/src/client/bindings.tsx b/packages/client/ui-renderer/src/client/bindings.tsx index 0e0c0c9527..5ecc314320 100644 --- a/packages/client/ui-renderer/src/client/bindings.tsx +++ b/packages/client/ui-renderer/src/client/bindings.tsx @@ -101,13 +101,43 @@ function useAbsentSnapshot( return undefined } -/** Erased open-key selector Hook synthesized from one keyed source family. */ +/** + * Erased open-key selector Hook synthesized from one keyed source family. + * A string second argument names a member: the Hook reads the source the + * resolver returns for `(key, member)` without a selector. + */ export type KeyedSnapshotHook = ( keyOrSelector: string | ((value: unknown) => unknown), - selectorOrEqual?: ((value: unknown) => unknown) | ((left: unknown, right: unknown) => boolean), + memberOrSelectorOrEqual?: string | ((value: unknown) => unknown) | ((left: unknown, right: unknown) => boolean), equal?: (left: unknown, right: unknown) => boolean, ) => unknown +interface KeyedHookCall { + readonly key: string | undefined + readonly member: string | undefined + readonly selector: ((value: unknown) => unknown) | undefined + readonly equal: ((left: unknown, right: unknown) => boolean) | undefined +} + +/** Read the `(key, member)`, `(key, selector?, equal?)`, or `(selector, equal?)` call forms. */ +function keyedHookCall( + defaultKey: string | undefined, + keyOrSelector: Parameters[0], + second: Parameters[1], + equal: Parameters[2], +): KeyedHookCall { + if (typeof keyOrSelector !== 'string') { + return { + key: defaultKey, + member: undefined, + selector: keyOrSelector, + equal: second as ((left: unknown, right: unknown) => boolean) | undefined, + } + } + if (typeof second === 'string') return { key: keyOrSelector, member: second, selector: undefined, equal: undefined } + return { key: keyOrSelector, member: undefined, selector: second as ((value: unknown) => unknown) | undefined, equal } +} + /** * Bind an open-key source family. * @param source - keyed resolver, or absence for an optional scope. @@ -126,13 +156,13 @@ export function keyedObservableHook( const cacheKey = defaultKey ?? NO_DEFAULT_KEY let hook = hooks.get(cacheKey) if (hook === undefined) { - hook = (keyOrSelector, selectorOrEqual, equal) => { - const keyed = typeof keyOrSelector === 'string' - const key = keyed ? keyOrSelector : defaultKey - const selector = (keyed ? selectorOrEqual : keyOrSelector) as ((value: unknown) => unknown) | undefined - const comparison = (keyed ? equal : selectorOrEqual) as ((left: unknown, right: unknown) => boolean) | undefined - const useValue = observableHook(key === undefined ? absentSource : source(key) ?? absentSource) - return useValue(selector ?? identity, comparison) + hook = (keyOrSelector, second, equal) => { + const call = keyedHookCall(defaultKey, keyOrSelector, second, equal) + const resolved = call.key === undefined + ? undefined + : call.member === undefined ? source(call.key) : source(call.key, call.member) + const useValue = observableHook(resolved ?? absentSource) + return useValue(call.selector ?? identity, call.equal) } hooks.set(cacheKey, hook) } @@ -142,11 +172,9 @@ export function keyedObservableHook( const NO_DEFAULT_KEY = Symbol('no default key') const keyedHookCache = new WeakMap>() const identity = (value: unknown): unknown => value -const absentKeyedHook: KeyedSnapshotHook = (keyOrSelector, selectorOrEqual, equal) => { - const keyed = typeof keyOrSelector === 'string' - const selector = (keyed ? selectorOrEqual : keyOrSelector) as ((value: unknown) => unknown) | undefined - const comparison = (keyed ? equal : selectorOrEqual) as ((left: unknown, right: unknown) => boolean) | undefined - return observableHook(absentSource)(selector ?? identity, comparison) +const absentKeyedHook: KeyedSnapshotHook = (keyOrSelector, second, equal) => { + const call = keyedHookCall(undefined, keyOrSelector, second, equal) + return observableHook(absentSource)(call.selector ?? identity, call.equal) } /** Subscribe the tree to the atomically assembled root standard-source roster. */ diff --git a/packages/client/ui-renderer/tests/keyed-hook-member.client.spec.tsx b/packages/client/ui-renderer/tests/keyed-hook-member.client.spec.tsx new file mode 100644 index 0000000000..fdfaecf3d8 --- /dev/null +++ b/packages/client/ui-renderer/tests/keyed-hook-member.client.spec.tsx @@ -0,0 +1,61 @@ +// @vitest-environment jsdom +import { describe, expect, it, vi } from 'vitest' +import { act, renderHook } from '@testing-library/react' +import type { HostObservable, KeyedStandardSource } from '@deepseek-ai/dsh-client-ui-slots' +import { keyedObservableHook } from '../src/client/bindings.tsx' + +function observable(initial: T): HostObservable & { set(next: T): void } { + let value = initial + const listeners = new Set<() => void>() + return { + getSnapshot: () => value, + subscribe: (listener) => { + listeners.add(listener) + return () => { listeners.delete(listener) } + }, + set: (next) => { + value = next + for (const listener of [...listeners]) listener() + }, + } +} + +describe('keyed standard Hooks', () => { + it('reads the resolver\'s (key, member) source when the second argument is a string', () => { + const fork = observable(true) + const whole = observable({ title: 'one' }) + const resolver = vi.fn((key, member) => + member === undefined ? (key === 's1' ? whole : undefined) : (key === 's1' && member === 'session/fork' ? fork : undefined)) + const useAccess = keyedObservableHook(resolver) + + const member = renderHook(() => useAccess('s1', 'session/fork')) + expect(member.result.current).toBe(true) + act(() => { fork.set(false) }) + expect(member.result.current).toBe(false) + expect(resolver).toHaveBeenCalledWith('s1', 'session/fork') + + expect(renderHook(() => useAccess('s1', 'session/missing')).result.current).toBeUndefined() + expect(renderHook(() => useAccess('s1')).result.current).toEqual({ title: 'one' }) + expect(resolver.mock.calls.some(call => call.length === 1 && call[0] === 's1')).toBe(true) + }) + + it('keeps the selector, equality, and default-key call forms', () => { + const whole = observable({ title: 'one', count: 1 }) + const useValue = keyedObservableHook(key => (key === 'k' ? whole : undefined), 'k') + expect(renderHook(() => useValue('k', (value: unknown) => (value as { title: string }).title)).result.current).toBe('one') + expect(renderHook(() => useValue((value: unknown) => (value as { count: number }).count)).result.current).toBe(1) + const equal = vi.fn(() => true) + renderHook(() => useValue('k', (value: unknown) => value, equal)) + act(() => { whole.set({ title: 'two', count: 2 }) }) + expect(equal).toHaveBeenCalled() + const noDefault = keyedObservableHook(key => (key === 'k' ? whole : undefined)) + expect(renderHook(() => noDefault((value: unknown) => value)).result.current).toBeUndefined() + }) + + it('reads undefined for every call form while the source family is absent', () => { + const absent = keyedObservableHook(undefined) + expect(renderHook(() => absent('s1', 'session/fork')).result.current).toBeUndefined() + expect(renderHook(() => absent('s1', (value: unknown) => value ?? 'fallback')).result.current).toBe('fallback') + expect(renderHook(() => absent((value: unknown) => value ?? 'bare')).result.current).toBe('bare') + }) +}) diff --git a/packages/client/ui-schedule/tests/task-detail.client.spec.tsx b/packages/client/ui-schedule/tests/task-detail.client.spec.tsx index bb6a084780..ff64d41b88 100644 --- a/packages/client/ui-schedule/tests/task-detail.client.spec.tsx +++ b/packages/client/ui-schedule/tests/task-detail.client.spec.tsx @@ -11,7 +11,7 @@ */ import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { allowAllAccess, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import type { RemoteResult } from '@deepseek-ai/dsh-api-remotes/client' import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client' @@ -118,6 +118,7 @@ function mount( useSessionStatus: select => select(new Map()), useSessionRetainInfo: () => undefined, useResource: () => { throw new Error('The task manager does not load document resources') }, + ...allowAllAccess, onDelete: vi.fn(async () => 'deleted'), onRetry: vi.fn(async () => {}), onNewTask: vi.fn(), diff --git a/packages/client/ui-schedule/tests/task-manager-page.client.spec.tsx b/packages/client/ui-schedule/tests/task-manager-page.client.spec.tsx index 52158d0ed2..7f29237374 100644 --- a/packages/client/ui-schedule/tests/task-manager-page.client.spec.tsx +++ b/packages/client/ui-schedule/tests/task-manager-page.client.spec.tsx @@ -3,7 +3,7 @@ import { readFileSync } from 'node:fs' import { resolve } from 'node:path' import { act, cleanup, fireEvent, isInaccessible, render, screen, waitFor, within } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { bindSnapshotSelector, makeTranslate, RemoteError } from '@deepseek-ai/dsh-client-test-runtime' +import { allowAllAccess, bindSnapshotSelector, makeTranslate, RemoteError } from '@deepseek-ai/dsh-client-test-runtime' import type { RemoteResult } from '@deepseek-ai/dsh-api-remotes/client' import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client' @@ -174,6 +174,7 @@ function mount( useSessionStatus: select => select(new Map()), useSessionRetainInfo: () => undefined, useResource: () => { throw new Error('The task manager does not load document resources') }, + ...allowAllAccess, onDelete: vi.fn(async () => 'deleted'), onRetry: vi.fn(async () => {}), onNewTask: vi.fn(), @@ -1598,6 +1599,7 @@ describe('Task manager details', () => { useResource={unread} useSessionStatus={unread} useSessionRetainInfo={unread} + {...allowAllAccess} />) } diff --git a/packages/client/ui-session/README.i18n.yaml b/packages/client/ui-session/README.i18n.yaml index 02db9d6ecb..447f8f489b 100644 --- a/packages/client/ui-session/README.i18n.yaml +++ b/packages/client/ui-session/README.i18n.yaml @@ -12,8 +12,11 @@ en: 75428e0474a073a4 zh: 18f1db4b4c5302e5 /deepseek-ai-dsh-client-ui-session/table-of-contents: - en: b4683d2a53256fda - zh: 4e65ff63dc8c48fe + en: 12e1ffc25a2d6695 + zh: 321156dea1356e55 +/deepseek-ai-dsh-client-ui-session/access-hooks: + en: 619ba442c3e40fb0 + zh: af0206a774ac9bc1 /deepseek-ai-dsh-client-ui-session/model-experience: en: cb2d4451e8bf8520 zh: 6d3adbb930d90c59 diff --git a/packages/client/ui-session/README.md b/packages/client/ui-session/README.md index d10b62ffbc..79ac9f0e73 100644 --- a/packages/client/ui-session/README.md +++ b/packages/client/ui-session/README.md @@ -14,12 +14,18 @@ Running status comes from Host list baselines or status events. Subagent catalog ## Table of Contents +- [Access Hooks](#access-hooks) - [Model Experience](#model-experience) - [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) - [Dev Note](#dev-note) ----- + +## Access Hooks + +The package injects the Client `remoteAccess` service and publishes Session access Hooks over its `source()`: the root `useSessionAccess(sessionId, action)` answers on one Session resource, and the Session-scope `useAccess(action)` answers on the bound Session's resource; the `global` resource's answer is a different Hook, `useRemoteAccess(action)`, so neither shadows the other. The SessionMaybe-scope `useAccess(action)` returns `undefined` while no Session is bound, which callers treat as not allowed; a decision on the `global` resource reads `useRemoteAccess` instead. Snapshot semantics belong to [`dsh-api-remote-access`](../../api/remote-access/README.md#client-face). + ## Model Experience diff --git a/packages/client/ui-session/README.zh.md b/packages/client/ui-session/README.zh.md index 95944f55f3..157cc79a7f 100644 --- a/packages/client/ui-session/README.zh.md +++ b/packages/client/ui-session/README.zh.md @@ -14,12 +14,18 @@ kind: "package-reference" ## 目录 +- [访问 Hook](#access-hooks) - [模型体验](#model-experience) - [已知限制与暂缓事项](#known-limitations-and-deferred-work) - [开发备注](#dev-note) ----- + +## 访问 Hook + +本包注入 Client `remoteAccess` 服务,并在其 `source()` 之上发布会话访问 Hook:根作用域的 `useSessionAccess(sessionId, action)` 回答某一个 Session 资源上的访问,Session 作用域的 `useAccess(action)` 回答所绑定 Session 资源上的访问;`global` 资源上的答案是另一个 Hook `useRemoteAccess(action)`,两者互不遮蔽。SessionMaybe 作用域的 `useAccess(action)` 在未绑定 Session 时返回 `undefined`,调用方将其视为不允许;需要 `global` 资源上的判断时读 `useRemoteAccess`。快照语义归 [`dsh-api-remote-access`](../../api/remote-access/README.zh.md#client-face)。 + ## 模型体验 diff --git a/packages/client/ui-session/package.json b/packages/client/ui-session/package.json index db6caa564f..1518437c49 100644 --- a/packages/client/ui-session/package.json +++ b/packages/client/ui-session/package.json @@ -29,6 +29,7 @@ "dsh": { "client": { "inject": [ + "@deepseek-ai/dsh-api-remote-access", "@deepseek-ai/dsh-api-remotes", "@deepseek-ai/dsh-api-session-controller", "@deepseek-ai/dsh-client-ui-renderer" @@ -46,6 +47,7 @@ }, "devDependencies": { "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-api-remote-access": "workspace:*", "@deepseek-ai/dsh-api-remotes": "workspace:*", "@deepseek-ai/dsh-api-session-controller": "workspace:*", "@deepseek-ai/dsh-client-store": "workspace:*", diff --git a/packages/client/ui-session/src/client/index.ts b/packages/client/ui-session/src/client/index.ts index 5017904d86..29aeebce04 100644 --- a/packages/client/ui-session/src/client/index.ts +++ b/packages/client/ui-session/src/client/index.ts @@ -12,6 +12,7 @@ import type { } from '@deepseek-ai/dsh-api-session-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type {} from '@deepseek-ai/dsh-api-remotes/client' +import type { RemoteEndpoint } from '@deepseek-ai/dsh-api-remote-access/client' import { notifySubscribers } from '@deepseek-ai/dsh-client-store' import { WeakMapWithValues } from '@deepseek-ai/dsh-util-values' import { standardHookPropName } from '@deepseek-ai/dsh-client-ui-slots' @@ -158,6 +159,8 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { useSessions: UseSessions useSessionStatus: UseSessionStatus useSessionRetainInfo: UseSessionRetainInfo + /** Whether the current Peer may call `action` on one Session resource. */ + useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean } interface SessionStandardProps { @@ -167,6 +170,8 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { sessionId: SessionId /** Host-computed projection values addressed by projection key. */ useProjection: UseProjection + /** Whether the current Peer may call `action` on this Session resource. */ + useAccess: (action: RemoteEndpoint) => boolean } interface SessionMaybeStandardProps { @@ -176,6 +181,8 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { sessionId: SessionId | undefined /** Host-computed projection values; every key is absent without a Session. */ useProjection: UseProjection + /** The bound Session's access answer; `undefined` without a Session, which callers treat as not allowed. */ + useAccess: (action: RemoteEndpoint) => boolean | undefined } } @@ -660,8 +667,8 @@ function claimStandardProp(kind: StandardMemberKind, name: string, finalProps: S finalProps.add(propName) } -/** Required Controller and renderer services. */ -export const inject = ['sessions', 'slots', 'remote'] +/** Required Controller, renderer, Remote, and access services. */ +export const inject = ['sessions', 'slots', 'remote', 'remoteAccess'] /** * Install the Session root source and scoped adapter. @@ -676,9 +683,18 @@ export function apply(ctx: Context): void { }, keyedHooks: { sessionRetainInfo: key => ctx.sessions.retainInfo(key as SessionId), + sessionAccess: (sessionId, action) => action === undefined + ? undefined + : ctx.remoteAccess.source('session', sessionId, action), }, } satisfies RootStandardSourceContribution) ctx.slots.installScope('session', service.adapter) + service.provide({ + keyedHooks: ['access'], + resolve: binding => ({ + keyedHooks: { access: action => ctx.remoteAccess.source('session', binding.sessionId, action) }, + }), + }) } function sameSessionStatus(left: SessionStatusSnapshot, right: SessionStatusSnapshot): boolean { diff --git a/packages/client/ui-session/tests/ui-session.client.spec.ts b/packages/client/ui-session/tests/ui-session.client.spec.ts index a91f0e2b9c..ae105e7b8e 100644 --- a/packages/client/ui-session/tests/ui-session.client.spec.ts +++ b/packages/client/ui-session/tests/ui-session.client.spec.ts @@ -715,25 +715,49 @@ describe('ui-session apply', () => { const slots = { provideRoot: vi.fn(), installScope: vi.fn(), + bindStoreScope: vi.fn(), + } + const accessSources = new Map>() + const remoteAccess = { + source: vi.fn((kind: string, id: string, action: string): HostObservable => { + const key = `${kind}:${id}:${action}` + let source = accessSources.get(key) + if (source === undefined) { + source = createSnapshotStore(false) + accessSources.set(key, source) + } + return source + }), } ctx.provide('sessions', bench.sessions) ctx.provide('slots', slots as never) + ctx.provide('remoteAccess', remoteAccess) apply(ctx) expect(ctx.uiSession).toBeInstanceOf(UiSession) const retainInfoMatcher: unknown = expect.any(Function) + const sessionAccessMatcher: unknown = expect.any(Function) expect(slots.provideRoot).toHaveBeenCalledWith({ hooks: { sessions: bench.sessions.list, sessionStatus: ctx.uiSession.sessionStatus, }, - keyedHooks: { sessionRetainInfo: retainInfoMatcher }, + keyedHooks: { sessionRetainInfo: retainInfoMatcher, sessionAccess: sessionAccessMatcher }, }) expect(slots.installScope).toHaveBeenCalledWith('session', ctx.uiSession.adapter) const root = slots.provideRoot.mock.calls[0]![0] as RootStandardSourceContribution expect(root.keyedHooks?.sessionRetainInfo?.(sessionId('s1'))) .toBe(bench.retainInfo(sessionId('s1'))) + // `useSessionAccess(sessionId, action)` reads the Session resource's answer; a call without an action names no source. + expect(root.keyedHooks?.sessionAccess?.(sessionId('s1'), 'session/fork')) + .toBe(remoteAccess.source('session', sessionId('s1'), 'session/fork')) + expect(root.keyedHooks?.sessionAccess?.(sessionId('s1'))).toBeUndefined() + + // The Session scope's `useAccess(action)` reads the bound Session's answer from the same source. + const binding = bench.binding(sessionId('s2')) + const scoped = ctx.uiSession.bindingSource(bench.reference(binding)).getSnapshot() + expect(scoped.keyedHooks.access?.('session/fork')).toBe(remoteAccess.source('session', sessionId('s2'), 'session/fork')) }) it('keeps the Host loader half inert', () => { diff --git a/packages/client/ui-session/tsconfig.json b/packages/client/ui-session/tsconfig.json index de3f6472ee..e38563a19b 100644 --- a/packages/client/ui-session/tsconfig.json +++ b/packages/client/ui-session/tsconfig.json @@ -14,6 +14,9 @@ { "path": "../../api/remotes/tsconfig.client.json" }, + { + "path": "../../api/remote-access/tsconfig.client.json" + }, { "path": "../../api/session-controller/tsconfig.client.json" }, diff --git a/packages/client/ui-settings-account/tests/onboarding.client.spec.tsx b/packages/client/ui-settings-account/tests/onboarding.client.spec.tsx index 9d5915fcb5..007aa59644 100644 --- a/packages/client/ui-settings-account/tests/onboarding.client.spec.tsx +++ b/packages/client/ui-settings-account/tests/onboarding.client.spec.tsx @@ -6,7 +6,7 @@ import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots' import type { ThemeSnapshot } from '@deepseek-ai/dsh-client-ui-theme/client' import { AccountOnboarding } from '../src/client/AccountOnboarding.tsx' import type { AccountSnapshot } from '../src/client/AccountSection.tsx' -import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { allowAllAccess, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { en } from '../src/client/locales.ts' afterEach(cleanup) @@ -22,6 +22,7 @@ it('claims onboarding while loading, offers API key fallback, and completes afte const globals: GlobalStandardProps = { usePanelInfo: unused, useSessions: unused, useSessionStatus: unused, useSessionRetainInfo: unused, useResource: unused, useWorkspaces: unused, + ...allowAllAccess, } const props = { ...globals, 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 ac2e7f000b..ab8a19976d 100644 --- a/packages/client/ui-settings-general/tests/components.client.spec.tsx +++ b/packages/client/ui-settings-general/tests/components.client.spec.tsx @@ -2,7 +2,7 @@ import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots' import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' -import { bindSnapshotSelector, RemoteError } from '@deepseek-ai/dsh-client-test-runtime' +import { allowAllAccess, bindSnapshotSelector, RemoteError } from '@deepseek-ai/dsh-client-test-runtime' import type { GeneralSectionComponentProps } from '../src/client/GeneralSection.tsx' import { GeneralSection } from '../src/client/GeneralSection.tsx' import { CloseLabel, HeaderContent, TriggerContent } from '../src/client/chrome.tsx' @@ -41,6 +41,7 @@ const useSessionStatus: TriggerContentProps['useSessionStatus'] = selector => se const kit = { useSessions: unusedHook, useSessionStatus, usePanelInfo, useSessionRetainInfo: () => undefined, useResource, useWorkspaces: unusedHook, + ...allowAllAccess, } describe('Desktop collapsed update badge', () => { diff --git a/packages/client/ui-settings-general/tests/settings-root.client.spec.tsx b/packages/client/ui-settings-general/tests/settings-root.client.spec.tsx index dcf874bcf7..2f46ad2057 100644 --- a/packages/client/ui-settings-general/tests/settings-root.client.spec.tsx +++ b/packages/client/ui-settings-general/tests/settings-root.client.spec.tsx @@ -5,7 +5,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { useEffect, useState } from 'react' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { createSettingsShellStore } from '../src/client/shell-store.ts' -import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { allowAllAccess, bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' import { SessionId } from '@deepseek-ai/dsh-session/types' import type { SettingsRootComponentProps } from '../src/client/shell-contract.ts' @@ -104,6 +104,7 @@ function mount({ useSessionStatus, usePanelInfo, useSessionRetainInfo: () => undefined, useResource, useWorkspaces: unusedHook, + ...allowAllAccess, wide, reconnect, openDesktopUpdate: () => {}, diff --git a/packages/client/ui-settings-models/tests/onboarding-dialog.client.spec.tsx b/packages/client/ui-settings-models/tests/onboarding-dialog.client.spec.tsx index f04b8853b4..a60aff9f8c 100644 --- a/packages/client/ui-settings-models/tests/onboarding-dialog.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/onboarding-dialog.client.spec.tsx @@ -6,7 +6,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import Schema from '@deepseek-ai/schemastery' import type { SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client' import type { JsonValue } from '@deepseek-ai/dsh-util-values' -import { bindSnapshotSelector, RemoteError } from '@deepseek-ai/dsh-client-test-runtime' +import { allowAllAccess, bindSnapshotSelector, RemoteError } from '@deepseek-ai/dsh-client-test-runtime' import { DeepSeekOnboardingDialog } from '../src/client/DeepSeekOnboardingDialog.tsx' import type { DeepSeekOnboardingDialogProps } from '../src/client/DeepSeekOnboardingDialog.tsx' import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts' @@ -152,6 +152,7 @@ function harness(options: { useSessionStatus, usePanelInfo, useSessionRetainInfo: () => undefined, useResource, useWorkspaces: unusedHook, + ...allowAllAccess, controller, useModels: bindSnapshotSelector(controller.store), operations, diff --git a/packages/client/ui-settings-models/tests/welcome-notice.client.spec.tsx b/packages/client/ui-settings-models/tests/welcome-notice.client.spec.tsx index 09cb3bb49d..107266f082 100644 --- a/packages/client/ui-settings-models/tests/welcome-notice.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/welcome-notice.client.spec.tsx @@ -2,7 +2,7 @@ import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' -import { bindSnapshotSelector, RemoteError } from '@deepseek-ai/dsh-client-test-runtime' +import { allowAllAccess, bindSnapshotSelector, RemoteError } from '@deepseek-ai/dsh-client-test-runtime' import { Context } from '@deepseek-ai/cordis' import { SettingsSchemaService } from '@deepseek-ai/dsh-client-ui-settings/src/client/schema.ts' import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts' @@ -95,6 +95,7 @@ function mount( useSessionStatus, usePanelInfo, useSessionRetainInfo: () => undefined, useResource, useWorkspaces: unusedHook, + ...allowAllAccess, controller, useWelcome: bindSnapshotSelector(controller.store), t: key => zh[key], diff --git a/packages/client/ui-sidebar-right/tests/expand-button.client.spec.tsx b/packages/client/ui-sidebar-right/tests/expand-button.client.spec.tsx index 9061f68145..a379b7010f 100644 --- a/packages/client/ui-sidebar-right/tests/expand-button.client.spec.tsx +++ b/packages/client/ui-sidebar-right/tests/expand-button.client.spec.tsx @@ -10,6 +10,7 @@ import { useSyncExternalStore } from 'react' import type { GlobalStandardProps, SessionStandardProps } from '@deepseek-ai/dsh-client-ui-slots' import type { ShortcutCatalogEntry } from '@deepseek-ai/dsh-client-shortcuts/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { allowAllAccess } from '@deepseek-ai/dsh-client-test-runtime' import { ExpandButton } from '../src/client/shell/ExpandButton.tsx' import type { ExpandButtonProps } from '../src/client/shell/ExpandButton.tsx' import { createSidebarRightStore } from '../src/client/stores.ts' @@ -21,6 +22,7 @@ const standard: GlobalStandardProps & SessionStandardProps = { useInput: unused, useChat: unused, useTrajectory: unused, usePanelInfo: unused, useSessions: unused, useSessionStatus: unused, useSessionRetainInfo: unused, useResource: unused, useWorkspaces: unused, + ...allowAllAccess, inputActions: { captureInsertion: unused, insertText: unused, setDraft: unused, persistDraft: unused, addAttachments: unused, removeAttachment: unused, pruneAttachments: unused, submit: unused }, } diff --git a/packages/client/ui-sidebar-right/tests/guide-body.client.spec.tsx b/packages/client/ui-sidebar-right/tests/guide-body.client.spec.tsx index a4015b079e..445b4dae68 100644 --- a/packages/client/ui-sidebar-right/tests/guide-body.client.spec.tsx +++ b/packages/client/ui-sidebar-right/tests/guide-body.client.spec.tsx @@ -19,7 +19,7 @@ import type { IconProps } from '@deepseek-ai/dsh-client-ui-primitives' import { GuideBody } from '../src/client/tabs/guide/GuideBody.tsx' import type { GuideBodyProps } from '../src/client/tabs/guide/GuideBody.tsx' import type { SidebarRightGuideBox } from '../src/client/tab-registry.ts' -import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' +import { allowAllAccess, bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' import css from '../src/client/tabs/guide/GuideBody.module.css' afterEach(cleanup) @@ -31,6 +31,7 @@ const standard: GlobalStandardProps & SessionStandardProps = { useInput: unused, useChat: unused, useTrajectory: unused, usePanelInfo: unused, useSessions: unused, useSessionStatus: unused, useSessionRetainInfo: unused, useResource: unused, useWorkspaces: unused, + ...allowAllAccess, inputActions: { captureInsertion: unused, insertText: unused, setDraft: unused, persistDraft: unused, addAttachments: unused, removeAttachment: unused, pruneAttachments: unused, submit: unused }, } diff --git a/packages/client/ui-sidebar-terminal/tests/cleanup.client.spec.tsx b/packages/client/ui-sidebar-terminal/tests/cleanup.client.spec.tsx index ec7bcd46ca..390fecc4da 100644 --- a/packages/client/ui-sidebar-terminal/tests/cleanup.client.spec.tsx +++ b/packages/client/ui-sidebar-terminal/tests/cleanup.client.spec.tsx @@ -2,7 +2,7 @@ /** Cleanup errors remain reachable after the terminal tab has disappeared. */ import { afterEach, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render, within } from '@testing-library/react' -import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { allowAllAccess, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import type { TerminalCloseFailure } from '@deepseek-ai/dsh-api-terminal-controller/client' import type { WebTerminalId } from '@deepseek-ai/dsh-api-terminal-controller/types' import { TerminalCleanup } from '../src/client/TerminalCleanup.tsx' @@ -15,6 +15,7 @@ const unusedHook = (): never => { throw new Error('Terminal cleanup does not con const standard: Omit = { useSessions: unusedHook, useSessionStatus: unusedHook, useSessionRetainInfo: unusedHook, usePanelInfo: unusedHook, useWorkspaces: unusedHook, useResource: unusedHook, + ...allowAllAccess, } it('stays absent for ordinary closes and offers a retry only after cleanup fails', () => { diff --git a/packages/client/ui-sidebar-terminal/tests/terminal-guide.client.spec.tsx b/packages/client/ui-sidebar-terminal/tests/terminal-guide.client.spec.tsx index a50f7ca835..bdcd5fb2b1 100644 --- a/packages/client/ui-sidebar-terminal/tests/terminal-guide.client.spec.tsx +++ b/packages/client/ui-sidebar-terminal/tests/terminal-guide.client.spec.tsx @@ -2,7 +2,7 @@ /** Guide shell discovery, direct launch and cancellation stay within the entry lifetime. */ import { act, cleanup, fireEvent, render } from '@testing-library/react' import { afterEach, expect, it, vi } from 'vitest' -import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { allowAllAccess, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import type { TerminalLaunchShells } from '@deepseek-ai/dsh-api-terminal-controller/client' import type { GlobalStandardProps, SessionStandardProps } from '@deepseek-ai/dsh-client-ui-slots' import type { SessionId } from '@deepseek-ai/dsh-session/types' @@ -19,6 +19,7 @@ const standard: GlobalStandardProps & SessionStandardProps = { useInput: unused, useChat: unused, useTrajectory: unused, usePanelInfo: unused, useSessions: unused, useSessionStatus: unused, useSessionRetainInfo: unused, useResource: unused, useWorkspaces: unused, + ...allowAllAccess, inputActions: { captureInsertion: unused, insertText: unused, setDraft: unused, persistDraft: unused, addAttachments: unused, removeAttachment: unused, pruneAttachments: unused, submit: unused }, } diff --git a/packages/client/ui-sidebar/tests/pointer-scrollbars.client.spec.tsx b/packages/client/ui-sidebar/tests/pointer-scrollbars.client.spec.tsx index 1522597dae..200cfe6f74 100644 --- a/packages/client/ui-sidebar/tests/pointer-scrollbars.client.spec.tsx +++ b/packages/client/ui-sidebar/tests/pointer-scrollbars.client.spec.tsx @@ -8,6 +8,7 @@ import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots' import { afterEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render } from '@testing-library/react' +import { allowAllAccess } from '@deepseek-ai/dsh-client-test-runtime' import type { SidebarRootComponentProps, SidebarSectionOwnerProps } from '../src/client/contract/slots.ts' import { SidebarRoot } from '../src/client/SidebarRoot.tsx' import { en } from '../src/client/locales.ts' @@ -43,6 +44,7 @@ function mountColumn(): { column: HTMLElement; quiet: () => boolean } { useSessions={neverHook} useSessionStatus={useSessionStatus} useSessionRetainInfo={neverHook} usePanelInfo={usePanelInfo} selectPanel={() => {}} usePanels={selector => selector([])} useShortcuts={selector => selector([])} useResource={useResource} useWorkspaces={neverHook} + {...allowAllAccess} startSession={vi.fn()} toggleSidebar={vi.fn()} t={t} renderSlot={((_key: string, owner: SidebarSectionOwnerProps) =>

) as SidebarRootComponentProps['renderSlot']} diff --git a/packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx b/packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx index f2407246f8..c0aee40acf 100644 --- a/packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx +++ b/packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx @@ -4,7 +4,7 @@ import type { ShortcutCatalogEntry, ShortcutCommandId } from '@deepseek-ai/dsh-c import { afterEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' -import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { allowAllAccess, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import type { ReactNode } from 'react' import type { SidebarFooterActionOwnerProps, SidebarRootComponentProps, SidebarSectionOwnerProps, @@ -54,6 +54,7 @@ function mountShell({ collapsed = false, width = 300, shortcuts = [] }: { useSessions={neverHook} useSessionStatus={useSessionStatus} useSessionRetainInfo={neverHook} usePanelInfo={usePanelInfo} selectPanel={() => {}} usePanels={selector => selector([])} useShortcuts={selector => selector(shortcuts)} useResource={useResource} useWorkspaces={neverHook} + {...allowAllAccess} startSession={startSession} toggleSidebar={toggleSidebar} t={t} renderSlot={(( key: string, @@ -115,6 +116,7 @@ describe('SidebarRoot shell', () => { render( select([])} useShortcuts={select => select([shortcut])} />) const button = screen.getByRole('button', { name: 'New session' }) expect(button.getAttribute('aria-keyshortcuts')).toBe('Control+N') @@ -143,6 +145,7 @@ describe('SidebarRoot shell', () => { useSessions={neverHook} useSessionStatus={useSessionStatus} useSessionRetainInfo={neverHook} usePanelInfo={usePanelInfo} selectPanel={() => {}} usePanels={selector => selector([])} useShortcuts={selector => selector([])} useResource={useResource} useWorkspaces={neverHook} + {...allowAllAccess} startSession={vi.fn()} toggleSidebar={vi.fn()} t={t} renderSlot={((_key: string, _owner: unknown, options?: { fallback?: ReactNode }) => options?.fallback ?? null) as SidebarRootComponentProps['renderSlot']} @@ -163,6 +166,7 @@ describe('SidebarRoot shell', () => { useSessions={neverHook} useSessionStatus={useSessionStatus} useSessionRetainInfo={neverHook} usePanelInfo={usePanelInfo} selectPanel={() => {}} usePanels={selector => selector([])} useShortcuts={selector => selector([])} useResource={useResource} useWorkspaces={neverHook} + {...allowAllAccess} startSession={vi.fn()} toggleSidebar={vi.fn()} t={t} renderSlot={((_key: string, _owner: unknown, options?: { fallback?: ReactNode }) => options?.fallback ?? null) as SidebarRootComponentProps['renderSlot']} @@ -178,6 +182,7 @@ describe('SidebarRoot shell', () => { useSessions={neverHook} useSessionStatus={useSessionStatus} useSessionRetainInfo={neverHook} usePanelInfo={usePanelInfo} selectPanel={() => {}} usePanels={selector => selector([])} useShortcuts={selector => selector([])} useResource={useResource} useWorkspaces={neverHook} + {...allowAllAccess} startSession={vi.fn()} toggleSidebar={vi.fn()} t={t} renderSlot={((_key: string, _owner: unknown, options?: { fallback?: ReactNode }) => options?.fallback ?? null) as SidebarRootComponentProps['renderSlot']} @@ -225,6 +230,7 @@ describe('SidebarRoot shell', () => { useSessions={neverHook} useSessionStatus={useSessionStatus} useSessionRetainInfo={neverHook} usePanelInfo={usePanelInfo} selectPanel={() => {}} usePanels={selector => selector([])} useShortcuts={selector => selector([])} useResource={useResource} useWorkspaces={neverHook} + {...allowAllAccess} startSession={vi.fn()} toggleSidebar={vi.fn()} t={t} renderSlot={((key: string) => key === 'sidebar.toggle.badge' ? diff --git a/packages/client/ui-slots/README.i18n.yaml b/packages/client/ui-slots/README.i18n.yaml index af812bc333..453ea30bac 100644 --- a/packages/client/ui-slots/README.i18n.yaml +++ b/packages/client/ui-slots/README.i18n.yaml @@ -21,8 +21,8 @@ en: 71cbe7aa26f3ff80 zh: 0e35265d4d95cc36 /deepseek-ai-dsh-client-ui-slots/use-this-package/the-five-framework-props-shares: - en: 45e2933a5e084956 - zh: 938deedaf489f892 + en: b69ffb6ca503e781 + zh: fe5887cbfc7fb588 /deepseek-ai-dsh-client-ui-slots/use-this-package/store-seats: en: 0d1c2948112841ec zh: fc5a998cea485024 diff --git a/packages/client/ui-slots/README.md b/packages/client/ui-slots/README.md index fc4bfaf6ec..0698714bff 100644 --- a/packages/client/ui-slots/README.md +++ b/packages/client/ui-slots/README.md @@ -37,6 +37,8 @@ Factory `children` remain ordinary global Slots and must match `SlotMap`, while Every registered component receives props composed from five framework shares: the runtime share (`owner` from the parent's render call site, plus the session standard kit and global seat), the child-render share (`renderSlot` statically narrowed to declared children), the Factory-render share (`renderFactorySlot`), the store share (the declared handle's selector hook and draft-stripped actions), and the business share (inferred from `inject`). Components reference the derived props aliases; they never re-type a share locally. +An open-key standard Hook, called as `useName(key, selector?, equal?)`, also accepts `useName(key, member)`: a string second argument reads the `KeyedStandardSource` resolver's `(key, member)` source without a selector. The resolver returns the same source object for the same arguments while that source is live. + ### Store seats A register call may declare a store seat with `store: defineStore(...)`: `init` infers the state schema and `actions` is the complete draft-transform write set. Components read through the selector hook and write through the baked callbacks; the engine implementation of `defineStore` lives in the runtime package and satisfies the `DefineStore` contract exported here. diff --git a/packages/client/ui-slots/README.zh.md b/packages/client/ui-slots/README.zh.md index b08b278328..365db5b428 100644 --- a/packages/client/ui-slots/README.zh.md +++ b/packages/client/ui-slots/README.zh.md @@ -37,6 +37,8 @@ Factory `children` 仍是普通全局 Slots 且必须与 `SlotMap` 匹配,而 每个已注册组件都会收到由五个框架 share 组合而成的 props:运行时 share(父级 render 调用点的 `owner`,加上会话标准工具包与全局席位)、child render share(静态缩窄到已声明 children 的 `renderSlot`)、Factory render share(`renderFactorySlot`)、store share(已声明 handle 的 selector 钩子与移除 draft 的 actions),以及业务 share(从 `inject` 推导)。组件引用推导出的 props 别名;它们绝不在本地重新定义任何 share 的类型。 +以 `useName(key, selector?, equal?)` 调用的开放键标准钩子也接受 `useName(key, member)`:第二个参数为字符串时,钩子不经 selector,直接读取 `KeyedStandardSource` 解析器为 `(key, member)` 返回的 source。在该 source 存活期间,解析器对相同参数返回同一个 source 对象。 + ### Store 席位 register 调用可以用 `store: defineStore(...)` 声明 store 席位:`init` 推断状态 schema,`actions` 是完整的 draft-transform 写入集合。组件经 selector 钩子读取、经烘焙回调写入;`defineStore` 的引擎实现位于运行时包,并满足这里导出的 `DefineStore` 约定。 diff --git a/packages/client/ui-slots/src/renderer.ts b/packages/client/ui-slots/src/renderer.ts index 2ea326cea4..e6ed27a9ef 100644 --- a/packages/client/ui-slots/src/renderer.ts +++ b/packages/client/ui-slots/src/renderer.ts @@ -61,8 +61,12 @@ export interface StoreInstanceLike { readonly actions: Record void> } -/** Resolve one member of an open-key standard hook family. */ -export type KeyedStandardSource = (key: string) => HostObservable | undefined +/** + * Resolve one member of an open-key standard hook family. `member` is present + * only when the Hook is called as `useName(key, member)`; the resolver returns + * the same source object for the same arguments while that source is live. + */ +export type KeyedStandardSource = (key: string, member?: string) => HostObservable | undefined /** * Framework-neutral inputs from which the renderer materializes standard @@ -74,7 +78,11 @@ export interface StandardSourceBinding { readonly key: string | undefined /** Fixed-name sources; each `name` becomes a `useName` selector Hook. */ readonly hooks: Readonly | undefined>> - /** Open-key source families; each `name` becomes a `useName(key, selector)` Hook. */ + /** + * Open-key source families; each `name` becomes a `useName(key, selector?, equal?)` + * Hook, which also accepts `useName(key, member)` to read the resolver's + * `(key, member)` source without a selector. + */ readonly keyedHooks: Readonly> /** Stable plain values copied into standard props. */ readonly props: Readonly> diff --git a/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx b/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx index dac2d9e053..ed5b4043fa 100644 --- a/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx +++ b/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react' -import { makeTranslate, RemoteError, sessionSnapshot } from '@deepseek-ai/dsh-client-test-runtime' +import { allowAllAccess, makeTranslate, RemoteError, sessionSnapshot } from '@deepseek-ai/dsh-client-test-runtime' import type { SessionListState, SessionSummary, SessionSnapshot, } from '@deepseek-ai/dsh-api-session-controller/client' @@ -94,6 +94,7 @@ function props( useSessionRetainInfo: unused, useWorkspaces: unused, useResource: unused, + ...allowAllAccess, useProjection: unused, useConversation: unused, useInput: unused, diff --git a/packages/client/ui-theme/tests/appearance-row.client.spec.tsx b/packages/client/ui-theme/tests/appearance-row.client.spec.tsx index 75fcdcd0d2..d7d2afa0d2 100644 --- a/packages/client/ui-theme/tests/appearance-row.client.spec.tsx +++ b/packages/client/ui-theme/tests/appearance-row.client.spec.tsx @@ -5,7 +5,7 @@ import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' -import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' +import { allowAllAccess, bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' import { AppearanceRow } from '../src/client/AppearanceRow.tsx' import type { AppearanceRowComponentProps } from '../src/client/AppearanceRow.tsx' import { createAppearanceRowStore } from '../src/client/settings-store.ts' @@ -50,6 +50,7 @@ function mount(preference: ThemePreference = 'system') { useSessionStatus, usePanelInfo, useSessionRetainInfo: () => undefined, useResource, useWorkspaces: emptyWorkspaces(), + ...allowAllAccess, useStore: bindSnapshotSelector(store), actions: store.actions, t: (key: string) => COPY[key] ?? key, diff --git a/packages/client/ui-theme/tests/font-family-row.client.spec.tsx b/packages/client/ui-theme/tests/font-family-row.client.spec.tsx index 1fc4d77a32..a2ca325a7e 100644 --- a/packages/client/ui-theme/tests/font-family-row.client.spec.tsx +++ b/packages/client/ui-theme/tests/font-family-row.client.spec.tsx @@ -8,7 +8,7 @@ import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' -import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' +import { allowAllAccess, bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' import { FontFamilyRow } from '../src/client/FontFamilyRow.tsx' import type { FontFamilyRowComponentProps } from '../src/client/FontFamilyRow.tsx' import { createFontRowStore } from '../src/client/settings-store.ts' @@ -52,6 +52,7 @@ function mount(kind: FontRole = 'code', code = '') { useSessionStatus, usePanelInfo, useSessionRetainInfo: () => undefined, useResource, useWorkspaces: emptyWorkspaces(), + ...allowAllAccess, useStore: bindSnapshotSelector(store), actions: store.actions, t: (key: string) => COPY[key] ?? key, diff --git a/packages/client/ui-theme/tests/font-settings-group.client.spec.tsx b/packages/client/ui-theme/tests/font-settings-group.client.spec.tsx index f9c823be8d..947c099e20 100644 --- a/packages/client/ui-theme/tests/font-settings-group.client.spec.tsx +++ b/packages/client/ui-theme/tests/font-settings-group.client.spec.tsx @@ -8,7 +8,7 @@ import { cleanup, fireEvent, render, screen } from '@testing-library/react' import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' -import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' +import { allowAllAccess, bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' import { FontSettingsGroup } from '../src/client/FontSettingsGroup.tsx' import type { FontSettingsGroupProps } from '../src/client/FontSettingsGroup.tsx' import { createFontRowStore } from '../src/client/settings-store.ts' @@ -55,6 +55,7 @@ function mount() { useSessionStatus, usePanelInfo, useSessionRetainInfo: () => undefined, useResource, useWorkspaces: emptyWorkspaces(), + ...allowAllAccess, useStore: bindSnapshotSelector(store), actions: store.actions, t: (key: string) => COPY[key] ?? key, diff --git a/packages/client/ui-theme/tests/font-size-row.client.spec.tsx b/packages/client/ui-theme/tests/font-size-row.client.spec.tsx index 0081edfea6..35b67f6205 100644 --- a/packages/client/ui-theme/tests/font-size-row.client.spec.tsx +++ b/packages/client/ui-theme/tests/font-size-row.client.spec.tsx @@ -7,7 +7,7 @@ import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' -import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' +import { allowAllAccess, bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' import { FontSizeRow } from '../src/client/FontSizeRow.tsx' import type { FontSizeRowComponentProps } from '../src/client/FontSizeRow.tsx' import { createFontRowStore } from '../src/client/settings-store.ts' @@ -55,6 +55,7 @@ function mount(fontSize = 14, role: FontRole = 'text') { useSessionStatus, usePanelInfo, useSessionRetainInfo: () => undefined, useResource, useWorkspaces: emptyWorkspaces(), + ...allowAllAccess, useStore: bindSnapshotSelector(store), actions: store.actions, t: (key: string) => COPY[key] ?? key, diff --git a/packages/client/ui-trajectory/tests/views.client.spec.tsx b/packages/client/ui-trajectory/tests/views.client.spec.tsx index 2f5dd4a45e..1c6e39f0a1 100644 --- a/packages/client/ui-trajectory/tests/views.client.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.client.spec.tsx @@ -11,7 +11,7 @@ import type { GlobalStandardProps, PropsRenderSlots } from '@deepseek-ai/dsh-cli import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import { createElement, type ComponentProps, type FC, type ReactNode } from 'react' -import { bindSnapshotSelector, SlotTestRuntime, stubConfigForm } from '@deepseek-ai/dsh-client-test-runtime' +import { allowAllAccess, bindSnapshotSelector, SlotTestRuntime, stubConfigForm } from '@deepseek-ai/dsh-client-test-runtime' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' import { EMPTY_CONVERSATION_SNAPSHOT, UiConversation, @@ -235,6 +235,7 @@ function standaloneProps( ), useSessionRetainInfo: () => undefined, useWorkspaces: emptyWorkspaces(), + ...allowAllAccess, useConversation: bindSnapshotSelector(createSnapshotStore(conversationSnapshot(trajectory))), useInput: bindSnapshotSelector(input), inputActions, @@ -365,6 +366,7 @@ function mount(fixture: Awaited>) { useSessionStatus, useSessionRetainInfo: () => undefined, useWorkspaces, + ...allowAllAccess, useProjection, useInput, inputActions, diff --git a/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx b/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx index 798a2bedba..2ed8e0c27a 100644 --- a/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx +++ b/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx @@ -2,6 +2,7 @@ import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots' import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { allowAllAccess } from '@deepseek-ai/dsh-client-test-runtime' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { ToolCallId } from '@deepseek-ai/dsh-llm/brand' import { @@ -123,6 +124,7 @@ const kit: Omit = { useSessionStatus: selector => selector(attentionState), useSessionRetainInfo: () => undefined, useWorkspaces: selector => selector(workspaceState), + ...allowAllAccess, useConversation: selector => selector(conversationState), useChat: selector => selector(chatState), useTrajectory: selector => selector(trajectoryState), diff --git a/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx b/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx index a583a6d296..8ece0da777 100644 --- a/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx +++ b/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx @@ -3,6 +3,7 @@ import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots' import { useSyncExternalStore } from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { allowAllAccess } from '@deepseek-ai/dsh-client-test-runtime' import { ToolCallId } from '@deepseek-ai/dsh-llm' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { AskUserQuestionAnswerItem } from '@deepseek-ai/dsh-user-questions/types' @@ -122,6 +123,7 @@ const kitBase: Omit selector(attentionState), useSessionRetainInfo: () => undefined, useWorkspaces: selector => selector(workspaceState), + ...allowAllAccess, useConversation: selector => selector(conversationState), useChat: selector => selector(chatState), useTrajectory: selector => selector(trajectoryState), diff --git a/packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx b/packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx index 3fa00ccc54..c91c543c99 100644 --- a/packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx +++ b/packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx @@ -20,7 +20,7 @@ import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types' import { SessionSeq } from '@deepseek-ai/dsh-session' import { apply as applyLocale, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client' import { - chatSnapshot as emptyChatSnapshot, conversationSnapshot, makeTranslate, sessionSnapshot, + allowAllAccess, chatSnapshot as emptyChatSnapshot, conversationSnapshot, makeTranslate, sessionSnapshot, stubConfigForm, TestSessions, workspaceSnapshot, } from '@deepseek-ai/dsh-client-test-runtime' import { @@ -360,6 +360,7 @@ function panelProps(data: WorkflowRunChatData, sessions = listState(), openSessi submit: () => {}, }, useWorkspaces: selector => selector(panelWorkspace), + ...allowAllAccess, useTurnData: () => undefined, useDisclosure: () => { throw new Error('unused') }, useGroupAction: () => { throw new Error('unused') }, diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index eaa7774534..60a03011cf 100644 --- a/packages/client/ui-workspace/README.i18n.yaml +++ b/packages/client/ui-workspace/README.i18n.yaml @@ -33,8 +33,8 @@ en: 9fb88260380bf093 zh: 3bace2b96ecd4a82 /deepseek-ai-dsh-client-ui-workspace/use-this-package/active-schedule-markers: - en: 68423607516bb58e - zh: 550e5c55cc6a25db + en: 8d82539e4e4e890d + zh: 7b97a3927de90ea8 /deepseek-ai-dsh-client-ui-workspace/understand-the-implementation: en: 721d5a49fe231191 zh: 3ef1dd720219e00e @@ -42,8 +42,8 @@ en: 868904dbfb5713f8 zh: 225906bfef7398bf /deepseek-ai-dsh-client-ui-workspace/understand-the-implementation/session-row-actions: - en: 24d196702ca9a5a0 - zh: 351b4896a0217ac1 + en: 5bbba6e462faf3dc + zh: 4734263208ee07c9 /deepseek-ai-dsh-client-ui-workspace/understand-the-implementation/session-row-actions/packaged-client-plugin: en: 36934671b80b11ae zh: 8e0aea6162e35e77 diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index f694548654..2654447b77 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -81,6 +81,8 @@ Once both Workspace and Session startup baselines are ready, an empty installati The first-use directory name and its stored title are fixed, so neither follows the reader's language: `workspaces.initializeDefault` carries no names, and one installation keeps one on-disk path and one stored title across language switches. A Workspace still carrying that automatic title is labeled with the localized default name wherever this package shows it — sidebar rows and their hover card, search result meta, the hero picker menu, and the rename and delete dialogs — through the controller's `workspaceDisplayTitle`. The rename dialog is seeded with the label on screen while the stored title decides whether confirming saves, so confirming the untouched prefill on such a Workspace pins that name and the row stops following the language; the duplicate-title check excludes the target by Workspace identity rather than by title. +The package injects the Client `remoteAccess` service and provides the root standard Hook `useWorkspaceAccess(workspaceId, action)`, which answers whether the current Peer may call `action` on one Workspace resource; its snapshot semantics belong to [`dsh-api-remote-access`](../../api/remote-access/README.md#client-face). + ## Understand the implementation @@ -101,7 +103,7 @@ Each registration declares a **directory-flow child hole** (`single` kind: `conv The Session row's "..." menu and its hover buttons are two `list` slots declared by the WorkspaceBrowser entry: `sidebar.workspaces.session.menu.item` and `sidebar.workspaces.session.row.action`. Every menu row and every hover button is an entry, this package's own actions included: `apply` registers `pin` (menu 100, button 200), `rename` (200), `fork` (300), `archive` (menu 400, button 100), and `session-id` (450) the way a client plugin registers its actions, so a plugin action lands wherever its `order` says, and reusing a shipped id at another `priority` shadows that action. -An entry receives only the row identity (`sessionId`, `displayTitle`) and owns everything else. It reads the Host state it cares about through its own injected hooks (the pin and archive sets, as Sets derived once per Workspace snapshot), decides its own visibility (pin renders nothing on an archived row, because the Host keeps the two sets exclusive), carries its whole behavior in its registration's `inject` face (a pin also fronts the Session in its saved orders; an archive raises the notice), and raises its own surfaces: the rename dialog, the stop-and-archive dialog, and the row-action notice are this package's `shell.overlay` entries, fed by the requests the actions inject. A menu entry renders one `role="menuitem"` button (this package's rows use `MenuItemButton` from ui-primitives, which adds the host styling and `separatorBefore` for a group start; the hairline comes and goes with the row) and dismisses the menu through the slot-level `useMenuOpenState` hook, the menu's own open state bound from the row's render occurrence; a hover button entry renders one icon button, and the strip keeps its clicks from opening the row. The browser passes no action callbacks to its rows; its remaining verbs are the search results' restore button and the title double-click, which raises the same rename request. +An entry receives only the row identity (`sessionId`, `displayTitle`) and owns everything else. It reads the Host state it cares about through its own injected hooks (the pin and archive sets, as Sets derived once per Workspace snapshot), decides its own visibility (pin renders nothing on an archived row, because the Host keeps the two sets exclusive, and fork renders nothing when `useSessionAccess(sessionId, 'session/fork')` is false), carries its whole behavior in its registration's `inject` face (a pin also fronts the Session in its saved orders; an archive raises the notice), and raises its own surfaces: the rename dialog, the stop-and-archive dialog, and the row-action notice are this package's `shell.overlay` entries, fed by the requests the actions inject. A menu entry renders one `role="menuitem"` button (this package's rows use `MenuItemButton` from ui-primitives, which adds the host styling and `separatorBefore` for a group start; the hairline comes and goes with the row) and dismisses the menu through the slot-level `useMenuOpenState` hook, the menu's own open state bound from the row's render occurrence; a hover button entry renders one icon button, and the strip keeps its clicks from opening the row. The browser passes no action callbacks to its rows; its remaining verbs are the search results' restore button and the title double-click, which raises the same rename request. The existing notice overlay dispatches `workspace.toast` by notice kind. Keyed registrations inject localized display mappings and their own actions; only archive notices subscribe to the viewing filter. They render the package-local `workspace.toast.content` Factory, which reuses the Toast primitive once for all presentations. The existing notice state, display sequence, latest-notice replacement, and dismissal remain shared; locale or filter changes do not restart the same notice's timer. diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index 920562c4e7..c655995453 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -81,6 +81,8 @@ Workspace 和 Session 的启动基线均就绪后,空安装环境调用 `works 首次使用的目录名与其存储标题都是固定的,都不跟随读者语言:`workspaces.initializeDefault` 不携带任何名称,同一安装环境在切换语言后仍只有一个磁盘路径和一个存储标题。仍保留该自动标题的工作区,在本包展示它的所有位置——侧栏行及其悬浮卡片、搜索结果元信息、hero 选择器菜单、重命名与删除对话框——都通过控制器的 `workspaceDisplayTitle` 显示为本地化的默认名称。重命名对话框以屏幕上的标签作为初值,而判断确认是否需要保存用的是存储标题,因此对这类工作区直接确认未改动的初值即可固定该名称,该行不再跟随语言;重名检查按工作区身份而非标题排除目标自身。 +本包注入 Client `remoteAccess` 服务,并提供根作用域标准 Hook `useWorkspaceAccess(workspaceId, action)`,回答当前 Peer 能否在某个 Workspace 资源上调用 `action`;其快照语义归 [`dsh-api-remote-access`](../../api/remote-access/README.zh.md#client-face)。 + ## 理解实现 @@ -101,7 +103,7 @@ Workspace 和 Session 的启动基线均就绪后,空安装环境调用 `works Session 行的 "..." 菜单和行尾悬停按钮是 WorkspaceBrowser 注册项声明的两个 `list` slot:`sidebar.workspaces.session.menu.item` 与 `sidebar.workspaces.session.row.action`。每一个菜单行、每一个悬停按钮都是条目,本包自己的 action 也不例外:`apply` 以客户端插件注册自己 action 的同一方式注册 `pin`(菜单 100、按钮 200)、`rename`(200)、`fork`(300)、`archive`(菜单 400、按钮 100)和 `session-id`(450),因此插件 action 落在其 `order` 所指的位置,以另一个 `priority` 复用内置 id 则遮蔽该 action。 -条目只接收行身份(`sessionId`、`displayTitle`),其余一切自己负责:用自己注入的 hook 读自己关心的 Host 状态(置顶与归档集合,以每次 Workspace 快照只派生一次的 Set 形式),自己决定是否显示(Host 规定归档与置顶互斥,所以 pin 在归档行上不渲染),整套行为放在注册项自己的 `inject` face 里(置顶成功后顺带把会话推到保存顺序最前,归档成功后发提示),浮层也自己带——重命名对话框、停止并归档对话框和行 action 的提示是本包注册在 `shell.overlay` 的条目,由 action 注入的请求驱动。菜单条目渲染一个 `role="menuitem"` 的按钮(本包自己的行用 ui-primitives 的 `MenuItemButton`,它带宿主样式,开启新分组的行加 `separatorBefore`,分隔线随行一起出现和消失),并通过 slot 级 `useMenuOpenState` hook(菜单自身的打开状态,从该行的渲染出现处绑定)关闭菜单;悬停按钮条目渲染一个图标按钮,按钮条会拦住点击、不让它打开该行。browser 不再向行传任何 action 回调,它剩下的动作只有搜索结果里的恢复按钮和标题双击,后者发出的是同一个重命名请求。 +条目只接收行身份(`sessionId`、`displayTitle`),其余一切自己负责:用自己注入的 hook 读自己关心的 Host 状态(置顶与归档集合,以每次 Workspace 快照只派生一次的 Set 形式),自己决定是否显示(Host 规定归档与置顶互斥,所以 pin 在归档行上不渲染;`useSessionAccess(sessionId, 'session/fork')` 为 false 时 fork 不渲染),整套行为放在注册项自己的 `inject` face 里(置顶成功后顺带把会话推到保存顺序最前,归档成功后发提示),浮层也自己带——重命名对话框、停止并归档对话框和行 action 的提示是本包注册在 `shell.overlay` 的条目,由 action 注入的请求驱动。菜单条目渲染一个 `role="menuitem"` 的按钮(本包自己的行用 ui-primitives 的 `MenuItemButton`,它带宿主样式,开启新分组的行加 `separatorBefore`,分隔线随行一起出现和消失),并通过 slot 级 `useMenuOpenState` hook(菜单自身的打开状态,从该行的渲染出现处绑定)关闭菜单;悬停按钮条目渲染一个图标按钮,按钮条会拦住点击、不让它打开该行。browser 不再向行传任何 action 回调,它剩下的动作只有搜索结果里的恢复按钮和标题双击,后者发出的是同一个重命名请求。 现有通知浮层按通知 kind 分派 `workspace.toast`。各 keyed 注册注入本地化展示映射与自己的操作,只有归档通知订阅查看筛选状态。它们渲染包内的 `workspace.toast.content` Factory,所有展示共用一处 Toast primitive。现有通知状态、显示序号、最新提示替换及关闭逻辑仍共享;语言或筛选变化不会重新启动同一条提示的计时。 diff --git a/packages/client/ui-workspace/package.json b/packages/client/ui-workspace/package.json index c2d44e8939..e88301dcdf 100644 --- a/packages/client/ui-workspace/package.json +++ b/packages/client/ui-workspace/package.json @@ -29,6 +29,7 @@ "dsh": { "client": { "inject": [ + "@deepseek-ai/dsh-api-remote-access", "@deepseek-ai/dsh-api-remotes", "@deepseek-ai/dsh-api-session-controller", "@deepseek-ai/dsh-api-workspace-controller", @@ -53,6 +54,7 @@ "@deepseek-ai/cordis": "workspace:~" }, "devDependencies": { + "@deepseek-ai/dsh-api-remote-access": "workspace:*", "@deepseek-ai/cordis": "workspace:~", "@deepseek-ai/dsh-api-remotes": "workspace:*", "@deepseek-ai/dsh-agent": "workspace:*", diff --git a/packages/client/ui-workspace/src/client/index.ts b/packages/client/ui-workspace/src/client/index.ts index 04c4e5da2a..cc9da072c9 100644 --- a/packages/client/ui-workspace/src/client/index.ts +++ b/packages/client/ui-workspace/src/client/index.ts @@ -20,8 +20,9 @@ import type { Context } from '@deepseek-ai/cordis' import type { RemoteHostFacts } from '@deepseek-ai/dsh-api-remotes/client' import type { ISessions, SessionForkError } from '@deepseek-ai/dsh-api-session-controller/client' import type { - IWorkspaces, SessionActivity, WorkspaceArchiveError, WorkspaceSnapshot, + IWorkspaces, SessionActivity, WorkspaceArchiveError, WorkspaceId, WorkspaceSnapshot, } from '@deepseek-ai/dsh-api-workspace-controller/client' +import type { RemoteEndpoint } from '@deepseek-ai/dsh-api-remote-access/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { writeClipboard } from '@deepseek-ai/dsh-client-ui-primitives' import type { HostObservable, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' @@ -71,6 +72,8 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { interface GlobalStandardProps { /** Selector hook over the pure Workspace Controller snapshot. */ useWorkspaces: SnapshotSelectorHook + /** Whether the current Peer may call `action` on one Workspace resource. */ + useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean } interface LocaleNamespaceMap { @@ -97,7 +100,7 @@ const NS = 'workspace' * declaration through `slots.inject()` instead of assuming order. */ export const inject = [ - 'slots', 'sessions', 'workspaces', 'locale', 'remote', 'remote.directoryPicker', 'layout', 'shortcuts', + 'slots', 'sessions', 'workspaces', 'locale', 'remote', 'remote.directoryPicker', 'layout', 'shortcuts', 'remoteAccess', ] /** @@ -121,7 +124,14 @@ export function apply(ctx: Context): void { const uiWorkspace = new UiWorkspaceService( ctx, ctx.remote.directoryPicker, workspaces, sessions, viewInstance.actions, notify, ) - ctx.slots.provideRoot({ hooks: { workspaces: workspaces.list } }) + ctx.slots.provideRoot({ + hooks: { workspaces: workspaces.list }, + keyedHooks: { + workspaceAccess: (workspaceId, action) => action === undefined + ? undefined + : ctx.remoteAccess.source('workspace', workspaceId, action), + }, + }) ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-workspace: dictionaries') const shortcutControls = createWorkspaceShortcutControls() diff --git a/packages/client/ui-workspace/src/client/session-actions/ForkSession.tsx b/packages/client/ui-workspace/src/client/session-actions/ForkSession.tsx index 6a2d338b13..a71dc67638 100644 --- a/packages/client/ui-workspace/src/client/session-actions/ForkSession.tsx +++ b/packages/client/ui-workspace/src/client/session-actions/ForkSession.tsx @@ -4,15 +4,18 @@ import type { ForkSessionInjected, SessionMenuItemProps } from '../contract/slot /** * Menu row (order 300): fork at the Session's last completed turn; the child - * arrives through the Host list beside its source. - * @param props - owner share, menu open state, and the fork share. - * @returns the row. + * arrives through the Host list beside its source. Absent when the access + * snapshot excludes `session/fork` for this Session. + * @param props - owner share, menu open state, access Hook, and the fork share. + * @returns the row, or nothing when the caller may not fork this Session. */ export function ForkSessionMenuItem({ - sessionId, useMenuOpenState, useShortcuts, forkSession, t, + sessionId, useMenuOpenState, useShortcuts, useSessionAccess, forkSession, t, }: SessionMenuItemProps) { const [, setMenuOpen] = useMenuOpenState() const shortcut = useShortcuts(rows => rows.find(row => row.id === 'session.fork')) + const forkAllowed = useSessionAccess(sessionId, 'session/fork') + if (!forkAllowed) return null return ( = createSnapshotStore(true) + async function bench() { const ctx = new Context() const commands = new Map() @@ -66,6 +68,8 @@ async function bench() { }) ctx.provide('uiConversation', {}) await ctx.plugin(SlotRegistry).await() + const accessSource = vi.fn((_kind: string, _id: string, _action: string): HostObservable => ALLOWED) + ctx.provide('remoteAccess', { source: accessSource }) const create = vi.fn(async (input: { name: string } | { path: string }) => ({ workspaceId: 'ws-new' as never, path: 'name' in input ? `/projects/${input.name}` : input.path, @@ -158,7 +162,7 @@ async function bench() { locale.setLocale('zh') ctx.provide('locale', locale) return { - ctx, slots: ctx.get('slots') as SlotRegistry, locale, create, rename, commands, + ctx, slots: ctx.get('slots') as SlotRegistry, locale, create, rename, commands, accessSource, retain, using, selectPanel, search, renameSession, binding, fork, pickDirectory, pinSession, unpinSession, workspacesSubscribe, initializeDefault, history, scope, sessionOf, setWorkspaces: (snapshot: WorkspaceSnapshot): void => { workspaceSnapshot = snapshot }, @@ -215,10 +219,22 @@ describe('ui-workspace apply', () => { it('declares the services it drives', () => { expect(inject).toEqual([ - 'slots', 'sessions', 'workspaces', 'locale', 'remote', 'remote.directoryPicker', 'layout', 'shortcuts', + 'slots', 'sessions', 'workspaces', 'locale', 'remote', 'remote.directoryPicker', 'layout', 'shortcuts', 'remoteAccess', ]) }) + it('publishes the Workspace access Hook over the remoteAccess service', async () => { + const b = await bench() + onTestFinished(() => b.ctx.fiber.dispose()) + const provideRoot = vi.spyOn(b.slots, 'provideRoot') + await b.ctx.plugin({ inject: [...inject], apply }).await() + const keyed = provideRoot.mock.calls.map(([contribution]) => contribution.keyedHooks?.['workspaceAccess']).find(Boolean) + // `useWorkspaceAccess(workspaceId, action)` reads the Workspace resource's answer; a call without an action names no source. + expect(keyed?.('w1', 'workspace/follow')).toBe(ALLOWED) + expect(b.accessSource).toHaveBeenCalledWith('workspace', 'w1', 'workspace/follow') + expect(keyed?.('w1')).toBeUndefined() + }) + it('reports a default Workspace creation failure through the shared notice overlay', async () => { const b = await bench() onTestFinished(() => b.ctx.fiber.dispose()) diff --git a/packages/client/ui-workspace/tests/session-actions.client.spec.tsx b/packages/client/ui-workspace/tests/session-actions.client.spec.tsx index 35e40222e2..1b7533e4dc 100644 --- a/packages/client/ui-workspace/tests/session-actions.client.spec.tsx +++ b/packages/client/ui-workspace/tests/session-actions.client.spec.tsx @@ -15,7 +15,7 @@ import type { SessionStatusSnapshot } from '@deepseek-ai/dsh-client-ui-session/c import type { SessionId } from '@deepseek-ai/dsh-session/types' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import type { GlobalStandardProps, PropsLocale, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' -import { bindSnapshotSelector, makeTranslate, SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' +import { allowAllAccess, bindSnapshotSelector, makeTranslate, SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { registerToasts } from '../src/client/toast/register.ts' import { ArchiveToastEntry } from '../src/client/toast/Toast.tsx' @@ -84,6 +84,7 @@ const standard: GlobalStandardProps = { usePanelInfo, useResource, useWorkspaces: hook(workspaces), + ...allowAllAccess, } type MenuRowProps = PropsRuntime<'sidebar.workspaces.session.menu.item'> & PropsLocale<'workspace'> @@ -237,6 +238,16 @@ describe('fork and rename rows', () => { expect(callOrder(setMenuOpen)).toBeLessThan(callOrder(forkSession)) }) + it('omits the fork row when the access snapshot excludes session/fork for this Session', () => { + const { state } = openMenu() + const useSessionAccess = vi.fn(() => false) + const { container } = render( + , + ) + expect(container.innerHTML).toBe('') + expect(useSessionAccess).toHaveBeenCalledWith(sid('one'), 'session/fork') + }) + it('rename closes the menu, then asks for the dialog with the row title', () => { const { state, setMenuOpen } = openMenu() const requestSessionRename = vi.fn() diff --git a/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx b/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx index 544ddd5140..4b62826cbc 100644 --- a/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx @@ -2,7 +2,7 @@ import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, createEvent, fireEvent, render, screen, waitFor, within } from '@testing-library/react' -import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { allowAllAccess, bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import type { SessionListState, SessionSummary } from '@deepseek-ai/dsh-api-session-controller/client' import type { WorkspaceId, WorkspaceSnapshot, WorkspaceView, @@ -122,6 +122,7 @@ function mount(overrides: Partial = {}) { useSessionRetainInfo: () => undefined, usePanelInfo, useResource, useWorkspaces: hook(workspaceState([])), + ...allowAllAccess, useStore: bindSnapshotSelector(store), actions: store.actions, startSession: vi.fn(), 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 4bb54db303..df9149403c 100644 --- a/packages/client/ui-workspace/tests/workspace-picker.client.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-picker.client.spec.tsx @@ -7,7 +7,7 @@ import type { WorkspaceId, WorkspaceSnapshot, WorkspaceView, } from '@deepseek-ai/dsh-api-workspace-controller/client' import type {} from '@deepseek-ai/dsh-client-locale/client' -import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { allowAllAccess, bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import type { SessionStatusSnapshot } from '@deepseek-ai/dsh-client-ui-session/client' import type { DirectoryFlowOwnerProps, WorkspacePickerProps } from '../src/client/contract/slots.ts' @@ -102,6 +102,7 @@ function mount( useSessionRetainInfo={() => undefined} usePanelInfo={usePanelInfo} useResource={useResource} useWorkspaces={hook(workspaceState(nextItems))} + {...allowAllAccess} onPick={onPick} onClose={onClose} createWorkspace={createWorkspace} @@ -224,6 +225,7 @@ describe('WorkspacePicker', () => { useSessionStatus={hook(noPendingInteraction)} useSessionRetainInfo={() => undefined} usePanelInfo={usePanelInfo} useResource={useResource} + {...allowAllAccess} onPick={vi.fn()} onClose={vi.fn()} createWorkspace={vi.fn()} useDirectoryFlow={occupancySource().useDirectoryFlow} renderSlot={renderSlot} t={t} />, @@ -242,6 +244,7 @@ describe('WorkspacePicker', () => { useSessionStatus={hook(noPendingInteraction)} useSessionRetainInfo={() => undefined} usePanelInfo={usePanelInfo} useResource={useResource} + {...allowAllAccess} onPick={vi.fn()} onClose={vi.fn()} createWorkspace={vi.fn()} useDirectoryFlow={occupancySource().useDirectoryFlow} renderSlot={renderSlot} t={t} />, diff --git a/packages/client/ui-workspace/tsconfig.json b/packages/client/ui-workspace/tsconfig.json index 0bf315d56f..dea11b7bd6 100644 --- a/packages/client/ui-workspace/tsconfig.json +++ b/packages/client/ui-workspace/tsconfig.json @@ -17,6 +17,9 @@ { "path": "../../api/workspace-controller/tsconfig.client.json" }, + { + "path": "../../api/remote-access/tsconfig.client.json" + }, { "path": "../locale/tsconfig.client.json" }, diff --git a/packages/test-support/client-runtime/README.i18n.yaml b/packages/test-support/client-runtime/README.i18n.yaml index f4989ed8a8..0f7e7badee 100644 --- a/packages/test-support/client-runtime/README.i18n.yaml +++ b/packages/test-support/client-runtime/README.i18n.yaml @@ -18,8 +18,8 @@ en: 12afbb217bc06678 zh: c07f001ed8bb3300 /deepseek-ai-dsh-client-test-runtime/use-this-package/setting-up-a-feature-spec: - en: e78bc5e3adf2ff44 - zh: 480cd66637d92047 + en: 620906bcbaba405a + zh: b7c34920ba5873d1 /deepseek-ai-dsh-client-test-runtime/use-this-package/local-dom-snapshots: en: 1abbbed6cc4cf57b zh: ab3975178b589653 @@ -30,8 +30,8 @@ en: 1442fc215c8128e8 zh: 8439861b08e46ea2 /deepseek-ai-dsh-client-test-runtime/use-this-package/roster-and-startup-behavior: - en: 964236f295b61c0c - zh: 863a9342b1169ad8 + en: 95c5c2121547bd60 + zh: 4dfb089ba7aac04d /deepseek-ai-dsh-client-test-runtime/use-this-package/when-to-use-it: en: eb2f9bc90acb7a62 zh: cb59a2134bc83106 @@ -45,8 +45,8 @@ en: 3264640018c1a0e8 zh: d777e0b8ad8b602f /deepseek-ai-dsh-client-test-runtime/understand-the-implementation/source-map: - en: b77fd15bc12265db - zh: 1a14b6999978b72a + en: e3ef8397426d484b + zh: 23f735f4bda1534a /deepseek-ai-dsh-client-test-runtime/understand-the-implementation/lifecycle: en: b42779f364504719 zh: a10bad7ea1161388 diff --git a/packages/test-support/client-runtime/README.md b/packages/test-support/client-runtime/README.md index 2a7690f4ab..dc22248ff0 100644 --- a/packages/test-support/client-runtime/README.md +++ b/packages/test-support/client-runtime/README.md @@ -44,6 +44,8 @@ await runtime.dispose() `mount` prechecks required services and fails loud when one is missing — `provide(name, value)` supplies an extra service first. The runtime provides a `fileUpload` stub that rejects every call until a suite replaces `runtime.fileUpload.upload`. `storeOf(key, scopeKey)` returns the live store instance the renderer hands a slot's component for identity and action-driven-write assertions. +The runtime installs `allowAllAccessPlugin` before ui-session: its `remoteAccess` service answers true for every resource, so every access Hook allows every action. Hand-built component props spread `...allowAllAccess` to receive the same Hooks; a spec of a refused path overrides one Hook. + The optional render options select a keyed entry with `entryKey` or a list item with `only`; `view.update(owner)` retains that selection. `runtime.panelInfo` supplies the default `usePanelInfo` source with no global panel selected. Release it with `releasePanelInfoSource()` before mounting the production Layout owner. `dispose()` releases both default Workspace and panel-info root sources; early release is idempotent and does not remove replacement owners. The Session fixture resolves subagent addresses from explicitly supplied addresses or loaded parent projections without retaining a Session generation. External catalog entries have no local browsing address. @@ -91,7 +93,7 @@ The [generic Remote proxy](../remote-mock/README.md#remote-proxy) supports every ### Roster and startup behavior -`webApp` is the `web` profile's browser roster, read when the assembly entry is first imported from its bundles (`dsh-base`, then `dsh-web-app`) as the launcher composes them, except that a patch matching nothing throws here where the launcher warns: each bundle's `dsh.bundle.patch` list is parsed with the include plugin's YAML dialect and composed by its `applyEntryPatches`, and every enabled row whose package declares `dsh.client.platform === 'web'` becomes a row carrying that declaration's `inject` and `immediately`; `bundleRoster(bundles, anchor, disabledContext)` accepts an explicit Loader evaluation scope for conditional `disabled` flags; without one, browser expressions are rejected. `webApp` supplies the Web profile name, not business Host services. Nothing is copied from the bundles, so a bundle change is visible at the next test run. `webApp.closure(names)` keeps the named rows plus everything they inject, transitively (the rows a spec needs to boot those plugins as the bundle composes them), `webApp.pick(names)` and `webApp.without(names)` cut it down by hand, all three throw on unknown names, and `ClientRoster.of(rows)` builds one inline. `remoteDefaultResponses` holds default responses for exactly the Remote endpoints the roster calls while booting with no sessions, no workspaces, and default settings; a spec layers its own `RemoteTable` on top with `mock.load(table)`, and any call without a rule fails the test at `dispose()` through `mock.assertNoUnmatched()`. `mount` requires a roster that provides `uiRenderer`; `start` fails loud otherwise instead of returning an empty container. `client.connection` is the roster's Connection service (no `Context` augmentation declares it), and `connectTimeoutMs` bounds the readiness wait, whose failure message lists the mock log. `reload(name)` rebuilds one Loader entry through the shared Client Modules helper (registry-first teardown, then `entry.refresh()`); each client retains its own module system and instance-bound Connection replacement, so starts and reloads can overlap without changing a page global. `unload(name)` removes an entry and waits for plugin cleanup; `flush()` settles React inside `act`. jsdom implements neither `EventSource` (client-hmr opens one at apply) nor `ResizeObserver` nor the `document.fonts` event target (layout components observe size and font loading at mount), so `start` reference-counts inert stubs for whichever global is absent and `dispose` removes exactly those — a jsdom gap, not a client identity channel. The `@deepseek-ai/dsh-api-remotes` row is dropped from every roster: its generated Remote clients exist only in built `lib/`, and `remote.` is what the tier replaces anyway. `start` instead provides one contract-free proxy per `remote.` service the roster injects (plus the namespaces the mock has rules for at that point; a namespace first registered later has no proxy); `ctx.remote..(...args)` becomes a call on the endpoint `/` carrying the positional args, a stream when the mock registered a `stream()` script and a unary call otherwise, with the generated client's outcome folding (`gateway/internal` for carrier throws, `gateway/cancelled` on abort). An endpoint without a rule is still dispatched, so the mock logs it and `dispose()` fails the test. +`webApp` is the `web` profile's browser roster, read when the assembly entry is first imported from its bundles (`dsh-base`, then `dsh-web-app`) as the launcher composes them, except that a patch matching nothing throws here where the launcher warns: each bundle's `dsh.bundle.patch` list is parsed with the include plugin's YAML dialect and composed by its `applyEntryPatches`, and every enabled row whose package declares `dsh.client.platform === 'web'` becomes a row carrying that declaration's `inject` and `immediately`; `bundleRoster(bundles, anchor, disabledContext)` accepts an explicit Loader evaluation scope for conditional `disabled` flags; without one, browser expressions are rejected. `webApp` supplies the Web profile name, not business Host services. Nothing is copied from the bundles, so a bundle change is visible at the next test run. `webApp.closure(names)` keeps the named rows plus everything they inject, transitively (the rows a spec needs to boot those plugins as the bundle composes them), `webApp.pick(names)` and `webApp.without(names)` cut it down by hand, all three throw on unknown names, and `ClientRoster.of(rows)` builds one inline. `remoteDefaultResponses` holds default responses for exactly the Remote endpoints the roster calls while booting with no sessions, no workspaces, and default settings; a spec layers its own `RemoteTable` on top with `mock.load(table)`, and any call without a rule fails the test at `dispose()` through `mock.assertNoUnmatched()`. `mount` requires a roster that provides `uiRenderer`; `start` fails loud otherwise instead of returning an empty container. `client.connection` is the roster's Connection service (no `Context` augmentation declares it), and `connectTimeoutMs` bounds the readiness wait, whose failure message lists the mock log. `reload(name)` rebuilds one Loader entry through the shared Client Modules helper (registry-first teardown, then `entry.refresh()`); each client retains its own module system and instance-bound Connection replacement, so starts and reloads can overlap without changing a page global. `unload(name)` removes an entry and waits for plugin cleanup; `flush()` settles React inside `act`. jsdom implements neither `EventSource` (client-hmr opens one at apply) nor `ResizeObserver` nor the `document.fonts` event target (layout components observe size and font loading at mount), so `start` reference-counts inert stubs for whichever global is absent and `dispose` removes exactly those — a jsdom gap, not a client identity channel. The `@deepseek-ai/dsh-api-remotes` row is dropped from every roster: its generated Remote clients exist only in built `lib/`, and `remote.` is what the tier replaces anyway. The `@deepseek-ai/dsh-api-remote-access` row, which mounts its own generated contribution, is dropped too, and `start` installs `allowAllAccessPlugin` in its place; a plan `provide` entry for either row is refused. `start` instead provides one contract-free proxy per `remote.` service the roster injects (plus the namespaces the mock has rules for at that point; a namespace first registered later has no proxy); `ctx.remote..(...args)` becomes a call on the endpoint `/` carrying the positional args, a stream when the mock registered a `stream()` script and a unary call otherwise, with the generated client's outcome folding (`gateway/internal` for carrier throws, `gateway/cancelled` on abort). An endpoint without a rule is still dispatched, so the mock logs it and `dispose()` fails the test. ### When to use it @@ -125,6 +127,7 @@ The bench copies no production logic: it mounts the production `SlotRegistry`, p | [`src/sessions.ts`](src/sessions.ts) + [`src/workspaces.ts`](src/workspaces.ts) | `ISessions`/`IWorkspaces` test doubles and `FixtureSession` behavior stubs | | [`src/fixtures.ts`](src/fixtures.ts) | Plain fixture builders: conversation snapshots, workspace list state | | [`src/snapshot.ts`](src/snapshot.ts) | DOM snapshot serializer (class-hash folding, `` fingerprint) | +| [`src/access.ts`](src/access.ts) | `allowAllAccess` props and `allowAllAccessPlugin`, the allow-all stand-in for the remote-access Client row | | [`src/remote.ts`](src/remote.ts) | `TestRemote` double for host RPC, `RemoteError` value re-export | | [`src/translate.ts`](src/translate.ts) + [`src/locale-env.ts`](src/locale-env.ts) | Translation and pinned-browser-language test helpers | | [`src/config-form.ts`](src/config-form.ts) | `stubConfigForm` with test-driven publications and write spies | diff --git a/packages/test-support/client-runtime/README.zh.md b/packages/test-support/client-runtime/README.zh.md index a603d918c6..8c0a670afd 100644 --- a/packages/test-support/client-runtime/README.zh.md +++ b/packages/test-support/client-runtime/README.zh.md @@ -44,6 +44,8 @@ await runtime.dispose() `mount` 会预检必需服务,缺失时自明报错——先用 `provide(name, value)` 提供额外服务。运行时提供的 `fileUpload` 替身会拒绝每次调用,直到测试套件替换 `runtime.fileUpload.upload`。`storeOf(key, scopeKey)` 返回渲染器交给 slot 组件的实时存储实例,用于身份与动作驱动写入断言。 +运行时在 ui-session 之前安装 `allowAllAccessPlugin`:它的 `remoteAccess` 服务对每个资源都回答 true,因此每个访问 Hook 都允许每个 action。手工构造的组件 props 展开 `...allowAllAccess` 以获得同样的 Hook;测试被拒绝路径的 spec 覆盖其中一个 Hook。 + 可选渲染参数通过 `entryKey` 选择 keyed 条目,或通过 `only` 选择 list 条目;`view.update(owner)` 保留该选择。`runtime.panelInfo` 提供默认的 `usePanelInfo` 数据源,初始不选中全局面板。挂载生产 Layout 所有者之前,先调用 `releasePanelInfoSource()` 释放该数据源。`dispose()` 同时释放默认的工作区与面板信息根数据源;提前释放是幂等的,不会移除替代它们的所有者。 Session fixture 从显式提供的地址或已加载的父级投影解析子代理地址,不会保留 Session generation。外部目录项没有本地浏览地址。 @@ -91,7 +93,7 @@ test('registers into the sidebar', async ({ remote, start }) => { ### Roster 与启动行为 -`webApp` 是 `web` profile 的浏览器 roster,首次 import 装配入口时从它的 bundle(先 `dsh-base`、再 `dsh-web-app`)按启动器的方式现读,只是匹配不到任何行的补丁在这里抛错、启动器只警告:每个 bundle 的 `dsh.bundle.patch` 列表用 include 插件的 YAML 方言解析、用它的 `applyEntryPatches` 合成,每个未禁用且其包声明 `dsh.client.platform === 'web'` 的行成为一行,带上该声明的 `inject` 与 `immediately`;`bundleRoster(bundles, anchor, disabledContext)` 接受显式 Loader 求值作用域来处理条件 `disabled` 标记;未提供作用域时拒绝浏览器表达式。`webApp` 仅提供 Web profile 名称,不提供业务 Host 服务。没有任何东西从 bundle 拷贝出来,bundle 一改下次跑测试就能看见。`webApp.closure(names)` 保留点名的行及其传递注入的全部行(即按 bundle 组合方式起这些插件所需的行),`webApp.pick(names)` 与 `webApp.without(names)` 手工裁剪,三者都对未知名字抛错,`ClientRoster.of(rows)` 内联构造一份。`remoteDefaultResponses` 是 roster 在没有 session、没有 workspace、默认设置下启动时恰好会打的那些 Remote 端点的默认响应;测试用 `mock.load(table)` 在其上叠加自己的 `RemoteTable`,任何没有规则的调用都会在 `dispose()` 时经 `mock.assertNoUnmatched()` 让测试失败。`mount` 要求 roster 提供 `uiRenderer`;否则 `start` 响亮失败而不是返回一个空容器。`client.connection` 是 roster 的 Connection 服务(没有任何 `Context` 增强声明它),`connectTimeoutMs` 限定等就绪的时长,超时消息列出 mock log。`reload(name)` 通过共享的 Client Modules 辅助函数重建一个 Loader entry(先拆 registry,再 `entry.refresh()`);每个客户端都保留自己的模块系统和绑定实例的 Connection 替换,因此启动与重载可以重叠而无需改写页面全局变量。`unload(name)` 移除 entry 并等待插件清理完成,`flush()` 在 `act` 内让 React 落定。jsdom 既没有 `EventSource`(client-hmr 在 apply 时打开一个)也没有 `ResizeObserver` 和 `document.fonts` 事件目标(布局组件挂载时观察尺寸与字体加载),所以 `start` 对缺失的全局惰性桩做引用计数,`dispose` 只移除它装的那些——这是 jsdom 的缺口,不是客户端身份通道。每个 roster 里的 `@deepseek-ai/dsh-api-remotes` 行都会被去掉:它生成的 Remote 客户端只存在于构建后的 `lib/`,而 `remote.` 正是本档要替掉的东西。`start` 改为给 roster 注入的每个 `remote.` 服务(加上此刻 mock 登记过规则的命名空间;之后才首次登记的命名空间没有代理)提供一个无契约代理;`ctx.remote..(...args)` 变成对端点 `/` 的调用,携带位置参数,mock 登记了 `stream()` 脚本的走流、否则走一元,并沿用生成客户端的结果折叠(载体抛错折成 `gateway/internal`,中止折成 `gateway/cancelled`)。没有规则的端点照样发出,所以 mock 会记下它、`dispose()` 让测试失败。 +`webApp` 是 `web` profile 的浏览器 roster,首次 import 装配入口时从它的 bundle(先 `dsh-base`、再 `dsh-web-app`)按启动器的方式现读,只是匹配不到任何行的补丁在这里抛错、启动器只警告:每个 bundle 的 `dsh.bundle.patch` 列表用 include 插件的 YAML 方言解析、用它的 `applyEntryPatches` 合成,每个未禁用且其包声明 `dsh.client.platform === 'web'` 的行成为一行,带上该声明的 `inject` 与 `immediately`;`bundleRoster(bundles, anchor, disabledContext)` 接受显式 Loader 求值作用域来处理条件 `disabled` 标记;未提供作用域时拒绝浏览器表达式。`webApp` 仅提供 Web profile 名称,不提供业务 Host 服务。没有任何东西从 bundle 拷贝出来,bundle 一改下次跑测试就能看见。`webApp.closure(names)` 保留点名的行及其传递注入的全部行(即按 bundle 组合方式起这些插件所需的行),`webApp.pick(names)` 与 `webApp.without(names)` 手工裁剪,三者都对未知名字抛错,`ClientRoster.of(rows)` 内联构造一份。`remoteDefaultResponses` 是 roster 在没有 session、没有 workspace、默认设置下启动时恰好会打的那些 Remote 端点的默认响应;测试用 `mock.load(table)` 在其上叠加自己的 `RemoteTable`,任何没有规则的调用都会在 `dispose()` 时经 `mock.assertNoUnmatched()` 让测试失败。`mount` 要求 roster 提供 `uiRenderer`;否则 `start` 响亮失败而不是返回一个空容器。`client.connection` 是 roster 的 Connection 服务(没有任何 `Context` 增强声明它),`connectTimeoutMs` 限定等就绪的时长,超时消息列出 mock log。`reload(name)` 通过共享的 Client Modules 辅助函数重建一个 Loader entry(先拆 registry,再 `entry.refresh()`);每个客户端都保留自己的模块系统和绑定实例的 Connection 替换,因此启动与重载可以重叠而无需改写页面全局变量。`unload(name)` 移除 entry 并等待插件清理完成,`flush()` 在 `act` 内让 React 落定。jsdom 既没有 `EventSource`(client-hmr 在 apply 时打开一个)也没有 `ResizeObserver` 和 `document.fonts` 事件目标(布局组件挂载时观察尺寸与字体加载),所以 `start` 对缺失的全局惰性桩做引用计数,`dispose` 只移除它装的那些——这是 jsdom 的缺口,不是客户端身份通道。每个 roster 里的 `@deepseek-ai/dsh-api-remotes` 行都会被去掉:它生成的 Remote 客户端只存在于构建后的 `lib/`,而 `remote.` 正是本档要替掉的东西。自行挂载其生成 contribution 的 `@deepseek-ai/dsh-api-remote-access` 行同样会被去掉,`start` 改为安装 `allowAllAccessPlugin`;计划中为这两行之一提供的 `provide` 项会被拒绝。`start` 改为给 roster 注入的每个 `remote.` 服务(加上此刻 mock 登记过规则的命名空间;之后才首次登记的命名空间没有代理)提供一个无契约代理;`ctx.remote..(...args)` 变成对端点 `/` 的调用,携带位置参数,mock 登记了 `stream()` 脚本的走流、否则走一元,并沿用生成客户端的结果折叠(载体抛错折成 `gateway/internal`,中止折成 `gateway/cancelled`)。没有规则的端点照样发出,所以 mock 会记下它、`dispose()` 让测试失败。 ### 何时使用 @@ -125,6 +127,7 @@ test('registers into the sidebar', async ({ remote, start }) => { | [`src/sessions.ts`](src/sessions.ts) + [`src/workspaces.ts`](src/workspaces.ts) | `ISessions`/`IWorkspaces` 测试替身与 `FixtureSession` 行为桩 | | [`src/fixtures.ts`](src/fixtures.ts) | 普通 fixture 构造器:会话快照、workspace 列表状态 | | [`src/snapshot.ts`](src/snapshot.ts) | DOM 快照序列化器(类名哈希折叠、`` 指纹) | +| [`src/access.ts`](src/access.ts) | `allowAllAccess` props 与 `allowAllAccessPlugin`:remote-access Client 行的全允许替身 | | [`src/remote.ts`](src/remote.ts) | 用于 host RPC 的 `TestRemote` 替身、`RemoteError` 值转出 | | [`src/translate.ts`](src/translate.ts) + [`src/locale-env.ts`](src/locale-env.ts) | 翻译与固定浏览器语言测试辅助 | | [`src/config-form.ts`](src/config-form.ts) | 带测试驱动发布与写入 spy 的 `stubConfigForm` | diff --git a/packages/test-support/client-runtime/package.json b/packages/test-support/client-runtime/package.json index e2f131880a..2144ed5c47 100644 --- a/packages/test-support/client-runtime/package.json +++ b/packages/test-support/client-runtime/package.json @@ -59,6 +59,7 @@ "@deepseek-ai/cordis-plugin-include": "workspace:~", "@deepseek-ai/cordis-plugin-loader": "workspace:~", "@deepseek-ai/dsh-api-gateway": "workspace:*", + "@deepseek-ai/dsh-api-remote-access": "workspace:*", "@deepseek-ai/dsh-api-remotes": "workspace:*", "@deepseek-ai/dsh-api-session-controller": "workspace:*", "@deepseek-ai/dsh-api-workspace-controller": "workspace:*", @@ -76,6 +77,7 @@ "@deepseek-ai/dsh-client-ui-session": "workspace:*", "@deepseek-ai/dsh-client-ui-settings": "workspace:*", "@deepseek-ai/dsh-client-ui-slots": "workspace:*", + "@deepseek-ai/dsh-client-ui-workspace": "workspace:*", "@deepseek-ai/dsh-client-web": "workspace:*", "@deepseek-ai/dsh-remote-mock": "workspace:*", "@deepseek-ai/dsh-session": "workspace:*", diff --git a/packages/test-support/client-runtime/src/access.ts b/packages/test-support/client-runtime/src/access.ts new file mode 100644 index 0000000000..4c467ac61f --- /dev/null +++ b/packages/test-support/client-runtime/src/access.ts @@ -0,0 +1,53 @@ +/** + * Allow-all access for client specs. Production pages receive the + * `remoteAccess` Client service and the global-resource `useRemoteAccess` Hook + * from the remote-access Client plugin, and the Session and Workspace access + * Hooks (Session-scope `useAccess`, `useSessionAccess`, `useWorkspaceAccess`) + * from ui-session and ui-workspace over that service. The test runtimes + * install an allow-all service and `useRemoteAccess` Hook instead, and + * hand-built component props spread {@link allowAllAccess}; a spec of a + * refused path overrides one Hook. + * @module @deepseek-ai/dsh-client-test-runtime/src/access + */ + +import type { Context } from '@deepseek-ai/cordis' +import type { ClientRemoteAccess } from '@deepseek-ai/dsh-api-remote-access/client' +import type { GlobalStandardProps, HostObservable, SessionStandardProps } from '@deepseek-ai/dsh-client-ui-slots' +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +import type {} from '@deepseek-ai/dsh-client-ui-session/client' +import type {} from '@deepseek-ai/dsh-client-ui-workspace/client' + +/** The access Hooks every Slot component receives. */ +export type AccessStandardProps = + & Pick + & Pick + +/** Access Hooks that allow every action on every resource, for hand-built component props. */ +export const allowAllAccess: AccessStandardProps = { + useRemoteAccess: () => true, + useAccess: () => true, + useSessionAccess: () => true, + useWorkspaceAccess: () => true, +} + +const ALLOWED: HostObservable = { getSnapshot: () => true, subscribe: () => () => {} } +const allowed = (): HostObservable => ALLOWED + +/** The `remoteAccess` Client service answering true for every resource. */ +const allowAllRemoteAccess: ClientRemoteAccess = { source: allowed } + +/** + * Plugin standing in for the remote-access Client row: it provides an + * allow-all `remoteAccess` service, so ui-session and ui-workspace publish + * their resource Hooks over it, and the allow-all `useRemoteAccess` Hook on + * the global resource. + */ +export const allowAllAccessPlugin = { + name: 'client-test-runtime:allow-all-access', + inject: ['slots'], + /** @param ctx - plugin Context with the Slot registry injected. */ + apply(ctx: Context): void { + ctx.provide('remoteAccess', allowAllRemoteAccess) + ctx.slots.provideRoot({ keyedHooks: { remoteAccess: allowed } }) + }, +} diff --git a/packages/test-support/client-runtime/src/assembly/test-client.ts b/packages/test-support/client-runtime/src/assembly/test-client.ts index 2a54d235bc..acec237c87 100644 --- a/packages/test-support/client-runtime/src/assembly/test-client.ts +++ b/packages/test-support/client-runtime/src/assembly/test-client.ts @@ -18,6 +18,17 @@ import { act } from '@testing-library/react' import { createInProcessModules, loadPluginModules } from './modules.ts' import { assertPlan, graphFromRoster, type AssemblyPlan } from './roster.ts' import { REMOTES_PACKAGE, remoteNamespacesOf, remoteProxiesPlugin } from './remote-proxies.ts' +import { allowAllAccessPlugin } from '../access.ts' + +/** The remote-access row: it mounts its own generated Remote contribution and publishes the access Hooks. */ +const REMOTE_ACCESS_PACKAGE = '@deepseek-ai/dsh-api-remote-access' + +/** + * Rows whose generated Remote contributions exist only in built `lib/`. The + * tier drops them: `remote.` proxies stand in for the namespaces and + * allow-all access Hooks stand in for remote-access. + */ +const GENERATED_REMOTE_PACKAGES: readonly string[] = [REMOTES_PACKAGE, REMOTE_ACCESS_PACKAGE] /** Carrier options. */ export interface TestClientOptions { @@ -181,8 +192,10 @@ export class TestClient { * dropped from the roster: its generated Remote clients exist only in built * `lib/`, and the `remote.` services the roster injects (plus the * namespaces the mock has rules for at this point) are provided as - * contract-free proxies over the same Connection instead; a `provide` entry - * for that row is refused. On any failure the context is disposed, an owned + * contract-free proxies over the same Connection instead. The + * `@deepseek-ai/dsh-api-remote-access` row, which mounts its own generated + * contribution, is dropped too, and allow-all access Hooks replace it. A + * `provide` entry for either row is refused. On any failure the context is disposed, an owned * mount removed, and this client's hold on the shims released before the * original error is rethrown. * @param plan - roster and annotations. @@ -196,12 +209,13 @@ export class TestClient { options: TestClientOptions = {}, ): Promise { assertPlan(plan) - if (plan.provide?.[REMOTES_PACKAGE] !== undefined) { - throw new Error(`client-test-runtime: ${REMOTES_PACKAGE} cannot be provided; its remote. services are the tier's proxies`) + for (const name of GENERATED_REMOTE_PACKAGES) { + if (plan.provide?.[name] !== undefined) { + throw new Error(`client-test-runtime: ${name} cannot be provided; the tier replaces its generated Remote mount`) + } } - const roster = plan.roster.rows.some(row => row.name === REMOTES_PACKAGE) - ? plan.roster.without([REMOTES_PACKAGE]) - : plan.roster + const generated = GENERATED_REMOTE_PACKAGES.filter(name => plan.roster.rows.some(row => row.name === name)) + const roster = generated.length > 0 ? plan.roster.without(generated) : plan.roster const ctx = new Context() const pageLocation = typeof location === 'undefined' ? undefined @@ -230,6 +244,8 @@ export class TestClient { release = sharedJsdomShims.acquire() const system = createInProcessModules(graphFromRoster(roster.rows), modules) ctx.plugin(remoteProxiesPlugin(remoteNamespacesOf(modules.values(), mock), mock) as Plugin) + // The dropped remote-access row's Hooks: every action is allowed on every resource. + if (generated.includes(REMOTE_ACCESS_PACKAGE)) ctx.plugin(allowAllAccessPlugin) await bootClient({ ctx, modules: system, manifest: system.manifest }) if (mountPoint.element !== undefined) { if (ctx.get('uiRenderer') === undefined) { diff --git a/packages/test-support/client-runtime/src/index.ts b/packages/test-support/client-runtime/src/index.ts index 67c37d8e1a..40403354af 100644 --- a/packages/test-support/client-runtime/src/index.ts +++ b/packages/test-support/client-runtime/src/index.ts @@ -42,6 +42,7 @@ import { TestRemote } from './remote.ts' import { TestSessions } from './sessions.ts' import { TestWorkspaces } from './workspaces.ts' import type { Stabilizer } from './fixtures.ts' +import { allowAllAccessPlugin } from './access.ts' export type { UseSession } from '@deepseek-ai/dsh-client-ui-session/client' export { domSnapshotSerializer, registerDomSnapshotSerializer } from './snapshot.ts' @@ -57,6 +58,8 @@ export type { FixtureSnapshot, SessionBehaviorOverrides, SessionFixture, SessionFixtureSnapshot, Stabilizer, } from './fixtures.ts' export { makeTranslate } from './translate.ts' +export { allowAllAccess, allowAllAccessPlugin } from './access.ts' +export type { AccessStandardProps } from './access.ts' export { usePinnedBrowserLanguages } from './locale-env.ts' /** @@ -284,6 +287,7 @@ export class SlotTestRuntime { const fiber = ctx.plugin(SlotRegistry) await fiber.await() const runtime = new SlotTestRuntime(ctx, ctx.get('slots') as SlotRegistry) + await ctx.plugin(allowAllAccessPlugin).await() await ctx.plugin({ inject: [...uiSessionInject], apply: applyUiSession }).await() return runtime } diff --git a/packages/test-support/client-runtime/tests/assembly-test-client-node.client.spec.ts b/packages/test-support/client-runtime/tests/assembly-test-client-node.client.spec.ts index 748b6d3eab..e51ce69892 100644 --- a/packages/test-support/client-runtime/tests/assembly-test-client-node.client.spec.ts +++ b/packages/test-support/client-runtime/tests/assembly-test-client-node.client.spec.ts @@ -48,10 +48,22 @@ describe('TestClient (node environment)', () => { expect(client.connection.state.getSnapshot()).toBe('connected') }) - it('refuses a provide entry for the api-remotes row, whose services are the proxies', async () => { - const roster = webApp.closure(['@deepseek-ai/dsh-api-remotes']) - await expect(TestClient.start({ roster, provide: { '@deepseek-ai/dsh-api-remotes': { apply() {} } } }, RemoteMock.create())) - .rejects.toThrow('@deepseek-ai/dsh-api-remotes cannot be provided; its remote. services are the tier\'s proxies') + it.each(['@deepseek-ai/dsh-api-remotes', '@deepseek-ai/dsh-api-remote-access'])( + 'refuses a provide entry for the %s row, whose generated Remote mount the tier replaces', + async (name) => { + const roster = webApp.closure([name]) + await expect(TestClient.start({ roster, provide: { [name]: { apply() {} } } }, RemoteMock.create())) + .rejects.toThrow(`${name} cannot be provided; the tier replaces its generated Remote mount`) + }, + ) + + it('drops the remote-access row and answers every access question with true', async () => { + const mock = RemoteMock.create().load(remoteDefaultResponses) + const client = await TestClient.start({ roster: webApp.closure(['@deepseek-ai/dsh-api-remote-access']) }, mock) + onTestFinished(() => client.dispose()) + expect(client.ctx.get('remoteAccess')?.source('session', 's1', 'session/fork').getSnapshot()).toBe(true) + // The dropped row never mounts its namespace, so no access stream opens. + expect(mock.log.streams('remoteAccess/follow')).toEqual([]) }) it('reports unmatched requests alongside a failed teardown instead of hiding them', async () => { diff --git a/packages/test-support/client-runtime/tests/helpers.client.spec.tsx b/packages/test-support/client-runtime/tests/helpers.client.spec.tsx index 6fe307748b..9c8cf56867 100644 --- a/packages/test-support/client-runtime/tests/helpers.client.spec.tsx +++ b/packages/test-support/client-runtime/tests/helpers.client.spec.tsx @@ -5,10 +5,12 @@ import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { EMPTY_CHAT_SNAPSHOT } from '@deepseek-ai/dsh-client-ui-chat/client' import { EMPTY_CONVERSATION_SNAPSHOT } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { WorkspaceId } from '@deepseek-ai/dsh-api-workspace-controller/client' import type { MainPanelId, PanelInfo } from '@deepseek-ai/dsh-client-ui-layout/client' import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import { afterAll, afterEach, describe, expect, it, vi } from 'vitest' import { + allowAllAccess, bindSnapshotSelector, chatSnapshot, conversationSnapshot, @@ -46,6 +48,26 @@ function entry(seq: number): SessionLiveEventEntry { } describe('fixture helpers', () => { + it('allows every action on every resource through the hand-built access Hooks', () => { + expect(allowAllAccess.useRemoteAccess('session/list')).toBe(true) + expect(allowAllAccess.useAccess('session/fork')).toBe(true) + expect(allowAllAccess.useSessionAccess('s1' as SessionId, 'session/fork')).toBe(true) + expect(allowAllAccess.useWorkspaceAccess('w1' as WorkspaceId, 'workspace/follow')).toBe(true) + }) + + it('installs an allow-all remoteAccess service whose sources never change', async () => { + const runtime = await SlotTestRuntime.create() + try { + const source = runtime.ctx.remoteAccess.source('session', 's1', 'session/fork') + expect(source.getSnapshot()).toBe(true) + const unsubscribe = source.subscribe(() => {}) + unsubscribe() + expect(source.getSnapshot()).toBe(true) + } finally { + await runtime.dispose() + } + }) + it('retracts default root sources without removing their live replacements', async () => { const runtime = await SlotTestRuntime.create() const hooks = { workspaces: runtime.workspaces.list, panelInfo: runtime.panelInfo } diff --git a/packages/test-support/client-runtime/tsconfig.json b/packages/test-support/client-runtime/tsconfig.json index 5503b5e71a..0867ea9b86 100644 --- a/packages/test-support/client-runtime/tsconfig.json +++ b/packages/test-support/client-runtime/tsconfig.json @@ -27,6 +27,9 @@ { "path": "../../api/workspace-controller/tsconfig.client.json" }, + { + "path": "../../api/remote-access/tsconfig.client.json" + }, { "path": "../../attachment/attachment" }, @@ -57,6 +60,9 @@ { "path": "../../client/ui-renderer" }, + { + "path": "../../client/ui-workspace" + }, { "path": "../../client/ui-session" }, From a18488955d53ada841921d56e3a07695d97ed371 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:49:04 +0800 Subject: [PATCH 21/35] docs: sync subsystem docs and regenerate catalogs --- apps/cli/composition.md | 3 + docs/api-gateway.i18n.yaml | 12 +- docs/api-gateway.md | 5 +- docs/api-gateway.zh.md | 5 +- docs/capability-seams.i18n.yaml | 4 +- docs/capability-seams.md | 7 + docs/capability-seams.zh.md | 7 + docs/config-catalog.md | 17 +- docs/config-catalog.zh.md | 17 +- .../remote-and-api.i18n.yaml | 4 +- docs/defensive-patterns/remote-and-api.md | 5 +- docs/defensive-patterns/remote-and-api.zh.md | 5 +- docs/event-producer-consumer.md | 14 +- docs/event-producer-consumer.zh.md | 14 +- docs/module-graph.md | 5 +- docs/module-graph.zh.md | 5 +- docs/subsystems/session.md | 8 +- docs/subsystems/session.zh.md | 8 +- docs/subsystems/slots.i18n.yaml | 4 +- docs/subsystems/slots.md | 9 +- docs/subsystems/slots.zh.md | 9 +- docs/subsystems/typert.i18n.yaml | 11 +- docs/subsystems/typert.md | 200 +++++++- docs/subsystems/typert.zh.md | 200 +++++++- docs/subsystems/web-client.i18n.yaml | 4 +- docs/subsystems/web-client.md | 2 +- docs/subsystems/web-client.zh.md | 2 +- docs/subsystems/web-server.md | 18 +- docs/subsystems/web-server.zh.md | 18 +- docs/subsystems/workspace.md | 10 +- docs/subsystems/workspace.zh.md | 10 +- .../src/client/api-catalog.ts | 2 +- .../src/client/slot-catalog.ts | 458 ++++++++++++++---- .../extensions/tool-cordis/src/api-catalog.ts | 169 ++++++- .../references/packages.md | 1 + scripts/gen-client-catalog.ts | 15 +- scripts/gen-cordis-catalog.ts | 10 + scripts/gen-doc-graphs.ts | 8 + scripts/no-unknown-casts.baseline.json | 1 - scripts/type-equiv.manifest.json | 35 ++ 40 files changed, 1101 insertions(+), 240 deletions(-) diff --git a/apps/cli/composition.md b/apps/cli/composition.md index 9888602296..8b2cfb83ab 100644 --- a/apps/cli/composition.md +++ b/apps/cli/composition.md @@ -30,6 +30,8 @@ flowchart LR cfg --> plugin_dsh_base_typert_loader plugin_dsh_base_typert_gateway["typert-gateway
@deepseek-ai/dsh-api-gateway"] cfg --> plugin_dsh_base_typert_gateway + plugin_dsh_base_remote_access["remote-access
@deepseek-ai/dsh-api-remote-access"] + cfg --> plugin_dsh_base_remote_access plugin_dsh_base_session_title["session-title
@deepseek-ai/dsh-session-title"] cfg --> plugin_dsh_base_session_title plugin_dsh_base_session_title_llm["session-title-llm
@deepseek-ai/dsh-session-title-first-prompt-llm"] @@ -211,6 +213,7 @@ flowchart LR | `typert` | `@deepseek-ai/dsh-typert-registry` | | `typert-loader` | `@deepseek-ai/dsh-typert-loader` | | `typert-gateway` | `@deepseek-ai/dsh-api-gateway` | +| `remote-access` | `@deepseek-ai/dsh-api-remote-access` | | `session-title` | `@deepseek-ai/dsh-session-title` | | `session-title-llm` | `@deepseek-ai/dsh-session-title-first-prompt-llm` | | `user-questions` | `@deepseek-ai/dsh-user-questions` | diff --git a/docs/api-gateway.i18n.yaml b/docs/api-gateway.i18n.yaml index 3f9e8b79ab..65b0c3e196 100644 --- a/docs/api-gateway.i18n.yaml +++ b/docs/api-gateway.i18n.yaml @@ -6,17 +6,17 @@ en: 735eb4f22d5f6f71 zh: 697eef269b133039 /api-gateway/programming-model: - en: ea12b64211f0d377 - zh: 032e5c0d799c7529 + en: 78eb70d6c2ce6608 + zh: 826f0ccc206b7299 /api-gateway/component-responsibilities: - en: e29f1d7026291075 - zh: 15e361e797d44fce + en: fe382a9bcc768cc2 + zh: 78e3d50d59780c3d /api-gateway/strict-generation-pipeline: en: 9c4a98eb0539ec7f zh: ad2a145abd805fdd /api-gateway/runtime-invocation: - en: 2a2fed340aaa926c - zh: 46bb1f500c870a76 + en: 37b4858fa48a61b6 + zh: 9a296a2171f1ab7d /api-gateway/src-development-fallback: en: 9570ffe7a348e5ac zh: 2b70b61b1ff1fbb0 diff --git a/docs/api-gateway.md b/docs/api-gateway.md index 0a891cd244..484b866e95 100644 --- a/docs/api-gateway.md +++ b/docs/api-gateway.md @@ -12,6 +12,8 @@ Business services use `@Remote` or `@RemoteScope` to select the methods exposed `@RemoteScope(key)` first resolves an identity to a scoped Context through `ctx.typert.contexts`, then obtains the service from that Context and invokes the method. It applies when the method itself depends on scoped composition and does not need to receive objects such as `Agent` explicitly. +`@Remote` accepts an export name or `{ name?, mode?, resources? }`, and `@RemoteScope(key, option?)` accepts an export name, as in `@RemoteScope('agent', 'current')` below, or `{ name?, resources? }` as its second argument. `resources` holds per-kind readers that return the identities of the resources one call touches for access control; each reader receives the method's wire arguments as its own parameters, with types inferred from the decorated method. Most methods declare no readers, because the Gateway also reads identities from lookup parameters and scoped Contexts that declare a `resource`, and from the default fields in `typert.resources`; the [Typert subsystem](subsystems/typert.md) owns that order. + Services normally extend `TypertRemoteService` so the constructor explicitly binds the Cordis service key and default Remote namespace. A service that already has another base class can instead declare `readonly typertRemote = bindTypertRemote(this, serviceKey)`; both forms leave an inspectable public binding and do not depend on the compiler injecting a symbol into the constructor. ```ts @@ -88,6 +90,7 @@ The `api-remotes` assembly and the `ctx.remote` contract are React-independent; | Host | `@deepseek-ai/dsh-typert-registry` and Loader | Places generated Host descriptors, schemas, and business-package registrations in `ctx.typert`, and holds lookup and Context providers | | Host | `@deepseek-ai/dsh-api-session-controller` | Owns the application Agent/Session identity policy and configures the corresponding Typert lookups | | Host | `@deepseek-ai/dsh-api-gateway` | Provides `ctx.typertGateway`, claims Remote endpoints, validates request values, resolves objects or Contexts, and invokes live Cordis services | +| Host | `@deepseek-ai/dsh-api-remote-access` | Decides which Remote methods a non-owner Peer may call through business-registered role-based policies, filters list results, and streams each Peer's access snapshot; the Gateway requires it | | Client | `@deepseek-ai/dsh-api-gateway/client` | Provides `ctx.remote` and `remote.` child Services, mounts generated descriptors as concrete methods, and initiates and cancels calls through the Connection | | Client | `@deepseek-ai/dsh-api-remotes/client` | Explicitly selects and mounts the `/remote` contributions allowed by the application and brings the corresponding declaration merges into business code | | Both | `@deepseek-ai/dsh-client-connection` | Provides the RPC carrier, request correlation, trust boundary, cancellation, response envelope, and the `/api` HTTP bridge | @@ -124,7 +127,7 @@ Remote calls use the Connection's `/api` route. The Client Remote calls `connect The Connection performs the unified trust check for `/api` before the HTTP bridge, then dispatches inside the shared FetchHandler. The Typert Gateway claims only two-segment endpoints that have a strict descriptor or active SRC marker; it projects binary Remote fields into JSON-compatible metadata and result-relative byte attachments, which Connection frames as multipart responses. Feature-owned exact Fetch routes handle responses outside the RPC envelope, and other requests return 404. Connection owns transport, RPC ids, response envelopes, and request cancellation, while Gateway owns the Remote data protocol and business dispatch. Replacing the Connection carrier does not require changes to Remote descriptors or the Client programming interface. -For every call, the Gateway resolves the descriptor and live service from the current registries instead of caching business objects. It requires the fields in `args` to match the descriptor exactly, validates wire values with codecs, resolves objects or receivers through registered lookup or Context providers, and invokes the service method targeted by the binding. The Client Gateway validates decoded binary results with the generated result codec; ordinary JSON results retain their existing handling. A missing provider, unknown identity, binding mismatch, missing or extra argument, schema failure, or missing method fails before entering or after leaving business code. +For every call, the Gateway resolves the descriptor and live service from the current registries instead of caching business objects. Every call names the Peer it speaks for, and a disposed Peer is refused with `gateway/peer-disposed`. The Gateway requires the fields in `args` to match the descriptor exactly; for a call whose Peer principal is not the owner it then resolves the resources the call touches and asks `ctx.remoteAccess.decide()`, so a refused call resolves no lookup and activates no Agent. It validates wire values with codecs, resolves objects or receivers through registered lookup or Context providers, and invokes the service method targeted by the binding. The Client Gateway validates decoded binary results with the generated result codec; ordinary JSON results retain their existing handling. A missing provider, unknown identity, binding mismatch, missing or extra argument, schema failure, or missing method fails before entering or after leaving business code. The lookup provider's `register()` supplies both the stable declaration and the default resolver; `configure()` supplies a resolver owned by Host composition that may execute asynchronously and is scoped to an effect lifetime. Configuration may precede provider mounting; without a provider, invocation still fails with `gateway/lookup-unavailable`, and unloading the configuration restores the provider's default policy. The Session Controller owns the standard resolver semantics for `agent` and `session`: it reuses a live Agent, automatically resumes ordinary cold sessions, deduplicates concurrent resumes, and rejects identities owned by subagent routing; the `session` lookup returns that Agent's Session. A resume failure and an ownership fence raise a `RemoteError` carrying their own code, `session/not-found` or `session/agent-busy`, which the Gateway encodes onto the wire unchanged; only an unclassified throw folds into `gateway/internal`. diff --git a/docs/api-gateway.zh.md b/docs/api-gateway.zh.md index 4cc33ee24b..c44d6f403f 100644 --- a/docs/api-gateway.zh.md +++ b/docs/api-gateway.zh.md @@ -14,6 +14,8 @@ `@RemoteScope(key)` 表示先通过 `ctx.typert.contexts` 把 identity 解析为一个作用域 Context,再从该 Context 取得服务并调用方法。它适用于方法本身依赖作用域组合、而不需要显式接收 `Agent` 等对象的情形。 +`@Remote` 接受导出名或 `{ name?, mode?, resources? }`;`@RemoteScope(key, option?)` 的第二个参数接受导出名(如下文的 `@RemoteScope('agent', 'current')`)或 `{ name?, resources? }`。`resources` 按资源种类给出读取函数,返回一次调用所访问资源的身份,供访问控制使用;每个读取函数以方法的 wire 参数作为自身参数,类型从被装饰方法推断。多数方法不声明读取函数,因为 Gateway 还会从声明了 `resource` 的 lookup 参数与作用域 Context 以及 `typert.resources` 中的默认字段读取身份;[Typert 子系统](subsystems/typert.zh.md)负责该顺序。 + 服务通常继承 `TypertRemoteService`,让 Cordis 服务 key 与默认 Remote namespace 在构造器中显式绑定。已有其他基类的服务可以改为声明 `readonly typertRemote = bindTypertRemote(this, serviceKey)`;两种方式都会留下可检查的公开 binding,不依赖编译器向构造函数注入 symbol。 ```ts @@ -90,6 +92,7 @@ Client 应用只装配 `@deepseek-ai/dsh-api-remotes`。该包以运行时值导 | Host | `@deepseek-ai/dsh-typert-registry` 与 Loader | 把生成的 Host 描述符、schema 及业务包注册项放入 `ctx.typert`,并持有 lookup 与 Context 提供方 | | Host | `@deepseek-ai/dsh-api-session-controller` | 负责应用的 Agent/Session 身份策略,并配置对应的 Typert lookup | | Host | `@deepseek-ai/dsh-api-gateway` | 提供 `ctx.typertGateway`,认领 Remote endpoint,校验请求值,解析对象或 Context,并调用实时 Cordis 服务 | +| Host | `@deepseek-ai/dsh-api-remote-access` | 按业务方注册的基于 role 的 policy 判定非 owner Peer 可调用哪些 Remote 方法,过滤列表结果,并为每个 Peer 推送访问快照;Gateway 依赖它 | | Client | `@deepseek-ai/dsh-api-gateway/client` | 提供 `ctx.remote` 与 `remote.` 子服务,把生成的描述符挂成具体方法,并通过 Connection 发起和取消调用 | | Client | `@deepseek-ai/dsh-api-remotes/client` | 显式选择并挂载本应用允许使用的 `/remote` 贡献,向业务代码带入对应的声明合并 | | 双侧 | `@deepseek-ai/dsh-client-connection` | 提供 RPC carrier、请求关联、信任边界、取消、响应 envelope 与 `/api` HTTP bridge | @@ -126,7 +129,7 @@ Remote 调用使用 Connection 的 `/api` 路由。Client Remote 调用 `connect Connection 在 HTTP bridge 之前执行 `/api` 的统一信任检查,再在共享 FetchHandler 内分发。Typert Gateway 只认领存在严格描述符或活跃 SRC marker 的两段式 endpoint;它把二进制 Remote 字段投影为 JSON 兼容元数据及相对于结果的字节附件,再由 Connection 封装为 multipart 响应。功能自有的精确 Fetch 路由处理 RPC 信封之外的响应,其他请求返回 404。Connection 拥有传输、RPC id、响应 envelope 和请求取消,Gateway 拥有 Remote 数据协议和业务分发。替换 Connection carrier 不要求改变 Remote 描述符或 Client 编程接口。 -Gateway 每次调用都从当前注册表解析描述符和实时服务,不缓存业务对象。它要求 `args` 的字段集合与描述符完全一致,先用 codec 校验 wire 值,再通过注册的 lookup 或 Context 提供方解析对象或接收者,最后调用 binding 指向的服务方法。Client Gateway 使用生成的结果 codec 校验解码后的二进制结果;普通 JSON 结果保持原有处理方式。缺少提供方、identity 未命中、binding 不一致、参数缺失或多余、schema 失败和方法不存在都会在进入业务代码前或离开业务代码后失败。 +Gateway 每次调用都从当前注册表解析描述符和实时服务,不缓存业务对象。每次调用都给出其代表的 Peer,已 dispose 的 Peer 以 `gateway/peer-disposed` 被拒。Gateway 要求 `args` 的字段集合与描述符完全一致;对 Peer principal 不是 owner 的调用,随后解析调用访问的资源并询问 `ctx.remoteAccess.decide()`,被拒的调用不解析任何 lookup,也不激活 Agent。之后它先用 codec 校验 wire 值,再通过注册的 lookup 或 Context 提供方解析对象或接收者,最后调用 binding 指向的服务方法。Client Gateway 使用生成的结果 codec 校验解码后的二进制结果;普通 JSON 结果保持原有处理方式。缺少提供方、identity 未命中、binding 不一致、参数缺失或多余、schema 失败和方法不存在都会在进入业务代码前或离开业务代码后失败。 lookup 提供方的 `register()` 同时提供稳定声明和默认 resolver;`configure()` 提供由 Host 组合拥有、可异步执行且受 effect 生命周期约束的 resolver。配置可以先于提供方挂载;没有提供方时调用仍以 `gateway/lookup-unavailable` 失败,配置卸载后则恢复提供方默认策略。Session Controller 负责 `agent` 与 `session` 的标准 resolver 语义:复用 live Agent,自动恢复普通冷会话,对并发恢复去重,并拒绝由 subagent routing 拥有的 identity;`session` lookup 返回该 Agent 的 Session。恢复失败与 ownership fence 抛出携带自有码的 `RemoteError`(`session/not-found` 或 `session/agent-busy`),Gateway 原样编码上 wire;只有未归类的 throw 才折成 `gateway/internal`。 diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index ba4bb17387..49d05c0722 100644 --- a/docs/capability-seams.i18n.yaml +++ b/docs/capability-seams.i18n.yaml @@ -6,5 +6,5 @@ en: 41d772192075e133 zh: 5ef3da69b4de1d8a /capability-seams-and-core-services: - en: 2e9d4cb22f8f2a6b - zh: 81f523914e8531f7 + en: d7b1a50795bcac28 + zh: 68386bdbf1869342 diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 95ab26504b..333f570d5e 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -101,6 +101,8 @@ flowchart LR svc_typert["ctx.typert
Runtime type registry"] pkg_typert_loader["typert-loader"] svc_typertGateway["ctx.typertGateway
Typert Host invocation gateway"] + pkg_api_remote_access["api-remote-access"] + svc_remoteAccess["ctx.remoteAccess
Remote method access control"] svc_sessionPersistence["ctx.sessionPersistence
Durable session persistence seam"] pkg_session_persistence_jsonl["session-persistence-jsonl"] pkg_hooks_claude_code["hooks-claude-code"] @@ -294,6 +296,7 @@ flowchart LR pkg_agent_preset_registry --> svc_agentPresets pkg_api_gateway --> svc_typertGateway pkg_api_job_controller --> svc_jobController + pkg_api_remote_access --> svc_remoteAccess pkg_api_session_controller --> svc_sessionController pkg_api_session_controller --> svc_sessionFileReferences pkg_api_session_controller --> svc_sessionSkillCatalog @@ -500,6 +503,9 @@ flowchart LR svc_profileContext --> pkg_plugin_manager svc_ptcRuntime --> pkg_tools svc_ptcRuntime --> pkg_workflow_ptc + svc_remoteAccess --> pkg_api_gateway + svc_remoteAccess --> pkg_api_session_controller + svc_remoteAccess --> pkg_api_workspace_controller svc_sandbox --> pkg_bash_sandbox svc_sandbox --> pkg_terminal_bash svc_sandboxPolicy --> pkg_bash_sandbox @@ -631,6 +637,7 @@ flowchart LR | `ctx.directoryPickerController` | `core` | [`api-workspace-controller`](../packages/api/workspace-controller) | - | - | - | Carries the picking seam onto the wire: capability gating, cancellation, and the seam-coded failures a browser directory flow discriminates on. | | `ctx.typert` | `core` | [`typert-registry`](../packages/typert/registry) | - | [`typert-loader`](../packages/typert/loader), [`api-gateway`](../packages/api/gateway) | - | Plugins register live zod contributions directly or through dsh-typert-loader; the API gateway consumes invocation descriptors and providers, while other runtime consumers query schemas and reflection metadata at their own edges. | | `ctx.typertGateway` | `core` | [`api-gateway`](../packages/api/gateway) | - | - | - | Associates generated Remote descriptors with live Cordis services, resolves registered identities, and exposes unary calls through the shared Connection RPC carrier. | +| `ctx.remoteAccess` | `core` | [`api-remote-access`](../packages/api/remote-access) | - | [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller) | - | Decides which Remote methods a non-owner Peer may call through business-registered role-based policies, filters list results, and streams each Peer access snapshot. | | `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session/session-persistence) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl) | [`agent-loop`](../packages/core/agent-loop), [`tool-bash`](../packages/shell/tool-bash), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`message-feedback`](../packages/feedback/message-feedback) | - | The JSONL backend persists the SessionEvent vocabulary as one artifact per Session. | | `ctx.configEditor` | `core` | [`config-editor`](../packages/boot/config-editor) | - | [`settings`](../packages/settings/settings), [`agent-default-model`](../packages/core/agent-default-model) | - | Persists profile config patches under the application file lock and HMR queue, then reconciles Loader entries. | | `ctx.settings` | `core` | [`settings`](../packages/settings/settings) | - | [`api-settings-controller`](../packages/api/settings-controller) | - | Forms project volatile Config fields from active profile entries and delegate validated edits to config-editor. Plugins consume their own Config references. | diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index ee3ec60466..d810b4e900 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -103,6 +103,8 @@ flowchart LR svc_typert["ctx.typert
Runtime type registry"] pkg_typert_loader["typert-loader"] svc_typertGateway["ctx.typertGateway
Typert Host invocation gateway"] + pkg_api_remote_access["api-remote-access"] + svc_remoteAccess["ctx.remoteAccess
Remote method access control"] svc_sessionPersistence["ctx.sessionPersistence
Durable session persistence seam"] pkg_session_persistence_jsonl["session-persistence-jsonl"] pkg_hooks_claude_code["hooks-claude-code"] @@ -296,6 +298,7 @@ flowchart LR pkg_agent_preset_registry --> svc_agentPresets pkg_api_gateway --> svc_typertGateway pkg_api_job_controller --> svc_jobController + pkg_api_remote_access --> svc_remoteAccess pkg_api_session_controller --> svc_sessionController pkg_api_session_controller --> svc_sessionFileReferences pkg_api_session_controller --> svc_sessionSkillCatalog @@ -502,6 +505,9 @@ flowchart LR svc_profileContext --> pkg_plugin_manager svc_ptcRuntime --> pkg_tools svc_ptcRuntime --> pkg_workflow_ptc + svc_remoteAccess --> pkg_api_gateway + svc_remoteAccess --> pkg_api_session_controller + svc_remoteAccess --> pkg_api_workspace_controller svc_sandbox --> pkg_bash_sandbox svc_sandbox --> pkg_terminal_bash svc_sandboxPolicy --> pkg_bash_sandbox @@ -633,6 +639,7 @@ flowchart LR | `ctx.directoryPickerController` | `core` | [`api-workspace-controller`](../packages/api/workspace-controller) | - | - | - | 把选目录 seam 送上线:能力门禁、取消传播,以及浏览器目录流程用于分支判断的 seam 错误码。 | | `ctx.typert` | `core` | [`typert-registry`](../packages/typert/registry) | - | [`typert-loader`](../packages/typert/loader), [`api-gateway`](../packages/api/gateway) | - | 插件直接或通过 dsh-typert-loader 注册实时 zod 贡献;API 网关消费调用描述符和提供方,其他运行时消费方则在各自边界查询 schema 与反射元数据。 | | `ctx.typertGateway` | `core` | [`api-gateway`](../packages/api/gateway) | - | - | - | 将生成的 Remote 描述符与实时 Cordis 服务关联,解析已注册的身份,并通过共享的 Connection RPC 载体提供一元调用。 | +| `ctx.remoteAccess` | `core` | [`api-remote-access`](../packages/api/remote-access) | - | [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller) | - | 通过业务方注册的基于 role 的 policy 判定非 owner Peer 可调用哪些 Remote 方法,过滤列表结果,并为每个 Peer 推送访问快照。 | | `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session/session-persistence) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl) | [`agent-loop`](../packages/core/agent-loop), [`tool-bash`](../packages/shell/tool-bash), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`message-feedback`](../packages/feedback/message-feedback) | - | JSONL backend 把 SessionEvent 词汇持久化为每个 Session 一份产物。 | | `ctx.configEditor` | `core` | [`config-editor`](../packages/boot/config-editor) | - | [`settings`](../packages/settings/settings), [`agent-default-model`](../packages/core/agent-default-model) | - | Persists profile config patches under the application file lock and HMR queue, then reconciles Loader entries. | | `ctx.settings` | `core` | [`settings`](../packages/settings/settings) | - | [`api-settings-controller`](../packages/api/settings-controller) | - | Forms project volatile Config fields from active profile entries and delegate validated edits to config-editor. Plugins consume their own Config references. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 6a05ba6bb9..514968564b 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -181,8 +181,8 @@ export interface Config { ## `@deepseek-ai/dsh-api-gateway` -- `inject`: `typert` -- `source`: [`packages/api/gateway/src/index.ts:147`](../packages/api/gateway/src/index.ts) +- `inject`: `typert` · `remoteAccess` +- `source`: [`packages/api/gateway/src/index.ts:155`](../packages/api/gateway/src/index.ts) ```ts config-catalog /** Gateway transport configuration. */ @@ -219,8 +219,8 @@ export interface Config { ## `@deepseek-ai/dsh-api-session-controller` -- `inject`: `agentDefaultModel` · `agents` · `attachments` · `fileUploads` · `fs` · `llm` · `sessions` · `sessionProjections` · `sessionQuery` · `typert` · `workspaceRegistry` -- `source`: [`packages/api/session-controller/src/index.ts:80`](../packages/api/session-controller/src/index.ts) +- `inject`: `agentDefaultModel` · `agents` · `attachments` · `fileUploads` · `fs` · `llm` · `remoteAccess` · `sessions` · `sessionProjections` · `sessionQuery` · `typert` · `workspaceRegistry` +- `source`: [`packages/api/session-controller/src/index.ts:94`](../packages/api/session-controller/src/index.ts) ```ts config-catalog /** Session Controller deployment policy. */ @@ -300,8 +300,8 @@ export interface Config { ## `@deepseek-ai/dsh-api-workspace-controller` -- `inject`: `typert` · `workspaceRegistry` -- `source`: [`packages/api/workspace-controller/src/index.ts:33`](../packages/api/workspace-controller/src/index.ts) +- `inject`: `remoteAccess` · `typert` · `workspaceRegistry` +- `source`: [`packages/api/workspace-controller/src/index.ts:37`](../packages/api/workspace-controller/src/index.ts) ```ts config-catalog /** First-use directory policy for the Host account. */ @@ -435,7 +435,7 @@ export type Config = LocalConfig ## `@deepseek-ai/dsh-client-connection` - `inject`: `credentials` -- `source`: [`packages/client/connection/src/index.ts:92`](../packages/client/connection/src/index.ts) +- `source`: [`packages/client/connection/src/index.ts:93`](../packages/client/connection/src/index.ts) ```ts config-catalog /** Browser authentication, request limits, and connection recovery configuration. */ @@ -3276,7 +3276,7 @@ export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' - `inject`: `workingDirectory` - `refs`: `Volatile` (`@deepseek-ai/cordis`) -- `source`: [`packages/subagent/subagent/src/index.ts:184`](../packages/subagent/subagent/src/index.ts) +- `source`: [`packages/subagent/subagent/src/index.ts:186`](../packages/subagent/subagent/src/index.ts) ```ts config-catalog /** Host configuration for continuable subagent capacity. */ @@ -4515,6 +4515,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co | `@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-account-controller` | `deepseekAccount` · `agents` | [`packages/api/account-controller/src/index.ts`](../packages/api/account-controller/src/index.ts) | +| `@deepseek-ai/dsh-api-remote-access` | — | [`packages/api/remote-access/src/index.ts`](../packages/api/remote-access/src/index.ts) | | `@deepseek-ai/dsh-api-remotes` | `typertGateway` | [`packages/api/remotes/src/index.ts`](../packages/api/remotes/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) | diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 80bf1886b4..005ea98599 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -183,8 +183,8 @@ export interface Config { ## `@deepseek-ai/dsh-api-gateway` -- `inject`: `typert` -- `source`: [`packages/api/gateway/src/index.ts:147`](../packages/api/gateway/src/index.ts) +- `inject`: `typert` · `remoteAccess` +- `source`: [`packages/api/gateway/src/index.ts:155`](../packages/api/gateway/src/index.ts) ```ts config-catalog /** Gateway transport configuration. */ @@ -221,8 +221,8 @@ export interface Config { ## `@deepseek-ai/dsh-api-session-controller` -- `inject`: `agentDefaultModel` · `agents` · `attachments` · `fileUploads` · `fs` · `llm` · `sessions` · `sessionProjections` · `sessionQuery` · `typert` · `workspaceRegistry` -- `source`: [`packages/api/session-controller/src/index.ts:80`](../packages/api/session-controller/src/index.ts) +- `inject`: `agentDefaultModel` · `agents` · `attachments` · `fileUploads` · `fs` · `llm` · `remoteAccess` · `sessions` · `sessionProjections` · `sessionQuery` · `typert` · `workspaceRegistry` +- `source`: [`packages/api/session-controller/src/index.ts:94`](../packages/api/session-controller/src/index.ts) ```ts config-catalog /** Session Controller deployment policy. */ @@ -302,8 +302,8 @@ export interface Config { ## `@deepseek-ai/dsh-api-workspace-controller` -- `inject`: `typert` · `workspaceRegistry` -- `source`: [`packages/api/workspace-controller/src/index.ts:33`](../packages/api/workspace-controller/src/index.ts) +- `inject`: `remoteAccess` · `typert` · `workspaceRegistry` +- `source`: [`packages/api/workspace-controller/src/index.ts:37`](../packages/api/workspace-controller/src/index.ts) ```ts config-catalog /** First-use directory policy for the Host account. */ @@ -437,7 +437,7 @@ export type Config = LocalConfig ## `@deepseek-ai/dsh-client-connection` - `inject`: `credentials` -- `source`: [`packages/client/connection/src/index.ts:92`](../packages/client/connection/src/index.ts) +- `source`: [`packages/client/connection/src/index.ts:93`](../packages/client/connection/src/index.ts) ```ts config-catalog /** Browser authentication, request limits, and connection recovery configuration. */ @@ -3278,7 +3278,7 @@ export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' - `inject`: `workingDirectory` - `refs`: `Volatile` (`@deepseek-ai/cordis`) -- `source`: [`packages/subagent/subagent/src/index.ts:184`](../packages/subagent/subagent/src/index.ts) +- `source`: [`packages/subagent/subagent/src/index.ts:186`](../packages/subagent/subagent/src/index.ts) ```ts config-catalog /** Host configuration for continuable subagent capacity. */ @@ -4517,6 +4517,7 @@ 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-account-controller` | `deepseekAccount` · `agents` | [`packages/api/account-controller/src/index.ts`](../packages/api/account-controller/src/index.ts) | +| `@deepseek-ai/dsh-api-remote-access` | — | [`packages/api/remote-access/src/index.ts`](../packages/api/remote-access/src/index.ts) | | `@deepseek-ai/dsh-api-remotes` | `typertGateway` | [`packages/api/remotes/src/index.ts`](../packages/api/remotes/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) | diff --git a/docs/defensive-patterns/remote-and-api.i18n.yaml b/docs/defensive-patterns/remote-and-api.i18n.yaml index e36bfb5699..c16a005c98 100644 --- a/docs/defensive-patterns/remote-and-api.i18n.yaml +++ b/docs/defensive-patterns/remote-and-api.i18n.yaml @@ -9,8 +9,8 @@ en: 5759a7b0f96ea4bc zh: 62465fbe91a4d2af /remote-and-api-rules/remote-marks-capability-methods: - en: e5613ec07023429d - zh: 9272c9ae5718060a + en: 86422ee69c63f339 + zh: 4a5e8d6f5d9a6ae3 /remote-and-api-rules/packages-api-carries-generic-orchestration-only: en: 12a964b9c04fccf9 zh: cf804f8044535727 diff --git a/docs/defensive-patterns/remote-and-api.md b/docs/defensive-patterns/remote-and-api.md index 25281e96fb..7f2d0646c9 100644 --- a/docs/defensive-patterns/remote-and-api.md +++ b/docs/defensive-patterns/remote-and-api.md @@ -44,13 +44,14 @@ One object then answers for both domains, so a change to either capability's act ## `@Remote` marks capability methods -`@Remote` carries four disciplines that a reviewer checks separately: it marks the concrete method that performs a capability; client-facing types come from the existing definitions through the generator instead of a hand-written copy; the payload is JSON and the remote method performs no output-side decode; and existing method and field names are additive. Each discipline removes a different failure — a wrapper hides which operation is exposed, a second hand-written type set drifts from the generated one, an output-side decode adds a second result path beside the codec the Gateway already runs, and a rename breaks every caller in the same change. +`@Remote` carries five disciplines that a reviewer checks separately: it marks the concrete method that performs a capability; client-facing types come from the existing definitions through the generator instead of a hand-written copy; the payload is JSON and the remote method performs no output-side decode; existing method and field names are additive; and a method names every resource it touches and reads its caller only from the invocation. Each discipline removes a different failure — a wrapper hides which operation is exposed, a second hand-written type set drifts from the generated one, an output-side decode adds a second result path beside the codec the Gateway already runs, a rename breaks every caller in the same change, and an unnamed resource, an unfiltered aggregate, or an argument-supplied caller lets a non-owner reach what no binding grants it. - **Capability methods.** The annotation goes on the concrete method that performs the operation (`list`, `prompt`, `interruptByParent`), never on a wrapper that forwards to the real implementation. Judged by the decorated method's body: a body that only delegates means the annotation is on the wrong method. - **Types from inference.** Client-facing types are aliased from what the generator produces — `type SessionRemote = TypertClientRemote['session']` — instead of being written a second time. Judged by a hand-written declaration whose fields repeat a generated Client type; [typert.md](../subsystems/typert.md) owns the generated maps and their declaration merges. - **JSON on the wire, no output-side decode.** The **wire payload** of a Remote method is JSON, with binary content expressed field-level. The Host signature keeps what the Gateway already supports — lookup arguments resolved to Host objects, a trailing `AbortSignal`, a stream declared with `@Remote({ mode: 'stream' })` — and `@Remote` performs no decode on the way out; the generated codec and the Gateway validate the result. Judged by a decode, a stream reassembly, or a whole non-JSON value in the payload of a method carrying the decorator; [programming model](../api-gateway.md#programming-model) owns what may cross a Remote method. - **Existing names are additive.** A migration does not rename a remote method or field that a Client already calls; a new semantic gets a new entry, and a rename is its own change with its own review. Judged by a diff that renames or removes a name already exposed through `ctx.remote.`. -- **Not covered.** A method without the decorator stays an ordinary service method with in-process callers; the four disciplines govern what becomes a Remote method, not what a service may implement. +- **Caller identity and access.** Every Remote method is under access control: the `@Remote({ resources })` / `@RemoteScope(key, { resources })` readers and automatic resolution must name every resource the method touches, because access control never checks a resource they omit, and a method returning several resources filters each item through `this.ctx.remoteAccess.allows(resource)`. The caller is read only from `this.ctx.invocation.peer.principal`, never from arguments; a Remote method that builds a user-message source records it through `principalOf(this.ctx.invocation)` and passes the built source down instead of passing the principal as a function argument. Every call path names its Peer explicitly. Judged by a method whose resolved resources omit one it touches, an unfiltered aggregate, a principal read from wire data or threaded through helper parameters, or a user-message source built in a Remote method without `principalOf`; the [Peer principals Agent Note](../../.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md) owns the design. +- **Not covered.** A method without the decorator stays an ordinary service method with in-process callers; the five disciplines govern what becomes a Remote method, not what a service may implement. - **Exception.** A rename or a new decode path states its benefit and the impact on existing callers first and needs the domain owner's confirmation; the author never self-approves. The generated map is the only source for Client-facing types, the decorator sits on the concrete method, and the payload stays JSON: diff --git a/docs/defensive-patterns/remote-and-api.zh.md b/docs/defensive-patterns/remote-and-api.zh.md index 1a9c291d9c..c621109913 100644 --- a/docs/defensive-patterns/remote-and-api.zh.md +++ b/docs/defensive-patterns/remote-and-api.zh.md @@ -44,13 +44,14 @@ class ApiProxy { ## `@Remote` 标在能力方法上 -`@Remote` 有四项纪律,审查者分别核对:标注执行能力的具体方法;面向 Client 的类型经生成器来自既有定义,而不是手写第二份;载荷只承载 JSON,远程方法不做输出侧 decode;既有的方法与字段名只增不改。每项纪律消除一类不同的缺陷——壳方法藏起真正暴露的是哪个操作,手写的第二份类型会与生成结果漂移,输出侧 decode 在 Gateway 已有的 codec 之外再造一条结果路径,改名则让所有调用方在同一次变更里一起断掉。 +`@Remote` 有五项纪律,审查者分别核对:标注执行能力的具体方法;面向 Client 的类型经生成器来自既有定义,而不是手写第二份;载荷只承载 JSON,远程方法不做输出侧 decode;既有的方法与字段名只增不改;方法指明其访问的全部资源,且只从 invocation 读取调用方。每项纪律消除一类不同的缺陷——壳方法藏起真正暴露的是哪个操作,手写的第二份类型会与生成结果漂移,输出侧 decode 在 Gateway 已有的 codec 之外再造一条结果路径,改名让所有调用方在同一次变更里一起断掉,遗漏资源、未过滤的聚合结果或按参数判定调用方则让非 owner 访问没有 binding 授予它的内容。 - **标能力方法。** 标注落在执行该操作的具体方法上(`list`、`prompt`、`interruptByParent`),绝不落在转发给真正实现的壳方法上。判定看被标注方法的函数体:函数体只做转发,标注就标错了方法。 - **类型靠推断。** 面向 Client 的类型从生成结果取别名——`type SessionRemote = TypertClientRemote['session']`——而不是重写一份。判定看是否出现字段与生成的 Client 类型重复的手写声明;生成映射及其声明合并的正家是 [typert.zh.md](../subsystems/typert.zh.md)。 - **线上载荷只承载 JSON、不做输出侧 decode。** Remote 方法的**线上载荷**是 JSON,二进制在字段级表达。Host 签名保留 Gateway 已支持的形式——经解析变成 Host 对象的 lookup 参数、末尾的 `AbortSignal`、以 `@Remote({ mode: 'stream' })` 声明的流式返回;`@Remote` 不做输出侧 decode,结果由生成的 codec 与 Gateway 校验。判定看带该标注的方法上是否出现 decode、流重组或整体非 JSON 值;可跨越 Remote 方法的内容正家是 [编程模型](../api-gateway.zh.md#programming-model)。 - **存量名只增不改。** 迁移不改 Client 已在调用的远程方法名或字段名;新语义新增条目,改名是独立的一刀、单独评审。判定看 diff 是否改名或删除了已经经 `ctx.remote.` 暴露的名字。 -- **不覆盖。** 不带该标注的方法仍是有进程内调用者的普通 service 方法;这四项纪律约束的是什么成为 Remote 方法,而不是 service 可以实现什么。 +- **调用方身份与访问。** 每个 Remote 方法都受访问控制:`@Remote({ resources })` / `@RemoteScope(key, { resources })` 的读取函数与自动解析必须指明方法访问的全部资源,因为访问控制从不检查它们遗漏的资源;返回多个资源的方法对每一项经 `this.ctx.remoteAccess.allows(resource)` 过滤。调用方只从 `this.ctx.invocation.peer.principal` 读取,绝不从参数读取;在 Remote 方法内构造用户消息 source 时经 `principalOf(this.ctx.invocation)` 记录调用方,并向下传递已构造的 source,而不是把 principal 当函数参数传递。每条调用路径都显式给出 Peer。判定看方法解析出的资源是否遗漏其访问的资源、聚合结果是否未过滤、principal 是否从 wire 数据读取或经辅助函数参数逐层传递、Remote 方法内构造的用户消息 source 是否未经 `principalOf`;设计的正家是 [Peer 身份属性 Agent Note](../../.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md)。 +- **不覆盖。** 不带该标注的方法仍是有进程内调用者的普通 service 方法;这五项纪律约束的是什么成为 Remote 方法,而不是 service 可以实现什么。 - **例外。** 确需改名或新增 decode 路径,须先说明收益与对既有调用方的影响,并取得对应领域专家的确认;作者不得自行批准。 生成的映射是面向 Client 类型的唯一来源,标注落在具体方法上,载荷保持 JSON: diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index a54a65bdb7..d727ee4ec8 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -22,17 +22,17 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:353`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:280`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:381`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:624`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:604`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:631`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:610`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:617`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:629`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:609`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:636`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:615`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:622`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:54`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:87`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | - | | `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:67`](../packages/client/connection/src/index.ts) | `connection` (`waterfall`) | - | +| `connection/request` | `waterfall` | [`packages/client/connection/src/index.ts:68`](../packages/client/connection/src/index.ts) | `connection` (`waterfall`) | - | | `cordis/dynamic-package` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:383`](../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:389`](../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:395`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | @@ -80,7 +80,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:191`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | | `tools/result` | `emit` | [`packages/core/tools/src/index.ts:199`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, [`subagent`](../packages/subagent/subagent), [`tool-present`](../packages/deliverables/tool-present) | | `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:157`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes` | -| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:39`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` | +| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:39`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `remote-access`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` | | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) | | `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) | | `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:89`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index faebbe62ff..fccfeea698 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -24,17 +24,17 @@ | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:353`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:280`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:381`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:624`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:604`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:631`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:610`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:617`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:629`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:609`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:636`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:615`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:622`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:54`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:87`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | - | | `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:67`](../packages/client/connection/src/index.ts) | `connection` (`waterfall`) | - | +| `connection/request` | `waterfall` | [`packages/client/connection/src/index.ts:68`](../packages/client/connection/src/index.ts) | `connection` (`waterfall`) | - | | `cordis/dynamic-package` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:383`](../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:389`](../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:395`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | @@ -82,7 +82,7 @@ | `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:191`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | | `tools/result` | `emit` | [`packages/core/tools/src/index.ts:199`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, [`subagent`](../packages/subagent/subagent), [`tool-present`](../packages/deliverables/tool-present) | | `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:157`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes` | -| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:39`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` | +| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:39`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `remote-access`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` | | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) | | `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) | | `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:89`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | diff --git a/docs/module-graph.md b/docs/module-graph.md index 264bc333b0..b6df4b5e7b 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -111,6 +111,7 @@ flowchart TD pkg_api_account_controller["api-account-controller"] pkg_api_gateway["api-gateway"] pkg_api_job_controller["api-job-controller"] + pkg_api_remote_access["api-remote-access"] pkg_api_remotes["api-remotes"] pkg_api_session_controller["api-session-controller"] pkg_api_settings_controller["api-settings-controller"] @@ -484,7 +485,6 @@ flowchart TD pkg_spill --> pkg_brand pkg_spill --> pkg_llm pkg_spill --> pkg_session - pkg_api_gateway --> pkg_client_connection pkg_api_terminal_controller --> pkg_subprocess pkg_attachment_local --> pkg_attachment pkg_attachment_local --> pkg_home_paths @@ -1461,7 +1461,9 @@ flowchart TD | [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions) | `llm` | — | | [`llm`](../packages/llm/llm) | `llm` | — | | [`scope`](../packages/core/scope) | `core` | — | +| [`api-gateway`](../packages/api/gateway) | `api` | — | | [`api-job-controller`](../packages/api/job-controller) | `api` | — | +| [`api-remote-access`](../packages/api/remote-access) | `api` | — | | [`api-workspace-files`](../packages/api/workspace-files) | `api` | — | | [`cmdline`](../packages/boot/cmdline) | `boot` | — | | [`acp-app`](../packages/bundle/acp-app) | `bundle` | — | @@ -1588,7 +1590,6 @@ flowchart TD | [`web-search-exa`](../packages/web/web-search-exa) | `web` | [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) | | [`web-search-perplexity`](../packages/web/web-search-perplexity) | `web` | [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) | | [`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) | | [`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) | | [`hmr`](../packages/boot/hmr) | `boot` | [`app-boot`](../packages/boot/app-boot), [`cmdline`](../packages/boot/cmdline) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 9306d32276..4c4e5e23d9 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -113,6 +113,7 @@ flowchart TD pkg_api_account_controller["api-account-controller"] pkg_api_gateway["api-gateway"] pkg_api_job_controller["api-job-controller"] + pkg_api_remote_access["api-remote-access"] pkg_api_remotes["api-remotes"] pkg_api_session_controller["api-session-controller"] pkg_api_settings_controller["api-settings-controller"] @@ -486,7 +487,6 @@ flowchart TD pkg_spill --> pkg_brand pkg_spill --> pkg_llm pkg_spill --> pkg_session - pkg_api_gateway --> pkg_client_connection pkg_api_terminal_controller --> pkg_subprocess pkg_attachment_local --> pkg_attachment pkg_attachment_local --> pkg_home_paths @@ -1463,7 +1463,9 @@ flowchart TD | [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions) | `llm` | — | | [`llm`](../packages/llm/llm) | `llm` | — | | [`scope`](../packages/core/scope) | `core` | — | +| [`api-gateway`](../packages/api/gateway) | `api` | — | | [`api-job-controller`](../packages/api/job-controller) | `api` | — | +| [`api-remote-access`](../packages/api/remote-access) | `api` | — | | [`api-workspace-files`](../packages/api/workspace-files) | `api` | — | | [`cmdline`](../packages/boot/cmdline) | `boot` | — | | [`acp-app`](../packages/bundle/acp-app) | `bundle` | — | @@ -1590,7 +1592,6 @@ flowchart TD | [`web-search-exa`](../packages/web/web-search-exa) | `web` | [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) | | [`web-search-perplexity`](../packages/web/web-search-perplexity) | `web` | [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) | | [`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) | | [`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) | | [`hmr`](../packages/boot/hmr) | `boot` | [`app-boot`](../packages/boot/app-boot), [`cmdline`](../packages/boot/cmdline) | diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md index bb40e7f93f..fc2db4f0de 100644 --- a/docs/subsystems/session.md +++ b/docs/subsystems/session.md @@ -933,7 +933,7 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' | * @param signal - caller cancellation before prompt admission begins. * @returns acknowledgement that the Agent accepted the prompt. */ -@Remote('prompt') prompt(request: SessionPromptRequest, signal: AbortSignal): Promise +@Remote('prompt') async prompt(request: SessionPromptRequest, signal: AbortSignal): Promise /** * Read one image proven reachable from the addressed Session log. @@ -962,7 +962,7 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' | * @param signal - cancellation for persistence reads. * @returns one chronological page. */ -@Remote('page') page(request: SessionPageRequest, signal: AbortSignal): Promise +@Remote({ name: 'page', resources: { session: addressedSession } }) page(request: SessionPageRequest, signal: AbortSignal): Promise /** * Follow one Session log from its opening or resume cursor. @@ -971,7 +971,7 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' | * @returns a complete opening snapshot followed by gap-free durable event * frames and optional cursorless assistant-stream frames. */ -@Remote({ mode: 'stream' }) follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable +@Remote({ mode: 'stream', resources: { session: addressedSession } }) follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable /** * Read exact projections, returning cached hints only when migration is required. @@ -986,7 +986,7 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' | * @param signal - cancellation owned by the Remote stream carrier. * @returns one complete baseline followed by live replacement frames. */ -@Remote({ mode: 'stream' }) control(signal: AbortSignal): AsyncIterable +@Remote({ mode: 'stream' }) async *control(signal: AbortSignal): AsyncIterable ``` Types: [SessionId](core.md) · [SessionInspection](persistence.md) · [SessionSearchRequest](session-query.md) diff --git a/docs/subsystems/session.zh.md b/docs/subsystems/session.zh.md index 298b704bde..bae730ef7d 100644 --- a/docs/subsystems/session.zh.md +++ b/docs/subsystems/session.zh.md @@ -937,7 +937,7 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' | * @param signal - caller cancellation before prompt admission begins. * @returns acknowledgement that the Agent accepted the prompt. */ -@Remote('prompt') prompt(request: SessionPromptRequest, signal: AbortSignal): Promise +@Remote('prompt') async prompt(request: SessionPromptRequest, signal: AbortSignal): Promise /** * Read one image proven reachable from the addressed Session log. @@ -966,7 +966,7 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' | * @param signal - cancellation for persistence reads. * @returns one chronological page. */ -@Remote('page') page(request: SessionPageRequest, signal: AbortSignal): Promise +@Remote({ name: 'page', resources: { session: addressedSession } }) page(request: SessionPageRequest, signal: AbortSignal): Promise /** * Follow one Session log from its opening or resume cursor. @@ -975,7 +975,7 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' | * @returns a complete opening snapshot followed by gap-free durable event * frames and optional cursorless assistant-stream frames. */ -@Remote({ mode: 'stream' }) follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable +@Remote({ mode: 'stream', resources: { session: addressedSession } }) follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable /** * Read exact projections, returning cached hints only when migration is required. @@ -990,7 +990,7 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' | * @param signal - cancellation owned by the Remote stream carrier. * @returns one complete baseline followed by live replacement frames. */ -@Remote({ mode: 'stream' }) control(signal: AbortSignal): AsyncIterable +@Remote({ mode: 'stream' }) async *control(signal: AbortSignal): AsyncIterable ``` Types: [SessionId](core.zh.md) · [SessionInspection](persistence.zh.md) · [SessionSearchRequest](session-query.zh.md) diff --git a/docs/subsystems/slots.i18n.yaml b/docs/subsystems/slots.i18n.yaml index 1dd04a5ab9..5da22cf2d1 100644 --- a/docs/subsystems/slots.i18n.yaml +++ b/docs/subsystems/slots.i18n.yaml @@ -15,8 +15,8 @@ en: 9cb70abb2e39b958 zh: cd4b6f1705d90e17 /web-client-slots/framework-provided-hooks: - en: 2fcfc55fbac80f72 - zh: ea3016f394c56b07 + en: c9f8d836214623ae + zh: 76dec55006396535 /web-client-slots/developer-provided-injection: en: 3481dfa6cd3097da zh: acbd6f06a7da1752 diff --git a/docs/subsystems/slots.md b/docs/subsystems/slots.md index 30f8acedb4..12a8d89b8f 100644 --- a/docs/subsystems/slots.md +++ b/docs/subsystems/slots.md @@ -84,11 +84,12 @@ The shipped adapters add these standard props. They are available according to t | Availability | Props | Owner | |---|---|---| -| every scope | `useSessions`, `useSessionStatus`, `useSessionRetainInfo` | `ui-session` | -| every scope | `useWorkspaces` | `ui-workspace` | +| every scope | `useSessions`, `useSessionStatus`, `useSessionRetainInfo`, `useSessionAccess` | `ui-session` | +| every scope | `useWorkspaces`, `useWorkspaceAccess` | `ui-workspace` | +| every scope | `useRemoteAccess` (the `global` resource) | `api-remote-access` | | every scope | `usePanelInfo` | `ui-layout` | -| `session` | `sessionId`, `useSession`, `useProjection` | `ui-session` | -| `session-maybe` | optional `sessionId`, `useSession`, `useProjection` results | `ui-session` | +| `session` | `sessionId`, `useSession`, `useProjection`, `useAccess` (the bound Session's answer) | `ui-session` | +| `session-maybe` | optional `sessionId`, `useSession`, `useProjection`, `useAccess` results | `ui-session` | | `session` | `useConversation`, `useInput`, `inputActions` | `ui-conversation` | | `session-maybe` | optional `useConversation`, `useInput`, `inputActions` results | `ui-conversation` | | `session` | `useChat` | `ui-chat` | diff --git a/docs/subsystems/slots.zh.md b/docs/subsystems/slots.zh.md index a660f720e4..77f3717f36 100644 --- a/docs/subsystems/slots.zh.md +++ b/docs/subsystems/slots.zh.md @@ -86,11 +86,12 @@ Slot 声明固定两个相互独立的维度。 | 可用范围 | Props | Owner | |---|---|---| -| 所有 scope | `useSessions`、`useSessionStatus`、`useSessionRetainInfo` | `ui-session` | -| 所有 scope | `useWorkspaces` | `ui-workspace` | +| 所有 scope | `useSessions`、`useSessionStatus`、`useSessionRetainInfo`、`useSessionAccess` | `ui-session` | +| 所有 scope | `useWorkspaces`、`useWorkspaceAccess` | `ui-workspace` | +| 所有 scope | `useRemoteAccess`(`global` 资源) | `api-remote-access` | | 所有作用域 | `usePanelInfo` | `ui-layout` | -| `session` | `sessionId`、`useSession`、`useProjection` | `ui-session` | -| `session-maybe` | 结果可选的 `sessionId`、`useSession`、`useProjection` | `ui-session` | +| `session` | `sessionId`、`useSession`、`useProjection`、`useAccess`(所绑定 Session 的答案) | `ui-session` | +| `session-maybe` | 结果可选的 `sessionId`、`useSession`、`useProjection`、`useAccess` | `ui-session` | | `session` | `useConversation`、`useInput`、`inputActions` | `ui-conversation` | | `session-maybe` | 结果可选的 `useConversation`、`useInput`、`inputActions` | `ui-conversation` | | `session` | `useChat` | `ui-chat` | diff --git a/docs/subsystems/typert.i18n.yaml b/docs/subsystems/typert.i18n.yaml index 0030b50111..d6917635cf 100644 --- a/docs/subsystems/typert.i18n.yaml +++ b/docs/subsystems/typert.i18n.yaml @@ -11,12 +11,15 @@ /typert-remote-calls/invocation-descriptors: en: 21e88d68154c2c92 zh: 8c9e05c65ef0f34d +/typert-remote-calls/remote-access-resources: + en: fa95f70437c07395 + zh: d4870b678b569a89 /typert-remote-calls/typert-registry: - en: 29f578ba733cfd5a - zh: c486ff4ac2d1e3b4 + en: 5827ef50011811c9 + zh: 21a9e7b368ba211a /typert-remote-calls/host-gateway: - en: 90fab88fe51d17bb - zh: 2d4c3849cf2dc7d3 + en: 58b923de12e48f18 + zh: d3c2372d8becf65e /typert-remote-calls/consumer-remote: en: 324386c600a5fdf5 zh: 7963501eb663e228 diff --git a/docs/subsystems/typert.md b/docs/subsystems/typert.md index e9cabd714b..dc2beb2f9e 100644 --- a/docs/subsystems/typert.md +++ b/docs/subsystems/typert.md @@ -200,13 +200,35 @@ interface RemoteStreamHandle extends AsyncIterable { ```ts type-equiv /** - * One Peer's session on this Host. Connection owns it: `ctx` is the Cordis + * Merge-extensible caller identities. Each driver declares its own entry. + * Every entry is a JSON value with a unique literal `kind`, carries no + * credential, and is safe to persist in a Session log. + */ +interface PrincipalMap { + /** The local user who owns this Host. */ + owner: { readonly kind: 'owner' } +} +``` + +```ts type-equiv +/** One caller identity; the authorization layer reads only this. */ +type Principal = PrincipalMap[keyof PrincipalMap] +``` + +```ts type-equiv +/** + * One Peer's session on this Host. Its creator 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. + * Peer, and `principal` is the caller identity fixed when the Peer was created. */ interface PeerScope { readonly id: PeerId + /** Connection-lifetime registrations; disposed with the Peer. */ readonly ctx: Context + /** Caller identity fixed at creation by in-process Host code; no wire field can set it. */ + readonly principal: Principal + /** Aborted once the Peer is disposed; Remote streams that speak for the Peer end with it. */ + readonly signal: AbortSignal /** * Tear down every registration made through `ctx`. * @returns settles once the scope has quiesced; racing calls share one completion. @@ -239,7 +261,7 @@ interface RemoteInvocation { } /** Cordis service key of the receiving Service. */ readonly service: string - /** Peer the call speaks for; an in-process carrier speaks for the operator. */ + /** Peer the call speaks for; every carrier names it explicitly. */ readonly peer: PeerScope /** Carrier cancellation: Client cancel, socket close, or an uplink failure. */ readonly signal: AbortSignal @@ -260,9 +282,81 @@ interface RemoteInvocation { } ``` +## Remote access resources + +Every Remote method is under the access control of [`dsh-api-remote-access`](../../packages/api/remote-access/README.md): a non-owner call is allowed only when the policy for its principal kind binds, on every resource the call touches, a role that lists the endpoint action. The package that owns an identity type merge-declares its resource kind in `TypertResourceMap`; `global`, the built-in identity-less resource, is never a kind there. The Gateway reads a kind's identities from the `resources` readers of `@Remote({ resources })` or `@RemoteScope(key, { resources })`, else from a lookup parameter or scoped Context that declares the kind as its `resource`, else from the default field registered in `typert.resources`; a call that names no resource touches `global`. The [Peer principals Agent Note](../../.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md) owns the decision. + +```ts type-equiv +/** + * Merge-extensible resource kinds whose identities a Remote call or forwarded + * event may carry. The package that owns an identity type declares its kind; + * the value is that kind's identity type, a string type such as a branded id. + * `global` names the built-in identity-less resource and is never a kind here. + */ +interface TypertResourceMap {} +``` + +```ts type-equiv +/** One resource kind: the built-in `global` kind or a declared kind. */ +type RemoteAccessResourceKind = 'global' | TypertResourceKind +``` + +```ts type-equiv +/** + * One resource a call or forwarded event touches: `global`, or one identity + * of a declared kind. Identities are wire values, read before any lookup + * resolves or validates them. + */ +type RemoteAccessResource = + | { readonly kind: 'global' } + | { + [Kind in TypertResourceKind]: { readonly kind: Kind; readonly id: TypertResourceId } + }[TypertResourceKind] +``` + +```ts type-equiv +/** + * Explicit endpoint and resource choices for one Remote Scope method. + * @template WireArgs - the decorated method's positional parameters as the + * wire carries them; the default `never` admits readers of any tuple. + */ +interface RemoteScopeOptions { + /** Exported endpoint method; omission retains the implementation member name. */ + readonly name?: string + /** + * Per-kind readers of the resources one call touches. Each receives the + * method's arguments as its own parameters in undecoded wire JSON form, a + * lookup parameter as its wire identity and a cancellation parameter as the + * call's signal, so it performs plain reads only, and returns one identity, + * several, or `undefined` for none. A throw, an empty list, or an identity + * other than a non-empty string refuses the call. For a kind without a + * reader, the Gateway reads the identity of a scoped Context whose Host + * adapter declares that `resource` and of lookup parameters declaring it, + * else the field registered in `typert.resources`: a top-level wire + * argument or, failing that, the first object argument. A call that carries + * no identity touches `global`. The readers and these defaults together + * must name every resource the method accesses: access control never checks + * a resource they omit. + */ + readonly resources?: TypertResourceReaders +} +``` + +```ts type-equiv +/** + * Explicit endpoint, stream, and resource choices for one direct Remote method. + * @template WireArgs - the decorated method's positional parameters as the + * wire carries them; the default `never` admits readers of any tuple. + */ +interface RemoteMethodOptions extends RemoteScopeOptions { + /** `stream`: deliver each Iterable item over the shared logical-stream carrier. */ + readonly mode?: 'stream' +} +``` + ## 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. +`ctx.typert` separates current-environment descriptors, explicitly selected Remote contributions, lookup providers, scoped Context providers, and the default fields of resource kinds. 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. ```ts type-equiv /** Minimal Typert runtime consumed through dependency inversion. */ @@ -271,6 +365,7 @@ interface TypertRegistryContract { readonly remotes: TypertRemoteRegistry readonly lookups: TypertLookupRegistry readonly contexts: TypertContextRegistry + readonly resources: TypertResourceRegistry } ``` @@ -283,7 +378,7 @@ interface TypertRemoteNamespaceMap {} ## Host Gateway -Connection decodes its carrier envelope before calling `ctx.typertGateway`. The request carries exact named wire fields and the carrier's cancellation signal separately; infrastructure and boundary failures ride `TypertGatewayError`, whose `gateway/*` codes are ordinary `RemoteError` codes, so the RPC adapter passes every structurally identified `RemoteError` through with its code and details intact and folds only unrecognized exceptions into `gateway/internal`. +Connection decodes its carrier envelope before calling `ctx.typertGateway`. The request carries exact named wire fields, the carrier's cancellation signal, and the Peer the call speaks for separately; every carrier and in-process caller names that Peer, and the Gateway refuses a disposed one with `gateway/peer-disposed`. For a non-owner call the Gateway resolves its resources and asks `ctx.remoteAccess.decide()` after argument validation and before any lookup or Context resolution; infrastructure and boundary failures ride `TypertGatewayError`, whose `gateway/*` codes are ordinary `RemoteError` codes, so the RPC adapter passes every structurally identified `RemoteError` through with its code and details intact and folds only unrecognized exceptions into `gateway/internal`. ```ts type-equiv /** One Remote method request after a carrier has decoded its envelope. */ @@ -301,8 +396,8 @@ interface InvokeRemoteRequest { readonly uplink?: AsyncIterable /** Carrier-provided frame capacity; absent for carriers without an encoded-frame inbox limit. */ readonly uplinkWindow?: RemoteUplinkWindow | undefined - /** Peer the call speaks for; absent means an in-process carrier, answered as the operator. */ - readonly peer?: PeerScope + /** Peer the call speaks for; every carrier and in-process caller names it explicitly. */ + readonly peer: PeerScope /** Carrier or direct-caller cancellation injected only into cancellation-aware methods. */ readonly signal?: AbortSignal } @@ -324,6 +419,7 @@ type TypertGatewayErrorCode = | 'gateway/lookup-not-found' | 'gateway/lookup-unavailable' | 'gateway/method-unavailable' + | 'gateway/peer-disposed' | 'gateway/protocol' | 'gateway/provider-mismatch' | 'gateway/result-invalid' @@ -338,8 +434,8 @@ interface TypertGateway { /** Carrier adapter shared by WebSocket and in-process transports. */ readonly wireStream: TypertGatewayWireStream /** - * Check for an active Client event stream. - * @returns whether a stream is open and has not been cancelled. + * Check for an active owner Client event stream, the only kind that can answer forwarded waterfalls. + * @returns whether an owner stream is open and has not been cancelled. */ hasLiveClient(): boolean /** @@ -404,6 +500,88 @@ interface TypertClientRemote extends TypertRemoteNamespaceMap { Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md). + + +### `ctx.remoteAccess` — `RemoteAccessService` + +Registry of access policies, the Gateway's access decisions, list filtering, and the per-Peer access snapshot. Every composition that mounts the Gateway mounts this service too. + +```ts cordis-catalog +/** + * Register the policy of one non-owner principal kind in the caller's + * fiber; open snapshots are recomputed on registration and removal. + * @param kind - the principal kind the policy serves; never `owner`. + * @param policy - business-owned bindings and refusal reasons for principals of `kind`. + * @returns disposer removing this policy. + * @throws Error when `kind` already has a policy. + */ +registerPolicy>(kind: K, policy: RemoteAccessPolicy): () => void + +/** + * Report that the bindings or condition answers for `principal` may have + * changed. Business packages call this on every grant, revocation, mode + * change, and expiry; open snapshots re-push, and the next failure of each + * policy is logged again. + * @param principal - affected principal; omitted for every principal. + */ +invalidate(principal?: Principal): void + +/** + * Gateway-only: decide one request whose resources the Gateway resolved. + * The owner is allowed, and so is `remoteAccess/follow`: every Peer reads + * its own snapshot. Otherwise the action is allowed only when, on every + * listed resource, some binding of the principal's policy names that + * resource, has a role listing the action, and has no condition or a + * condition that allows. `bindings()` is called once per decision. + * @param request - the caller or recipient, the action, the resources it + * touches, and a call's arguments. + * @returns `allow`; or `deny` with `no-allow` when the principal kind has + * no policy, the first refusing condition's reason on the first resource + * no binding allows, else `explain()`'s reason, else `no-allow`; + * `policy-failed` when `bindings()`, a condition, or `explain()` throws, + * logged once per policy until the next `invalidate()`. The Gateway + * throws `remote-access/denied` for a denied call and drops a denied event. + */ +decide(request: RemoteAccessRequest): RemoteAccessDecision + +/** + * Filter items inside a Remote method. The invocation is read from the + * Context that accessed this service, so call it as + * `this.ctx.remoteAccess.allows(resource)` from the Remote method. Each + * check reads the policy's bindings again, so a long-running stream sees + * binding changes from the next check on. + * @param resource - resource of one result item. + * @returns true for the owner and outside a Remote call; otherwise whether + * the invocation's endpoint action is allowed on `resource` without call + * arguments, false when the policy is absent or fails. + */ +allows(resource: RemoteAccessResource): boolean + +/** + * The baseline a page opens with before its snapshot stream connects. A + * business package rendering a non-owner page assigns this value to + * {@link REMOTE_ACCESS_SNAPSHOT_KEY} after every index injection row, + * replacing the owner baseline the webServer index carries. + * @param principal - the page's caller identity. + * @returns the owner baseline for the owner; otherwise the principal's current grants. + */ +snapshotFor(principal: Principal): RemoteAccessBaseline + +/** + * Stream the calling Peer's access snapshot: one baseline, then `upsert` + * and `remove` frames after each `invalidate()` and each policy + * registration or removal that changes a grant. The owner receives only + * `{ owner: true }`. The generation ends when `signal` aborts or this + * service unloads, so a reloaded service starts every reader from a fresh + * baseline. + * @param signal - generation cancellation. + * @returns the access frames of one generation. + */ +@Remote({ mode: 'stream' }) async *follow(signal: AbortSignal): AsyncIterable +``` + +Source: [`packages/api/remote-access/src/index.ts`](../../packages/api/remote-access/src/index.ts) + ### `ctx.typert` — `TypertRegistry` @@ -476,8 +654,8 @@ Resolve strict generated definitions or conservative SRC markers against current ```ts cordis-catalog /** - * Check for an active Client event stream. - * @returns whether a stream is open and has not been cancelled. + * Check for an active owner Client event stream, the only kind that can answer forwarded waterfalls. + * @returns whether an owner stream is open and has not been cancelled. */ hasLiveClient(): boolean diff --git a/docs/subsystems/typert.zh.md b/docs/subsystems/typert.zh.md index 287e81a5fa..22a6e6c9ee 100644 --- a/docs/subsystems/typert.zh.md +++ b/docs/subsystems/typert.zh.md @@ -200,13 +200,35 @@ interface RemoteStreamHandle extends AsyncIterable { ```ts type-equiv /** - * One Peer's session on this Host. Connection owns it: `ctx` is the Cordis + * Merge-extensible caller identities. Each driver declares its own entry. + * Every entry is a JSON value with a unique literal `kind`, carries no + * credential, and is safe to persist in a Session log. + */ +interface PrincipalMap { + /** The local user who owns this Host. */ + owner: { readonly kind: 'owner' } +} +``` + +```ts type-equiv +/** One caller identity; the authorization layer reads only this. */ +type Principal = PrincipalMap[keyof PrincipalMap] +``` + +```ts type-equiv +/** + * One Peer's session on this Host. Its creator 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. + * Peer, and `principal` is the caller identity fixed when the Peer was created. */ interface PeerScope { readonly id: PeerId + /** Connection-lifetime registrations; disposed with the Peer. */ readonly ctx: Context + /** Caller identity fixed at creation by in-process Host code; no wire field can set it. */ + readonly principal: Principal + /** Aborted once the Peer is disposed; Remote streams that speak for the Peer end with it. */ + readonly signal: AbortSignal /** * Tear down every registration made through `ctx`. * @returns settles once the scope has quiesced; racing calls share one completion. @@ -239,7 +261,7 @@ interface RemoteInvocation { } /** Cordis service key of the receiving Service. */ readonly service: string - /** Peer the call speaks for; an in-process carrier speaks for the operator. */ + /** Peer the call speaks for; every carrier names it explicitly. */ readonly peer: PeerScope /** Carrier cancellation: Client cancel, socket close, or an uplink failure. */ readonly signal: AbortSignal @@ -260,9 +282,81 @@ interface RemoteInvocation { } ``` +## Remote 访问资源 + +每个 Remote 方法都受 [`dsh-api-remote-access`](../../packages/api/remote-access/README.zh.md) 的访问控制:只有当该 principal 种类的 policy 在调用访问的每个资源上都绑定了列出该端点 action 的 role 时,非 owner 调用才被放行。拥有某个身份类型的包把其资源种类声明合并进 `TypertResourceMap`;内置的无身份资源 `global` 从不是其中的种类。Gateway 从 `@Remote({ resources })` 或 `@RemoteScope(key, { resources })` 的 `resources` 读取函数读取某个种类的身份,其次是把该种类声明为 `resource` 的 lookup 参数或作用域 Context,再次是在 `typert.resources` 中登记的默认字段;不指向任何资源的调用访问 `global`。决策由 [Peer 身份属性 Agent Note](../../.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md) 负责。 + +```ts type-equiv +/** + * Merge-extensible resource kinds whose identities a Remote call or forwarded + * event may carry. The package that owns an identity type declares its kind; + * the value is that kind's identity type, a string type such as a branded id. + * `global` names the built-in identity-less resource and is never a kind here. + */ +interface TypertResourceMap {} +``` + +```ts type-equiv +/** One resource kind: the built-in `global` kind or a declared kind. */ +type RemoteAccessResourceKind = 'global' | TypertResourceKind +``` + +```ts type-equiv +/** + * One resource a call or forwarded event touches: `global`, or one identity + * of a declared kind. Identities are wire values, read before any lookup + * resolves or validates them. + */ +type RemoteAccessResource = + | { readonly kind: 'global' } + | { + [Kind in TypertResourceKind]: { readonly kind: Kind; readonly id: TypertResourceId } + }[TypertResourceKind] +``` + +```ts type-equiv +/** + * Explicit endpoint and resource choices for one Remote Scope method. + * @template WireArgs - the decorated method's positional parameters as the + * wire carries them; the default `never` admits readers of any tuple. + */ +interface RemoteScopeOptions { + /** Exported endpoint method; omission retains the implementation member name. */ + readonly name?: string + /** + * Per-kind readers of the resources one call touches. Each receives the + * method's arguments as its own parameters in undecoded wire JSON form, a + * lookup parameter as its wire identity and a cancellation parameter as the + * call's signal, so it performs plain reads only, and returns one identity, + * several, or `undefined` for none. A throw, an empty list, or an identity + * other than a non-empty string refuses the call. For a kind without a + * reader, the Gateway reads the identity of a scoped Context whose Host + * adapter declares that `resource` and of lookup parameters declaring it, + * else the field registered in `typert.resources`: a top-level wire + * argument or, failing that, the first object argument. A call that carries + * no identity touches `global`. The readers and these defaults together + * must name every resource the method accesses: access control never checks + * a resource they omit. + */ + readonly resources?: TypertResourceReaders +} +``` + +```ts type-equiv +/** + * Explicit endpoint, stream, and resource choices for one direct Remote method. + * @template WireArgs - the decorated method's positional parameters as the + * wire carries them; the default `never` admits readers of any tuple. + */ +interface RemoteMethodOptions extends RemoteScopeOptions { + /** `stream`: deliver each Iterable item over the shared logical-stream carrier. */ + readonly mode?: 'stream' +} +``` + ## Typert 注册表 -`ctx.typert` 分开保存当前环境的 descriptor、显式选择的 Remote contribution、lookup 提供方与作用域上下文提供方。lookup 提供方拥有稳定 wire 声明和默认 resolver;Host 组合可以为同一个 key 配置 effect-scoped 同步或异步 resolver,配置卸载后恢复默认策略。各项注册都是由 Cordis 持有的 effect,并返回可等待的 disposer。 +`ctx.typert` 分开保存当前环境的 descriptor、显式选择的 Remote contribution、lookup 提供方、作用域上下文提供方与资源种类的默认字段。lookup 提供方拥有稳定 wire 声明和默认 resolver;Host 组合可以为同一个 key 配置 effect-scoped 同步或异步 resolver,配置卸载后恢复默认策略。各项注册都是由 Cordis 持有的 effect,并返回可等待的 disposer。 ```ts type-equiv /** Minimal Typert runtime consumed through dependency inversion. */ @@ -271,6 +365,7 @@ interface TypertRegistryContract { readonly remotes: TypertRemoteRegistry readonly lookups: TypertLookupRegistry readonly contexts: TypertContextRegistry + readonly resources: TypertResourceRegistry } ``` @@ -283,7 +378,7 @@ interface TypertRemoteNamespaceMap {} ## Host Gateway -Connection 会先解码 carrier envelope,再调用 `ctx.typertGateway`。请求将精确的具名 wire 字段与 carrier 的取消 signal 分开携带;基础设施与边界失败由 `TypertGatewayError` 承载,其 `gateway/*` 码就是普通的 `RemoteError` 码,因此 RPC 适配器会把每个经结构识别的 `RemoteError` 连同其 code 与 details 原样放行,只把无法识别的异常归并为 `gateway/internal`。 +Connection 会先解码 carrier envelope,再调用 `ctx.typertGateway`。请求将精确的具名 wire 字段、carrier 的取消 signal 与本次调用代表的 Peer 分开携带;每个载体与进程内调用方都显式给出该 Peer,Gateway 以 `gateway/peer-disposed` 拒绝已 dispose 的 Peer。对非 owner 调用,Gateway 在参数校验之后、任何 lookup 或 Context 解析之前解析其资源并询问 `ctx.remoteAccess.decide()`;基础设施与边界失败由 `TypertGatewayError` 承载,其 `gateway/*` 码就是普通的 `RemoteError` 码,因此 RPC 适配器会把每个经结构识别的 `RemoteError` 连同其 code 与 details 原样放行,只把无法识别的异常归并为 `gateway/internal`。 ```ts type-equiv /** One Remote method request after a carrier has decoded its envelope. */ @@ -301,8 +396,8 @@ interface InvokeRemoteRequest { readonly uplink?: AsyncIterable /** Carrier-provided frame capacity; absent for carriers without an encoded-frame inbox limit. */ readonly uplinkWindow?: RemoteUplinkWindow | undefined - /** Peer the call speaks for; absent means an in-process carrier, answered as the operator. */ - readonly peer?: PeerScope + /** Peer the call speaks for; every carrier and in-process caller names it explicitly. */ + readonly peer: PeerScope /** Carrier or direct-caller cancellation injected only into cancellation-aware methods. */ readonly signal?: AbortSignal } @@ -324,6 +419,7 @@ type TypertGatewayErrorCode = | 'gateway/lookup-not-found' | 'gateway/lookup-unavailable' | 'gateway/method-unavailable' + | 'gateway/peer-disposed' | 'gateway/protocol' | 'gateway/provider-mismatch' | 'gateway/result-invalid' @@ -338,8 +434,8 @@ interface TypertGateway { /** Carrier adapter shared by WebSocket and in-process transports. */ readonly wireStream: TypertGatewayWireStream /** - * Check for an active Client event stream. - * @returns whether a stream is open and has not been cancelled. + * Check for an active owner Client event stream, the only kind that can answer forwarded waterfalls. + * @returns whether an owner stream is open and has not been cancelled. */ hasLiveClient(): boolean /** @@ -404,6 +500,88 @@ interface TypertClientRemote extends TypertRemoteNamespaceMap { Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.zh.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md). + + +### `ctx.remoteAccess` — `RemoteAccessService` + +Registry of access policies, the Gateway's access decisions, list filtering, and the per-Peer access snapshot. Every composition that mounts the Gateway mounts this service too. + +```ts cordis-catalog +/** + * Register the policy of one non-owner principal kind in the caller's + * fiber; open snapshots are recomputed on registration and removal. + * @param kind - the principal kind the policy serves; never `owner`. + * @param policy - business-owned bindings and refusal reasons for principals of `kind`. + * @returns disposer removing this policy. + * @throws Error when `kind` already has a policy. + */ +registerPolicy>(kind: K, policy: RemoteAccessPolicy): () => void + +/** + * Report that the bindings or condition answers for `principal` may have + * changed. Business packages call this on every grant, revocation, mode + * change, and expiry; open snapshots re-push, and the next failure of each + * policy is logged again. + * @param principal - affected principal; omitted for every principal. + */ +invalidate(principal?: Principal): void + +/** + * Gateway-only: decide one request whose resources the Gateway resolved. + * The owner is allowed, and so is `remoteAccess/follow`: every Peer reads + * its own snapshot. Otherwise the action is allowed only when, on every + * listed resource, some binding of the principal's policy names that + * resource, has a role listing the action, and has no condition or a + * condition that allows. `bindings()` is called once per decision. + * @param request - the caller or recipient, the action, the resources it + * touches, and a call's arguments. + * @returns `allow`; or `deny` with `no-allow` when the principal kind has + * no policy, the first refusing condition's reason on the first resource + * no binding allows, else `explain()`'s reason, else `no-allow`; + * `policy-failed` when `bindings()`, a condition, or `explain()` throws, + * logged once per policy until the next `invalidate()`. The Gateway + * throws `remote-access/denied` for a denied call and drops a denied event. + */ +decide(request: RemoteAccessRequest): RemoteAccessDecision + +/** + * Filter items inside a Remote method. The invocation is read from the + * Context that accessed this service, so call it as + * `this.ctx.remoteAccess.allows(resource)` from the Remote method. Each + * check reads the policy's bindings again, so a long-running stream sees + * binding changes from the next check on. + * @param resource - resource of one result item. + * @returns true for the owner and outside a Remote call; otherwise whether + * the invocation's endpoint action is allowed on `resource` without call + * arguments, false when the policy is absent or fails. + */ +allows(resource: RemoteAccessResource): boolean + +/** + * The baseline a page opens with before its snapshot stream connects. A + * business package rendering a non-owner page assigns this value to + * {@link REMOTE_ACCESS_SNAPSHOT_KEY} after every index injection row, + * replacing the owner baseline the webServer index carries. + * @param principal - the page's caller identity. + * @returns the owner baseline for the owner; otherwise the principal's current grants. + */ +snapshotFor(principal: Principal): RemoteAccessBaseline + +/** + * Stream the calling Peer's access snapshot: one baseline, then `upsert` + * and `remove` frames after each `invalidate()` and each policy + * registration or removal that changes a grant. The owner receives only + * `{ owner: true }`. The generation ends when `signal` aborts or this + * service unloads, so a reloaded service starts every reader from a fresh + * baseline. + * @param signal - generation cancellation. + * @returns the access frames of one generation. + */ +@Remote({ mode: 'stream' }) async *follow(signal: AbortSignal): AsyncIterable +``` + +Source: [`packages/api/remote-access/src/index.ts`](../../packages/api/remote-access/src/index.ts) + ### `ctx.typert` — `TypertRegistry` @@ -476,8 +654,8 @@ Resolve strict generated definitions or conservative SRC markers against current ```ts cordis-catalog /** - * Check for an active Client event stream. - * @returns whether a stream is open and has not been cancelled. + * Check for an active owner Client event stream, the only kind that can answer forwarded waterfalls. + * @returns whether an owner stream is open and has not been cancelled. */ hasLiveClient(): boolean diff --git a/docs/subsystems/web-client.i18n.yaml b/docs/subsystems/web-client.i18n.yaml index 29bb05c709..593dcc3494 100644 --- a/docs/subsystems/web-client.i18n.yaml +++ b/docs/subsystems/web-client.i18n.yaml @@ -24,8 +24,8 @@ en: c063711f8247c240 zh: 5e54758bbaf1670c /web-client-architecture/conversation-and-presentation: - en: 71362f4ac9490bcb - zh: 025507ca615f5e2a + en: 9a22492f0d4359c6 + zh: 55d449e383e88f62 /web-client-architecture/data-paths: en: a254278432416473 zh: 567c2d3c6a20b304 diff --git a/docs/subsystems/web-client.md b/docs/subsystems/web-client.md index 3f0d7ecf99..bb68cd64a1 100644 --- a/docs/subsystems/web-client.md +++ b/docs/subsystems/web-client.md @@ -57,7 +57,7 @@ This pairing is not a second source of business truth. Host controllers decide d Web and desktop share the [Coding Tools preference](../../packages/client/ui-settings/README.md#use-this-package). It controls diagnostic Views, [available preset choices](../../packages/client/ui-agent-preset/README.md), changed-file cards, and the builtin HTML preview policy without changing Session records. -`ui-session` installs the Session scope adapter and publishes `useSessions`, `useSessionStatus`, `useSessionRetainInfo`, `useSession`, `sessionId`, and `useProjection`. `SessionProvider` inherits an outer binding or binds an explicit `SessionReference`, so concurrent subtrees can target different Sessions. Domain adapters add further standard sources without putting React hooks on the model objects. +`ui-session` installs the Session scope adapter and publishes `useSessions`, `useSessionStatus`, `useSessionRetainInfo`, `useSessionAccess`, `useSession`, `sessionId`, `useProjection`, and the Session-scope `useAccess` over the [remote-access](../../packages/api/remote-access/README.md) Client service. `SessionProvider` inherits an outer binding or binds an explicit `SessionReference`, so concurrent subtrees can target different Sessions. Domain adapters add further standard sources without putting React hooks on the model objects. `ui-conversation` binds once to each `SessionBinding.eventSource`. Its event registry correlates durable Session events and Client-only `assistant/live-chunk` updates into stable business Contexts, and its view registry materializes target snapshots. Chat Assistant, Trajectory Assistant, and Turn Tail interpret both live chunks and the compact streams embedded in durable settlements, so reconnect and paged history reproduce the same Assistant state without durable token rows. `ui-chat` and `ui-trajectory` register separate Definitions and builders: they may interpret the same event family, but they do not import or share each other's final display model. The shell selects a registered view and passes its snapshot through standard hooks and Slots. [Conversation](conversation.md) defines Context identity, replay, Location data, target builders, and keyed renderers. diff --git a/docs/subsystems/web-client.zh.md b/docs/subsystems/web-client.zh.md index 5230eef917..a3d62f738f 100644 --- a/docs/subsystems/web-client.zh.md +++ b/docs/subsystems/web-client.zh.md @@ -57,7 +57,7 @@ Connection 拥有请求 URL 解析、request correlation、`/api` carrier、trus Web 和桌面端共享[代码工作工具偏好](../../packages/client/ui-settings/README.zh.md#use-this-package)。它控制诊断 View、[可用的预设选项](../../packages/client/ui-agent-preset/README.zh.md)、改动文件卡片和内置 HTML 预览策略,不改变 Session 记录。 -`ui-session` 安装 Session scope adapter,并提供 `useSessions`、`useSessionStatus`、`useSessionRetainInfo`、`useSession`、`sessionId` 和 `useProjection`。`SessionProvider` 可以继承外围 binding,也可以绑定显式 `SessionReference`,因此并存子树可以指向不同 Session。领域 adapter 可以继续添加标准 source,但不会把 React hook 放进 model object。 +`ui-session` 安装 Session scope adapter,并在 [remote-access](../../packages/api/remote-access/README.zh.md) Client 服务之上提供 `useSessions`、`useSessionStatus`、`useSessionRetainInfo`、`useSessionAccess`、`useSession`、`sessionId`、`useProjection` 和会话作用域的 `useAccess`。`SessionProvider` 可以继承外围 binding,也可以绑定显式 `SessionReference`,因此并存子树可以指向不同 Session。领域 adapter 可以继续添加标准 source,但不会把 React hook 放进 model object。 `ui-conversation` 对每个 `SessionBinding.eventSource` 只绑定一次。它的 event registry 把持久 Session event 与 Client-only `assistant/live-chunk` update 关联成稳定的业务 Context,view registry 则 materialize target snapshot。Chat Assistant、Trajectory Assistant 与 Turn Tail 同时解释 live chunk 和持久 settlement 中嵌入的紧凑 stream,因此重连与分页历史无需持久 token 行即可复现相同 Assistant 状态。`ui-chat` 与 `ui-trajectory` 分别注册自己的 Definition 和 builder:它们可以解释同一 event family,但不会导入或共享彼此的最终 display model。Shell 选择一个已注册 view,再通过标准 hook 与 Slot 交付其 snapshot。[Conversation](conversation.zh.md)定义 Context identity、replay、Location data、target builder 与 keyed renderer。 diff --git a/docs/subsystems/web-server.md b/docs/subsystems/web-server.md index 4fcb42f3cc..df76650336 100644 --- a/docs/subsystems/web-server.md +++ b/docs/subsystems/web-server.md @@ -116,13 +116,23 @@ createSharedFetchHandler(channel: '/api'): ConnectionFetchHandler requestRejection(request: ConnectionTrustRequest): ConnectionRequestRejection /** - * Admit one request: it passes {@link requestRejection} and speaks for the - * operator, or it is refused with that status. + * Admit one network request: it passes {@link requestRejection} and speaks + * for {@link browserPeer}, or it is refused with that status. * @param request - request headers from the HTTP or upgrade request. - * @returns the operator Peer, or the rejection status. + * @returns the admitted Peer, or the rejection status. */ admit(request: ConnectionTrustRequest): PeerAdmission +/** + * Create an independent Peer. In-process only: no route, header, query, or + * cookie reaches it. Disposed with the Context that read `ctx.connection`, + * or earlier by its own `dispose()`. + * @param options - caller identity the Peer speaks for. + * @param options.principal - principal fixed for the Peer's lifetime. + * @returns a new Peer distinct from every other Peer. + */ +createPeer(options: { readonly principal: Principal }): PeerScope + /** * Authenticate one frontend index request, owning a token redirect or 401. * The cookie this mints is `Secure` when the mounted Web carrier serves TLS, @@ -141,6 +151,8 @@ authorizeIndex(request: ConnectionIndexRequest, response: ConnectionIndexRespons authenticatedUrl(baseUrl: string): string ``` +Types: [Principal](typert.md) + Source: [`packages/client/connection/src/rpc.ts`](../../packages/client/connection/src/rpc.ts) diff --git a/docs/subsystems/web-server.zh.md b/docs/subsystems/web-server.zh.md index 67d67b15de..ed53d0ec22 100644 --- a/docs/subsystems/web-server.zh.md +++ b/docs/subsystems/web-server.zh.md @@ -116,13 +116,23 @@ createSharedFetchHandler(channel: '/api'): ConnectionFetchHandler requestRejection(request: ConnectionTrustRequest): ConnectionRequestRejection /** - * Admit one request: it passes {@link requestRejection} and speaks for the - * operator, or it is refused with that status. + * Admit one network request: it passes {@link requestRejection} and speaks + * for {@link browserPeer}, or it is refused with that status. * @param request - request headers from the HTTP or upgrade request. - * @returns the operator Peer, or the rejection status. + * @returns the admitted Peer, or the rejection status. */ admit(request: ConnectionTrustRequest): PeerAdmission +/** + * Create an independent Peer. In-process only: no route, header, query, or + * cookie reaches it. Disposed with the Context that read `ctx.connection`, + * or earlier by its own `dispose()`. + * @param options - caller identity the Peer speaks for. + * @param options.principal - principal fixed for the Peer's lifetime. + * @returns a new Peer distinct from every other Peer. + */ +createPeer(options: { readonly principal: Principal }): PeerScope + /** * Authenticate one frontend index request, owning a token redirect or 401. * The cookie this mints is `Secure` when the mounted Web carrier serves TLS, @@ -141,6 +151,8 @@ authorizeIndex(request: ConnectionIndexRequest, response: ConnectionIndexRespons authenticatedUrl(baseUrl: string): string ``` +Types: [Principal](typert.zh.md) + Source: [`packages/client/connection/src/rpc.ts`](../../packages/client/connection/src/rpc.ts) diff --git a/docs/subsystems/workspace.md b/docs/subsystems/workspace.md index 0736c86fe7..b363ba184d 100644 --- a/docs/subsystems/workspace.md +++ b/docs/subsystems/workspace.md @@ -387,11 +387,17 @@ Host service backing the generated `ctx.remote.workspace` namespace. @Remote('unpinSession') unpinSession(request: WorkspaceUnpinSessionRequest): Promise /** - * Stream a complete Workspace baseline followed by ordered increments. + * Stream a complete Workspace baseline followed by ordered increments. A + * non-owner receives only the Workspaces and Sessions `allows()` admits. + * When a delivered Workspace is no longer admitted at its next change or + * the next order change, the stream sends a `replace` frame with the + * complete filtered state instead of a `remove`, because a Client never + * resurrects a removed Workspace; a `remove` frame reports a deletion and + * reaches only a Client that received that Workspace. * @param signal - generation cancellation. * @returns baseline followed by ordered Workspace increments. */ -@Remote({ mode: 'stream' }) follow(signal: AbortSignal): AsyncIterable +@Remote({ mode: 'stream' }) async *follow(signal: AbortSignal): AsyncIterable ``` Source: [`packages/api/workspace-controller/src/index.ts`](../../packages/api/workspace-controller/src/index.ts) diff --git a/docs/subsystems/workspace.zh.md b/docs/subsystems/workspace.zh.md index 1a366370e5..c04a05c97d 100644 --- a/docs/subsystems/workspace.zh.md +++ b/docs/subsystems/workspace.zh.md @@ -387,11 +387,17 @@ Host service backing the generated `ctx.remote.workspace` namespace. @Remote('unpinSession') unpinSession(request: WorkspaceUnpinSessionRequest): Promise /** - * Stream a complete Workspace baseline followed by ordered increments. + * Stream a complete Workspace baseline followed by ordered increments. A + * non-owner receives only the Workspaces and Sessions `allows()` admits. + * When a delivered Workspace is no longer admitted at its next change or + * the next order change, the stream sends a `replace` frame with the + * complete filtered state instead of a `remove`, because a Client never + * resurrects a removed Workspace; a `remove` frame reports a deletion and + * reaches only a Client that received that Workspace. * @param signal - generation cancellation. * @returns baseline followed by ordered Workspace increments. */ -@Remote({ mode: 'stream' }) follow(signal: AbortSignal): AsyncIterable +@Remote({ mode: 'stream' }) async *follow(signal: AbortSignal): AsyncIterable ``` Source: [`packages/api/workspace-controller/src/index.ts`](../../packages/api/workspace-controller/src/index.ts) 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 6a1c9afeec..c4887547ab 100644 --- a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts @@ -661,7 +661,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'KeyedStandardSource', - declaration: 'export type KeyedStandardSource = (key: string) => HostObservable | undefined;', + declaration: 'export type KeyedStandardSource = (key: string, member?: string) => HostObservable | undefined;', }, { name: 'KeyPropsOf', 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 7ec9c04ce8..13795cd4df 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -91,13 +91,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', 'useChat: UseChat', 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', @@ -105,6 +107,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useSession: SessionSnapshotSelector', 'sessionId: SessionId', 'useProjection: UseProjection', + 'useAccess: (action: RemoteEndpoint) => boolean', 'useTrajectory: UseTrajectory', ], keyDomain: '', @@ -151,13 +154,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'MessageId', ], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', 'useChat: UseChat', 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', @@ -165,6 +170,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useSession: SessionSnapshotSelector', 'sessionId: SessionId', 'useProjection: UseProjection', + 'useAccess: (action: RemoteEndpoint) => boolean', 'useTrajectory: UseTrajectory', ], keyDomain: '', @@ -176,7 +182,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.assistant-actions\', () => ctx.slots.register(\n { name: \'conversation.chat.assistant-actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:439', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:441', }, { key: 'conversation.chat.commandview', @@ -201,13 +207,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'CompactionSummaryNode', ], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', 'useChat: UseChat', 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', @@ -215,6 +223,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useSession: SessionSnapshotSelector', 'sessionId: SessionId', 'useProjection: UseProjection', + 'useAccess: (action: RemoteEndpoint) => boolean', 'useTrajectory: UseTrajectory', ], keyDomain: 'open: any string the owner dispatches (no compile-time key set), none are taken yet', @@ -224,7 +233,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.commandview\', () => ctx.slots.register(\n { name: \'conversation.chat.commandview\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:421', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:423', }, { key: 'conversation.chat.flow', @@ -242,13 +251,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'RenderEntry', ], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', 'useChat: UseChat', 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', @@ -256,6 +267,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useSession: SessionSnapshotSelector', 'sessionId: SessionId', 'useProjection: UseProjection', + 'useAccess: (action: RemoteEndpoint) => boolean', 'useTrajectory: UseTrajectory', ], keyDomain: '', @@ -267,7 +279,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.flow\', () => ctx.slots.register(\n { name: \'conversation.chat.flow\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:388', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:390', }, { key: 'conversation.chat.message.images', @@ -285,13 +297,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'MessageImageSource', ], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', 'useChat: UseChat', 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', @@ -299,6 +313,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useSession: SessionSnapshotSelector', 'sessionId: SessionId', 'useProjection: UseProjection', + 'useAccess: (action: RemoteEndpoint) => boolean', 'useTrajectory: UseTrajectory', ], keyDomain: '', @@ -310,7 +325,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.message.images\', () => ctx.slots.register(\n { name: \'conversation.chat.message.images\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:413', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:415', }, { key: 'conversation.chat.node', @@ -337,13 +352,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'TurnTailOwnerProps', ], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', 'useChat: UseChat', 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', @@ -351,6 +368,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useSession: SessionSnapshotSelector', 'sessionId: SessionId', 'useProjection: UseProjection', + 'useAccess: (action: RemoteEndpoint) => boolean', 'useTrajectory: UseTrajectory', ], keyDomain: 'fixed by the owner\'s key table { [Kind in ChatNodeKind]: { node: ChatNode } }, already taken: assistant-step, command, command-input, compaction, context, manual-compaction, model-retry, question-reply, steering, system-prompt, tool-call, turn-error, turn-max-tokens, turn-process, turn-tail, turn-trigger, unknown, user, workflow-run', @@ -380,7 +398,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.node\', () => ctx.slots.register(\n { name: \'conversation.chat.node\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:400', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:402', }, { key: 'conversation.chat.reasoning.body', @@ -394,13 +412,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', 'useChat: UseChat', 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', @@ -408,6 +428,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useSession: SessionSnapshotSelector', 'sessionId: SessionId', 'useProjection: UseProjection', + 'useAccess: (action: RemoteEndpoint) => boolean', 'useTrajectory: UseTrajectory', ], keyDomain: '', @@ -420,7 +441,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.reasoning.body\', () => ctx.slots.register(\n { name: \'conversation.chat.reasoning.body\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:427', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:429', }, { key: 'conversation.chat.turnTail', @@ -455,13 +476,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'TurnLocation', ], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', 'useChat: UseChat', 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', @@ -469,6 +492,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useSession: SessionSnapshotSelector', 'sessionId: SessionId', 'useProjection: UseProjection', + 'useAccess: (action: RemoteEndpoint) => boolean', 'useTrajectory: UseTrajectory', ], keyDomain: '', @@ -482,7 +506,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.turnTail\', () => ctx.slots.register(\n { name: \'conversation.chat.turnTail\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:433', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:435', }, { key: 'conversation.chat.user.images', @@ -500,13 +524,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'MessageImageSource', ], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', 'useChat: UseChat', 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', @@ -514,6 +540,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useSession: SessionSnapshotSelector', 'sessionId: SessionId', 'useProjection: UseProjection', + 'useAccess: (action: RemoteEndpoint) => boolean', 'useTrajectory: UseTrajectory', ], keyDomain: '', @@ -525,7 +552,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.user.images\', () => ctx.slots.register(\n { name: \'conversation.chat.user.images\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:415', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:417', }, { key: 'conversation.composer', @@ -550,13 +577,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'SessionSnapshot', ], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', 'useChat: UseChat', 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', @@ -564,6 +593,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useSession: SessionSnapshotSelector', 'sessionId: SessionId', 'useProjection: UseProjection', + 'useAccess: (action: RemoteEndpoint) => boolean', 'useTrajectory: UseTrajectory', ], keyDomain: '', @@ -593,19 +623,22 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'Workspace', ], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', 'useConversation: MaybeSnapshotSelectorHook', 'useInput: MaybeSnapshotSelectorHook', 'inputActions: InputActions | undefined', 'useSession: MaybeSnapshotSelectorHook', 'sessionId: SessionId | undefined', 'useProjection: UseProjection', + 'useAccess: (action: RemoteEndpoint) => boolean | undefined', ], keyDomain: '', hookContext: '', @@ -647,13 +680,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ownerProps: [], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', 'useChat: UseChat', 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', @@ -661,6 +696,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useSession: SessionSnapshotSelector', 'sessionId: SessionId', 'useProjection: UseProjection', + 'useAccess: (action: RemoteEndpoint) => boolean', 'useTrajectory: UseTrajectory', ], keyDomain: '', @@ -685,19 +721,22 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ownerProps: [], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', 'useConversation: MaybeSnapshotSelectorHook', 'useInput: MaybeSnapshotSelectorHook', 'inputActions: InputActions | undefined', 'useSession: MaybeSnapshotSelectorHook', 'sessionId: SessionId | undefined', 'useProjection: UseProjection', + 'useAccess: (action: RemoteEndpoint) => boolean | undefined', ], keyDomain: '', hookContext: '', @@ -722,13 +761,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', ], keyDomain: '', hookContext: '', @@ -751,19 +792,22 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', 'useConversation: MaybeSnapshotSelectorHook', 'useInput: MaybeSnapshotSelectorHook', 'inputActions: InputActions | undefined', 'useSession: MaybeSnapshotSelectorHook', 'sessionId: SessionId | undefined', 'useProjection: UseProjection', + 'useAccess: (action: RemoteEndpoint) => boolean | undefined', ], keyDomain: '', hookContext: '', @@ -788,13 +832,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', ], keyDomain: '', hookContext: '', @@ -819,13 +865,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'Workspace', ], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', ], keyDomain: '', hookContext: '', @@ -850,13 +898,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', ], keyDomain: '', hookContext: '', @@ -884,13 +934,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'InputControlOwnerProps', ], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', 'useChat: UseChat', 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', @@ -898,6 +950,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useSession: SessionSnapshotSelector', 'sessionId: SessionId', 'useProjection: UseProjection', + 'useAccess: (action: RemoteEndpoint) => boolean', 'useTrajectory: UseTrajectory', ], keyDomain: '', @@ -927,19 +980,22 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'DraftFileUploads', ], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', 'useConversation: MaybeSnapshotSelectorHook', 'useInput: MaybeSnapshotSelectorHook', 'inputActions: InputActions | undefined', 'useSession: MaybeSnapshotSelectorHook', 'sessionId: SessionId | undefined', 'useProjection: UseProjection', + 'useAccess: (action: RemoteEndpoint) => boolean | undefined', ], keyDomain: '', hookContext: '', @@ -986,13 +1042,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'SessionSnapshot', ], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', 'useChat: UseChat', 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', @@ -1000,6 +1058,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useSession: SessionSnapshotSelector', 'sessionId: SessionId', 'useProjection: UseProjection', + 'useAccess: (action: RemoteEndpoint) => boolean', 'useTrajectory: UseTrajectory', ], keyDomain: '', @@ -1045,13 +1104,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ownerProps: [], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', 'useChat: UseChat', 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', @@ -1059,6 +1120,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useSession: SessionSnapshotSelector', 'sessionId: SessionId', 'useProjection: UseProjection', + 'useAccess: (action: RemoteEndpoint) => boolean', 'useTrajectory: UseTrajectory', ], keyDomain: '', @@ -1082,13 +1144,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', 'useChat: UseChat', 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', @@ -1096,6 +1160,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useSession: SessionSnapshotSelector', 'sessionId: SessionId', 'useProjection: UseProjection', + 'useAccess: (action: RemoteEndpoint) => boolean', 'useTrajectory: UseTrajectory', ], keyDomain: '', @@ -1138,13 +1203,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ownerProps: [], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', 'useChat: UseChat', 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', @@ -1152,6 +1219,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useSession: SessionSnapshotSelector', 'sessionId: SessionId', 'useProjection: UseProjection', + 'useAccess: (action: RemoteEndpoint) => boolean', 'useTrajectory: UseTrajectory', ], keyDomain: '', @@ -1179,13 +1247,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', 'useChat: UseChat', 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', @@ -1193,6 +1263,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useSession: SessionSnapshotSelector', 'sessionId: SessionId', 'useProjection: UseProjection', + 'useAccess: (action: RemoteEndpoint) => boolean', 'useTrajectory: UseTrajectory', ], keyDomain: '', @@ -1218,13 +1289,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', 'useChat: UseChat', 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', @@ -1232,6 +1305,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useSession: SessionSnapshotSelector', 'sessionId: SessionId', 'useProjection: UseProjection', + 'useAccess: (action: RemoteEndpoint) => boolean', 'useTrajectory: UseTrajectory', ], keyDomain: '', @@ -1274,13 +1348,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ownerProps: [], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', 'useChat: UseChat', 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', @@ -1288,6 +1364,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useSession: SessionSnapshotSelector', 'sessionId: SessionId', 'useProjection: UseProjection', + 'useAccess: (action: RemoteEndpoint) => boolean', 'useTrajectory: UseTrajectory', ], keyDomain: '', @@ -1330,13 +1407,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', 'useChat: UseChat', 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', @@ -1344,6 +1423,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useSession: SessionSnapshotSelector', 'sessionId: SessionId', 'useProjection: UseProjection', + 'useAccess: (action: RemoteEndpoint) => boolean', 'useTrajectory: UseTrajectory', ], keyDomain: '', @@ -1367,13 +1447,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ownerProps: [], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', 'useChat: UseChat', 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', @@ -1381,6 +1463,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useSession: SessionSnapshotSelector', 'sessionId: SessionId', 'useProjection: UseProjection', + 'useAccess: (action: RemoteEndpoint) => boolean', 'useTrajectory: UseTrajectory', ], keyDomain: '', @@ -1404,13 +1487,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ownerProps: [], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', 'useChat: UseChat', 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', @@ -1418,6 +1503,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useSession: SessionSnapshotSelector', 'sessionId: SessionId', 'useProjection: UseProjection', + 'useAccess: (action: RemoteEndpoint) => boolean', 'useTrajectory: UseTrajectory', ], keyDomain: '', @@ -1462,13 +1548,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', 'useChat: UseChat', 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', @@ -1476,6 +1564,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useSession: SessionSnapshotSelector', 'sessionId: SessionId', 'useProjection: UseProjection', + 'useAccess: (action: RemoteEndpoint) => boolean', 'useTrajectory: UseTrajectory', ], keyDomain: '', @@ -1504,13 +1593,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', 'useChat: UseChat', 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', @@ -1518,6 +1609,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useSession: SessionSnapshotSelector', 'sessionId: SessionId', 'useProjection: UseProjection', + 'useAccess: (action: RemoteEndpoint) => boolean', 'useTrajectory: UseTrajectory', ], keyDomain: '', @@ -1545,13 +1637,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'SessionId', ], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', 'useChat: UseChat', 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', @@ -1559,6 +1653,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useSession: SessionSnapshotSelector', 'sessionId: SessionId', 'useProjection: UseProjection', + 'useAccess: (action: RemoteEndpoint) => boolean', 'useTrajectory: UseTrajectory', ], keyDomain: '', @@ -1603,13 +1698,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', 'useChat: UseChat', 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', @@ -1617,6 +1714,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useSession: SessionSnapshotSelector', 'sessionId: SessionId', 'useProjection: UseProjection', + 'useAccess: (action: RemoteEndpoint) => boolean', 'useTrajectory: UseTrajectory', ], keyDomain: '', @@ -1648,13 +1746,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'MessageImageSource', ], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', 'useChat: UseChat', 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', @@ -1662,6 +1762,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useSession: SessionSnapshotSelector', 'sessionId: SessionId', 'useProjection: UseProjection', + 'useAccess: (action: RemoteEndpoint) => boolean', 'useTrajectory: UseTrajectory', ], keyDomain: '', @@ -1694,13 +1795,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', 'useChat: UseChat', 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', @@ -1708,6 +1811,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useSession: SessionSnapshotSelector', 'sessionId: SessionId', 'useProjection: UseProjection', + 'useAccess: (action: RemoteEndpoint) => boolean', 'useTrajectory: UseTrajectory', ], keyDomain: 'fixed by the owner\'s key table { [Id in UserTextId]: { node: UserTextNode } }, already taken: command, file, folder, secret, session, skill, text', @@ -1760,13 +1864,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'ConversationViewRequest', ], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', 'useChat: UseChat', 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', @@ -1774,6 +1880,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useSession: SessionSnapshotSelector', 'sessionId: SessionId', 'useProjection: UseProjection', + 'useAccess: (action: RemoteEndpoint) => boolean', 'useTrajectory: UseTrajectory', ], keyDomain: '', @@ -1820,13 +1927,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', 'useChat: UseChat', 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', @@ -1834,6 +1943,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useSession: SessionSnapshotSelector', 'sessionId: SessionId', 'useProjection: UseProjection', + 'useAccess: (action: RemoteEndpoint) => boolean', 'useTrajectory: UseTrajectory', ], keyDomain: '', @@ -1880,13 +1990,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'SlotEntryDef', ], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', 'useChat: UseChat', 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', @@ -1894,6 +2006,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useSession: SessionSnapshotSelector', 'sessionId: SessionId', 'useProjection: UseProjection', + 'useAccess: (action: RemoteEndpoint) => boolean', 'useTrajectory: UseTrajectory', ], keyDomain: '', @@ -1924,13 +2037,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ownerProps: [], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', ], keyDomain: 'open: any string the owner dispatches (no compile-time key set), already taken: conversation', hookContext: '', @@ -1955,19 +2070,22 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ownerProps: [], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', 'useConversation: MaybeSnapshotSelectorHook', 'useInput: MaybeSnapshotSelectorHook', 'inputActions: InputActions | undefined', 'useSession: MaybeSnapshotSelectorHook', 'sessionId: SessionId | undefined', 'useProjection: UseProjection', + 'useAccess: (action: RemoteEndpoint) => boolean | undefined', ], keyDomain: '', hookContext: '', @@ -2011,13 +2129,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', ], keyDomain: '', hookContext: '', @@ -2049,13 +2169,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', ], keyDomain: 'open: any string the owner dispatches (no compile-time key set), already taken: @deepseek-ai/dsh-experimental-voice-input-bundle', hookContext: '', @@ -2089,13 +2211,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'ConfigPageForm', ], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', ], keyDomain: 'open: any string the owner dispatches (no compile-time key set), already taken: @deepseek-ai/dsh-experimental-cot-translation-bundle, @deepseek-ai/dsh-experimental-voice-input-bundle', hookContext: '', @@ -2142,13 +2266,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'PluginsSubject', ], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', ], keyDomain: '', hookContext: '', @@ -2192,13 +2318,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'PluginsSubject', ], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', ], keyDomain: '', hookContext: '', @@ -2242,13 +2370,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'PluginsSubject', ], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', ], keyDomain: '', hookContext: '', @@ -2292,13 +2422,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'ConfigPageForm', ], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', ], keyDomain: '', hookContext: '', @@ -2335,13 +2467,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'ConfigPageForm', ], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', ], keyDomain: 'open: any string the owner dispatches (no compile-time key set), none are taken yet', hookContext: '', @@ -2364,13 +2498,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', ], keyDomain: '', hookContext: '', @@ -2398,13 +2534,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'Branded', ], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', 'useChat: UseChat', 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', @@ -2412,6 +2550,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useSession: SessionSnapshotSelector', 'sessionId: SessionId', 'useProjection: UseProjection', + 'useAccess: (action: RemoteEndpoint) => boolean', 'useTrajectory: UseTrajectory', ], keyDomain: '', @@ -2437,13 +2576,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', ], keyDomain: '', hookContext: '', @@ -2487,13 +2628,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', ], keyDomain: '', hookContext: '', @@ -2518,13 +2661,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', ], keyDomain: '', hookContext: '', @@ -2568,13 +2713,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', ], keyDomain: '', hookContext: '', @@ -2610,13 +2757,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', ], keyDomain: '', hookContext: '', @@ -2641,13 +2790,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', ], keyDomain: '', hookContext: '', @@ -2691,13 +2842,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', ], keyDomain: '', hookContext: '', @@ -2729,13 +2882,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'ProviderDirectoryEntry', ], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', ], keyDomain: 'open: any string the owner dispatches (no compile-time key set), none are taken yet', hookContext: '', @@ -2756,13 +2911,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ownerProps: [], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', ], keyDomain: '', hookContext: '', @@ -2806,13 +2963,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', ], keyDomain: '', hookContext: '', @@ -2857,13 +3016,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', ], keyDomain: '', hookContext: '', @@ -2907,13 +3068,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', ], keyDomain: '', hookContext: '', @@ -2942,13 +3105,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', ], keyDomain: '', hookContext: '', @@ -2971,13 +3136,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ownerProps: [], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', ], keyDomain: '', hookContext: '', @@ -3000,13 +3167,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ownerProps: [], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', ], keyDomain: '', hookContext: '', @@ -3048,13 +3217,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ownerProps: [], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', ], keyDomain: '', hookContext: '', @@ -3097,13 +3268,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'QuotaNoticeCode', ], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', ], keyDomain: '', hookContext: '', @@ -3114,7 +3287,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'shell.quota-notice\', () => ctx.slots.register(\n { name: \'shell.quota-notice\', select: owner => null },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:447', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:449', }, { key: 'sidebar', @@ -3128,13 +3301,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', ], keyDomain: '', hookContext: '', @@ -3159,13 +3334,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', ], keyDomain: '', hookContext: '', @@ -3190,13 +3367,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', ], keyDomain: '', hookContext: '', @@ -3219,13 +3398,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ownerProps: [], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', 'useChat: UseChat', 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', @@ -3233,6 +3414,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useSession: SessionSnapshotSelector', 'sessionId: SessionId', 'useProjection: UseProjection', + 'useAccess: (action: RemoteEndpoint) => boolean', 'useTrajectory: UseTrajectory', ], keyDomain: '', @@ -3277,13 +3459,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', ], keyDomain: '', hookContext: '', @@ -3327,13 +3511,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', ], keyDomain: '', hookContext: '', @@ -3364,13 +3550,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ownerProps: [], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', 'useChat: UseChat', 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', @@ -3378,6 +3566,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useSession: SessionSnapshotSelector', 'sessionId: SessionId', 'useProjection: UseProjection', + 'useAccess: (action: RemoteEndpoint) => boolean', 'useTrajectory: UseTrajectory', ], keyDomain: 'open: any string the owner dispatches (no compile-time key set), none are taken yet', @@ -3417,13 +3606,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ownerProps: [], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', 'useChat: UseChat', 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', @@ -3431,6 +3622,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useSession: SessionSnapshotSelector', 'sessionId: SessionId', 'useProjection: UseProjection', + 'useAccess: (action: RemoteEndpoint) => boolean', 'useTrajectory: UseTrajectory', ], keyDomain: 'open: any string the owner dispatches (no compile-time key set), none are taken yet', @@ -3471,13 +3663,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'DocumentContent', ], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', 'useChat: UseChat', 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', @@ -3485,6 +3679,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useSession: SessionSnapshotSelector', 'sessionId: SessionId', 'useProjection: UseProjection', + 'useAccess: (action: RemoteEndpoint) => boolean', 'useTrajectory: UseTrajectory', ], keyDomain: 'open: any string the owner dispatches (no compile-time key set), none are taken yet', @@ -3526,13 +3721,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'DocumentTextPage', ], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', 'useChat: UseChat', 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', @@ -3540,6 +3737,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useSession: SessionSnapshotSelector', 'sessionId: SessionId', 'useProjection: UseProjection', + 'useAccess: (action: RemoteEndpoint) => boolean', 'useTrajectory: UseTrajectory', ], keyDomain: 'open: any string the owner dispatches (no compile-time key set), none are taken yet', @@ -3582,13 +3780,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ownerProps: [], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', 'useChat: UseChat', 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', @@ -3596,6 +3796,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useSession: SessionSnapshotSelector', 'sessionId: SessionId', 'useProjection: UseProjection', + 'useAccess: (action: RemoteEndpoint) => boolean', 'useTrajectory: UseTrajectory', ], keyDomain: '', @@ -3630,13 +3831,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'DocumentContent', ], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', 'useChat: UseChat', 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', @@ -3644,6 +3847,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useSession: SessionSnapshotSelector', 'sessionId: SessionId', 'useProjection: UseProjection', + 'useAccess: (action: RemoteEndpoint) => boolean', 'useTrajectory: UseTrajectory', ], keyDomain: 'open: any string the owner dispatches (no compile-time key set), none are taken yet', @@ -3686,13 +3890,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ownerProps: [], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', 'useChat: UseChat', 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', @@ -3700,6 +3906,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useSession: SessionSnapshotSelector', 'sessionId: SessionId', 'useProjection: UseProjection', + 'useAccess: (action: RemoteEndpoint) => boolean', 'useTrajectory: UseTrajectory', ], keyDomain: '', @@ -3742,13 +3949,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ownerProps: [], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', 'useChat: UseChat', 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', @@ -3756,6 +3965,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useSession: SessionSnapshotSelector', 'sessionId: SessionId', 'useProjection: UseProjection', + 'useAccess: (action: RemoteEndpoint) => boolean', 'useTrajectory: UseTrajectory', ], keyDomain: '', @@ -3786,13 +3996,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ownerProps: [], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', 'useChat: UseChat', 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', @@ -3800,6 +4012,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useSession: SessionSnapshotSelector', 'sessionId: SessionId', 'useProjection: UseProjection', + 'useAccess: (action: RemoteEndpoint) => boolean', 'useTrajectory: UseTrajectory', ], keyDomain: '', @@ -3830,13 +4043,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', 'useChat: UseChat', 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', @@ -3844,6 +4059,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useSession: SessionSnapshotSelector', 'sessionId: SessionId', 'useProjection: UseProjection', + 'useAccess: (action: RemoteEndpoint) => boolean', 'useTrajectory: UseTrajectory', ], keyDomain: 'open: any string the owner dispatches (no compile-time key set), none are taken yet', @@ -3890,13 +4106,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'TabRecord', ], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', 'useChat: UseChat', 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', @@ -3904,6 +4122,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useSession: SessionSnapshotSelector', 'sessionId: SessionId', 'useProjection: UseProjection', + 'useAccess: (action: RemoteEndpoint) => boolean', 'useTrajectory: UseTrajectory', ], keyDomain: '', @@ -3948,13 +4167,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'SessionId', ], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', ], keyDomain: '', hookContext: '', @@ -4000,13 +4221,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'SessionId', ], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', ], keyDomain: '', hookContext: '', @@ -4031,13 +4254,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', ], keyDomain: '', hookContext: '', @@ -4060,13 +4285,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ownerProps: [], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', ], keyDomain: '', hookContext: '', @@ -4091,13 +4318,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', ], keyDomain: '', hookContext: '', @@ -4122,13 +4351,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', ], keyDomain: '', hookContext: '', @@ -4175,13 +4406,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'SessionId', ], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', ], keyDomain: '', hookContext: 'MenuOpenState', @@ -4231,13 +4464,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'SessionId', ], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', ], keyDomain: '', hookContext: '', @@ -4266,13 +4501,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'MessageImageSource', ], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', 'useChat: UseChat', 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', @@ -4280,6 +4517,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useSession: SessionSnapshotSelector', 'sessionId: SessionId', 'useProjection: UseProjection', + 'useAccess: (action: RemoteEndpoint) => boolean', 'useTrajectory: UseTrajectory', ], keyDomain: '', @@ -4321,13 +4559,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'UseDisclosure', ], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', 'useChat: UseChat', 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', @@ -4335,6 +4575,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useSession: SessionSnapshotSelector', 'sessionId: SessionId', 'useProjection: UseProjection', + 'useAccess: (action: RemoteEndpoint) => boolean', 'useTrajectory: UseTrajectory', ], keyDomain: 'open: any string the owner dispatches (no compile-time key set), already taken: ask_user_question, bash, cordis_define, cordis_inspect_list, cordis_inspect_query, cordis_inspect_self, cordis_run, cordis_stop, cordis_undefine, create_goal, edit, get_goal, glob, grep, interrupt_agent, job_kill, job_list, job_output, list_agents, list_subagent_models, lsp, present, ralph, read, read_image, schedule_create, schedule_delete, schedule_list, schedule_update, send_message, session_event_read, session_event_search, session_event_trace, session_search, session_trace, skill, spawn_teammate, subagent, team_task_create, team_task_get, team_task_list, team_task_update, terminal_close, terminal_list, terminal_open, terminal_read, terminal_signal, todo_write, update_goal, wait_agent, web_fetch, web_search, workflow, write', @@ -4424,13 +4665,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'CordisDynamicPluginRunId', ], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', 'useChat: UseChat', 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', @@ -4438,6 +4681,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useSession: SessionSnapshotSelector', 'sessionId: SessionId', 'useProjection: UseProjection', + 'useAccess: (action: RemoteEndpoint) => boolean', 'useTrajectory: UseTrajectory', ], keyDomain: 'open: any string the owner dispatches (no compile-time key set), none are taken yet', @@ -4470,13 +4714,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'Workspace', ], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', ], keyDomain: 'fixed by the owner\'s key table { [K in ToastKind]: ToastNotice }, already taken: archived, archivedNotOpenable, createFailed, defaultWorkspaceFailed, forkRequiresOpen, pinFailed, sessionIdCopied, sessionIdCopyFailed, shortcutForkFailed, stoppedAndArchived, unpinFailed', hookContext: '', diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index ae9f632f95..6d643e0099 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -754,8 +754,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [], }, { - signature: 'readonly operator: PeerScope', - description: 'The operator Peer every admitted request speaks for; its scope lives as long as Connection.', + signature: 'readonly browserPeer: PeerScope', + description: 'The Peer every authenticated browser connection shares; its principal is the owner and its scope lives as long as Connection.', parameters: [], }, { @@ -777,9 +777,15 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'admit(request: ConnectionTrustRequest): PeerAdmission', - description: 'Admit one request: it passes requestRejection and speaks for the operator, or it is refused with that status.', + description: 'Admit one network request: it passes requestRejection and speaks for browserPeer, or it is refused with that status.', parameters: [{ name: 'request', description: 'request headers from the HTTP or upgrade request.' }], - returns: 'the operator Peer, or the rejection status.', + returns: 'the admitted Peer, or the rejection status.', + }, + { + signature: 'createPeer(options: { readonly principal: Principal }): PeerScope', + description: 'Create an independent Peer. In-process only: no route, header, query, or cookie reaches it. Disposed with the Context that read `ctx.connection`, or earlier by its own `dispose()`.', + parameters: [{ name: 'options', description: '.principal - principal fixed for the Peer\'s lifetime.' }], + returns: 'a new Peer distinct from every other Peer.', }, { signature: 'authorizeIndex(request: ConnectionIndexRequest, response: ConnectionIndexResponse): boolean', @@ -1873,6 +1879,49 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, + { + key: 'remoteAccess', + summary: 'Registry of access policies, the Gateway\'s access decisions, list filtering, and the per-Peer access snapshot.', + description: 'Registry of access policies, the Gateway\'s access decisions, list filtering, and the per-Peer access snapshot. Every composition that mounts the Gateway mounts this service too.', + methods: [ + { + signature: 'registerPolicy>(kind: K, policy: RemoteAccessPolicy): () => void', + description: 'Register the policy of one non-owner principal kind in the caller\'s fiber; open snapshots are recomputed on registration and removal.', + parameters: [{ name: 'kind', description: 'the principal kind the policy serves; never `owner`.' }, { name: 'policy', description: 'business-owned bindings and refusal reasons for principals of `kind`.' }], + returns: 'disposer removing this policy.', + throws: ['Error when `kind` already has a policy.'], + }, + { + signature: 'invalidate(principal?: Principal): void', + description: 'Report that the bindings or condition answers for `principal` may have changed. Business packages call this on every grant, revocation, mode change, and expiry; open snapshots re-push, and the next failure of each policy is logged again.', + parameters: [{ name: 'principal', description: 'affected principal; omitted for every principal.' }], + }, + { + signature: 'decide(request: RemoteAccessRequest): RemoteAccessDecision', + description: 'Gateway-only: decide one request whose resources the Gateway resolved. The owner is allowed, and so is `remoteAccess/follow`: every Peer reads its own snapshot. Otherwise the action is allowed only when, on every listed resource, some binding of the principal\'s policy names that resource, has a role listing the action, and has no condition or a condition that allows. `bindings()` is called once per decision.', + parameters: [{ name: 'request', description: 'the caller or recipient, the action, the resources it touches, and a call\'s arguments.' }], + returns: '`allow`; or `deny` with `no-allow` when the principal kind has no policy, the first refusing condition\'s reason on the first resource no binding allows, else `explain()`\'s reason, else `no-allow`; `policy-failed` when `bindings()`, a condition, or `explain()` throws, logged once per policy until the next `invalidate()`. The Gateway throws `remote-access/denied` for a denied call and drops a denied event.', + }, + { + signature: 'allows(resource: RemoteAccessResource): boolean', + description: 'Filter items inside a Remote method. The invocation is read from the Context that accessed this service, so call it as `this.ctx.remoteAccess.allows(resource)` from the Remote method. Each check reads the policy\'s bindings again, so a long-running stream sees binding changes from the next check on.', + parameters: [{ name: 'resource', description: 'resource of one result item.' }], + returns: 'true for the owner and outside a Remote call; otherwise whether the invocation\'s endpoint action is allowed on `resource` without call arguments, false when the policy is absent or fails.', + }, + { + signature: 'snapshotFor(principal: Principal): RemoteAccessBaseline', + description: 'The baseline a page opens with before its snapshot stream connects. A business package rendering a non-owner page assigns this value to REMOTE_ACCESS_SNAPSHOT_KEY after every index injection row, replacing the owner baseline the webServer index carries.', + parameters: [{ name: 'principal', description: 'the page\'s caller identity.' }], + returns: 'the owner baseline for the owner; otherwise the principal\'s current grants.', + }, + { + signature: '@Remote({ mode: \'stream\' }) async *follow(signal: AbortSignal): AsyncIterable', + description: 'Stream the calling Peer\'s access snapshot: one baseline, then `upsert` and `remove` frames after each `invalidate()` and each policy registration or removal that changes a grant. The owner receives only `{ owner: true }`. The generation ends when `signal` aborts or this service unloads, so a reloaded service starts every reader from a fresh baseline.', + parameters: [{ name: 'signal', description: 'generation cancellation.' }], + returns: 'the access frames of one generation.', + }, + ], + }, { key: 'sandbox', summary: 'Abstract process-sandbox service.', @@ -2051,7 +2100,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ returns: 'the new Session identity.', }, { - signature: '@Remote(\'prompt\') prompt(request: SessionPromptRequest, signal: AbortSignal): Promise', + signature: '@Remote(\'prompt\') async prompt(request: SessionPromptRequest, signal: AbortSignal): Promise', description: 'Admit one prompt after explicitly resuming its Session.', parameters: [{ name: 'request', description: 'Session identity, prompt content, source metadata, and delivery mode.' }, { name: 'signal', description: 'caller cancellation before prompt admission begins.' }], returns: 'acknowledgement that the Agent accepted the prompt.', @@ -2075,13 +2124,13 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ returns: 'acknowledgement that cancellation was requested.', }, { - signature: '@Remote(\'page\') page(request: SessionPageRequest, signal: AbortSignal): Promise', + signature: '@Remote({ name: \'page\', resources: { session: addressedSession } }) page(request: SessionPageRequest, signal: AbortSignal): Promise', description: 'Read one cold-safe, message-aligned Session history page.', parameters: [{ name: 'request', description: 'durable address, backward cursor, and page budget.' }, { name: 'signal', description: 'cancellation for persistence reads.' }], returns: 'one chronological page.', }, { - signature: '@Remote({ mode: \'stream\' }) follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable', + signature: '@Remote({ mode: \'stream\', resources: { session: addressedSession } }) follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable', description: 'Follow one Session log from its opening or resume cursor.', parameters: [{ name: 'request', description: 'durable address and last committed sequence already held by the caller.' }, { name: 'signal', description: 'cancellation owned by the Remote stream carrier.' }], returns: 'a complete opening snapshot followed by gap-free durable event frames and optional cursorless assistant-stream frames.', @@ -2093,7 +2142,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ returns: 'a sequenced baseline, cached hints when migration is deferred, or null when absent.', }, { - signature: '@Remote({ mode: \'stream\' }) control(signal: AbortSignal): AsyncIterable', + signature: '@Remote({ mode: \'stream\' }) async *control(signal: AbortSignal): AsyncIterable', description: 'Stream a complete live-control baseline followed by replacement frames.', parameters: [{ name: 'signal', description: 'cancellation owned by the Remote stream carrier.' }], returns: 'one complete baseline followed by live replacement frames.', @@ -3459,9 +3508,9 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'hasLiveClient(): boolean', - description: 'Check for an active Client event stream.', + description: 'Check for an active owner Client event stream, the only kind that can answer forwarded waterfalls.', parameters: [], - returns: 'whether a stream is open and has not been cancelled.', + returns: 'whether an owner stream is open and has not been cancelled.', }, { signature: 'registerRemoteEvents( source: TypertRemoteEventSource, host: RemoteEventHostInfo, ): () => Promise', @@ -3768,8 +3817,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ returns: 'the complete resulting pin set, most recently pinned first.', }, { - signature: '@Remote({ mode: \'stream\' }) follow(signal: AbortSignal): AsyncIterable', - description: 'Stream a complete Workspace baseline followed by ordered increments.', + signature: '@Remote({ mode: \'stream\' }) async *follow(signal: AbortSignal): AsyncIterable', + description: 'Stream a complete Workspace baseline followed by ordered increments. A non-owner receives only the Workspaces and Sessions `allows()` admits. When a delivered Workspace is no longer admitted at its next change or the next order change, the stream sends a `replace` frame with the complete filtered state instead of a `remove`, because a Client never resurrects a removed Workspace; a `remove` frame reports a deletion and reaches only a Client that received that Workspace.', parameters: [{ name: 'signal', description: 'generation cancellation.' }], returns: 'baseline followed by ordered Workspace increments.', }, @@ -4993,7 +5042,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ConnectionFetchHandler', - declaration: 'export interface ConnectionFetchHandler {\n requestBodyMode(request: {\n readonly method: string;\n readonly url: URL;\n }): ConnectionRequestBodyMode;\n fetch(request: Request): Promise;\n}', + declaration: 'export interface ConnectionFetchHandler {\n requestBodyMode(request: {\n readonly method: string;\n readonly url: URL;\n }): ConnectionRequestBodyMode;\n fetch(request: Request, peer: PeerScope): Promise;\n}', }, { name: 'ConnectionFetchMethod', @@ -5645,7 +5694,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 uplinkWindow?: RemoteUplinkWindow | undefined;\n readonly peer?: PeerScope;\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 uplinkWindow?: RemoteUplinkWindow | undefined;\n readonly peer: PeerScope;\n readonly signal?: AbortSignal;\n}', }, { name: 'JobAppendOptions', @@ -6185,7 +6234,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'PeerScope', - declaration: 'export interface PeerScope {\n readonly id: PeerId;\n readonly ctx: Context;\n dispose(): Promise;\n}', + declaration: 'export interface PeerScope {\n readonly id: PeerId;\n readonly ctx: Context;\n readonly principal: Principal;\n readonly signal: AbortSignal;\n dispose(): Promise;\n}', }, { name: 'PendingModule', @@ -6311,6 +6360,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'PreToolDecision', declaration: 'export type PreToolDecision = {\n kind: \'allow\';\n} | {\n kind: \'deny\';\n reason: string;\n info?: ToolErrorInfo;\n} | {\n kind: \'cancel\';\n} | {\n kind: \'ask\';\n reason?: string;\n displayReason?: {\n readonly en: string;\n readonly [locale: string]: string;\n };\n};', }, + { + name: 'Principal', + declaration: 'export type Principal = PrincipalMap[keyof PrincipalMap];', + }, + { + name: 'PrincipalMap', + declaration: 'export interface PrincipalMap {\n owner: {\n readonly kind: \'owner\';\n };\n}', + }, { name: 'ProductEvent', declaration: 'export type ProductEvent = {\n [K in keyof ProductEventMap]: {\n eventName: K;\n attributes: ProductEventMap[K];\n timestamp: number;\n };\n}[keyof ProductEventMap];', @@ -6483,6 +6540,62 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'Reload', declaration: 'export interface Reload {\n filename: string;\n modules: ReloadModules;\n runtime?: Plugin.Runtime | undefined;\n}', }, + { + name: 'RemoteAccessAction', + declaration: 'export type RemoteAccessAction = RemoteAccessEndpoint | `event:${string}`;', + }, + { + name: 'RemoteAccessBaseline', + declaration: 'export type RemoteAccessBaseline = Extract;', + }, + { + name: 'RemoteAccessBinding', + declaration: 'export type RemoteAccessBinding = {\n [K in RemoteAccessResourceKind]: {\n readonly role: RemoteAccessRole;\n readonly resource: RemoteAccessResourceOf;\n readonly condition?: RemoteAccessCondition;\n };\n}[RemoteAccessResourceKind];', + }, + { + name: 'RemoteAccessCondition', + declaration: 'export type RemoteAccessCondition = (request: {\n readonly action: RemoteAccessAction;\n readonly resource: RemoteAccessResourceOf;\n readonly args?: Readonly>;\n}) => RemoteAccessDecision;', + }, + { + name: 'RemoteAccessDecision', + declaration: 'export type RemoteAccessDecision = {\n readonly kind: \'allow\';\n} | {\n readonly kind: \'deny\';\n readonly reason: string;\n};', + }, + { + name: 'RemoteAccessEndpoint', + declaration: 'export type RemoteAccessEndpoint = `${string}/${string}`;', + }, + { + name: 'RemoteAccessFrame', + declaration: 'export type RemoteAccessFrame = {\n readonly type: \'baseline\';\n readonly owner: true;\n} | {\n readonly type: \'baseline\';\n readonly owner: false;\n readonly grants: readonly RemoteAccessGrant[];\n} | {\n readonly type: \'upsert\';\n readonly grant: RemoteAccessGrant;\n} | {\n readonly type: \'remove\';\n readonly resource: RemoteAccessResource;\n};', + }, + { + name: 'RemoteAccessGrant', + declaration: 'export interface RemoteAccessGrant {\n readonly resource: RemoteAccessResource;\n readonly actions: readonly string[];\n}', + }, + { + name: 'RemoteAccessPolicy', + declaration: 'export interface RemoteAccessPolicy

{\n bindings(principal: P): readonly RemoteAccessBinding[];\n explain?(request: RemoteAccessRequest

): string | undefined;\n}', + }, + { + name: 'RemoteAccessRequest', + declaration: 'export interface RemoteAccessRequest

{\n readonly principal: P;\n readonly action: RemoteAccessAction;\n readonly resources: readonly RemoteAccessResource[];\n readonly args?: Readonly>;\n}', + }, + { + name: 'RemoteAccessResource', + declaration: 'export type RemoteAccessResource = {\n readonly kind: \'global\';\n} | {\n [Kind in TypertResourceKind]: {\n readonly kind: Kind;\n readonly id: TypertResourceId;\n };\n}[TypertResourceKind];', + }, + { + name: 'RemoteAccessResourceKind', + declaration: 'export type RemoteAccessResourceKind = \'global\' | TypertResourceKind;', + }, + { + name: 'RemoteAccessResourceOf', + declaration: 'export type RemoteAccessResourceOf = Extract;', + }, + { + name: 'RemoteAccessRole', + declaration: 'export interface RemoteAccessRole {\n readonly name: string;\n readonly resource: K;\n readonly actions: readonly RemoteAccessAction[];\n}', + }, { name: 'RemoteError', declaration: 'export class RemoteError extends Error {\n readonly isDSHRemoteError: true;\n constructor(readonly code: Code, message: string, readonly details: RemoteErrorDetailsMap[Code], options?: ErrorOptions);\n}', @@ -8173,7 +8286,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'TypertGatewayWireStream', - 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}', + declaration: 'export interface TypertGatewayWireStream {\n readonly open: (endpoint: string, payload: unknown, uplink: AsyncIterable, peer: PeerScope, signal: AbortSignal) => Promise>;\n readonly failure: (error: unknown) => {\n readonly code: string;\n readonly message: string;\n readonly details: object;\n };\n}', }, { name: 'TypertMemberModel', @@ -8205,7 +8318,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'TypertRemoteEventFrame', - declaration: 'export interface TypertRemoteEventFrame {\n readonly event: string;\n readonly args: readonly unknown[];\n}', + declaration: 'export interface TypertRemoteEventFrame {\n readonly event: string;\n readonly args: readonly unknown[];\n readonly resources?: Readonly>;\n}', }, { name: 'TypertRemoteEventInvocation', @@ -8227,6 +8340,26 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'TypertRemoteValue', declaration: 'export interface TypertRemoteValue {\n readonly create: () => TypertSchema;\n readonly description?: string | undefined;\n readonly jsonSchema?: Readonly> | undefined;\n readonly jsonSchemaError?: string | undefined;\n}', }, + { + name: 'TypertResource', + declaration: 'export interface TypertResource {\n readonly [RESOURCE_ID]: Id;\n}', + }, + { + name: 'TypertResourceId', + declaration: 'export type TypertResourceId = Resource extends TypertResource ? Id : never;', + }, + { + name: 'TypertResourceKind', + declaration: 'export type TypertResourceKind = Extract;', + }, + { + name: 'TypertResourceMap', + declaration: 'export interface TypertResourceMap {\n}', + }, + { + name: 'TypertResourceReader', + declaration: 'export type TypertResourceReader = {\n read(...args: readonly unknown[]): unknown;\n}[\'read\'];', + }, { name: 'TypertSchema', declaration: 'export interface TypertSchema {\n parse(value: unknown): Output;\n}', @@ -8557,7 +8690,7 @@ 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};', + declaration: 'export type WorkspaceFollowIncrement = {\n readonly type: \'replace\';\n readonly value: WorkspaceBaseline;\n} | {\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: 'WorkspaceInsertBeforeRequest', diff --git a/packages/preset/agent-preset/skills/cordis-composition-reference/references/packages.md b/packages/preset/agent-preset/skills/cordis-composition-reference/references/packages.md index 8446d4bb43..e11a24e3e7 100644 --- a/packages/preset/agent-preset/skills/cordis-composition-reference/references/packages.md +++ b/packages/preset/agent-preset/skills/cordis-composition-reference/references/packages.md @@ -17,6 +17,7 @@ Every package below exports a Cordis plugin that a bundle patch can name in a Lo | `@deepseek-ai/dsh-api-account-controller` | no | Expose safe account operations over authenticated Remote | | `@deepseek-ai/dsh-api-gateway` | yes | Typert Remote Host dispatcher and Client API endpoint | | `@deepseek-ai/dsh-api-job-controller` | yes | Job Remote observation stream and the reference-counted client job-output service | +| `@deepseek-ai/dsh-api-remote-access` | no | Method-level Remote access control for non-owner Peers and the per-Peer access snapshot | | `@deepseek-ai/dsh-api-remotes` | no | Remote BFF assembly for application-selected Host capabilities | | `@deepseek-ai/dsh-api-session-controller` | yes | Session Remote commands, cold reads, and live control transport | | `@deepseek-ai/dsh-api-settings-controller` | yes | Remote owner for the configuration surfaces over the settings-domain seams | diff --git a/scripts/gen-client-catalog.ts b/scripts/gen-client-catalog.ts index 0532775af7..b777ae7420 100644 --- a/scripts/gen-client-catalog.ts +++ b/scripts/gen-client-catalog.ts @@ -43,12 +43,14 @@ const MAX_DECL_CHARS = 1200 /** * Line budget for ONE slot's expanded report. The whole point of narrowing to a * single slot is to spend less context, so a report a model cannot finish - * reading is a defect rather than a detail. The widest measured slot renders 60 - * lines, so this leaves room to document a slot properly while catching the two - * ways a report runs away: an owner share that hands down a subsystem instead of - * a share, and prose that grew into a manual. + * reading is a defect rather than a detail. The budget leaves room to document + * a slot properly while catching the two ways a report runs away: an owner + * share that hands down a subsystem instead of a share, and prose that grew + * into a manual. Every report also lists one line per standard prop seat of its + * scope, so the widest Session-scope slot (`tool.call.toolview`, with the + * global and Session access seats) sets the current 122. */ -const MAX_ENTRY_LINES = 120 +const MAX_ENTRY_LINES = 122 /** One register-call option as the catalog teaches it. */ interface OptionDoc { @@ -255,7 +257,8 @@ function standardKits(files: readonly ScannedFile[]): ReadonlyMap() for (const scope of SCOPES) { const extra = SCOPE_KIT[scope] - kits.set(scope, [...global, ...extra === undefined ? [] : standardKitMembers(files, extra)]) + // A scope-kit member replaces the global member of the same name, so each seat is listed once. + kits.set(scope, [...new Set([...global, ...extra === undefined ? [] : standardKitMembers(files, extra)])]) } return kits } diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 191478c77a..d734035f5a 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -137,6 +137,7 @@ export const SERVICE_PAGE: Record = { tools: 'tools.md', typert: 'typert.md', typertGateway: 'typert.md', + remoteAccess: 'typert.md', userQuestions: 'user-questions.md', web: 'web.md', workflowEngine: 'workflow.md', @@ -281,6 +282,10 @@ export const EVENT_WALK_EXEMPTIONS: Record = { * appear on more than one page. */ export const LINK_MAP: Readonly> = { + Principal: 'typert.md', + PrincipalMap: 'typert.md', + RemoteAccessResource: 'typert.md', + RemoteAccessResourceKind: 'typert.md', RemoteStream: 'typert.md', RemoteUplinkWindow: 'typert.md', EventLogOptions: 'otel.md', @@ -864,6 +869,11 @@ export const TYPE_LINK_EXEMPTIONS: Readonly> = { 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', PeerScope: 'Peer scope contract is owned by packages/typert/protocol/src/types.ts', + RemoteAccessPolicy: 'access policy contract is owned by packages/api/remote-access/README.md', + RemoteAccessRequest: 'Gateway decision requests are owned by packages/api/remote-access/README.md', + RemoteAccessDecision: 'Gateway decisions are owned by packages/api/remote-access/README.md', + RemoteAccessFrame: 'access snapshot frames are owned by packages/api/remote-access/README.md', + RemoteAccessBaseline: 'access snapshot frames are owned by packages/api/remote-access/README.md', 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/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 1ec6a7220f..2f1f1c99a0 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -370,6 +370,14 @@ const SERVICE_ROLES: ServiceRole[] = [ mode: 'core', note: 'Associates generated Remote descriptors with live Cordis services, resolves registered identities, and exposes unary calls through the shared Connection RPC carrier.', }, + { + key: 'remoteAccess', + pkg: 'api-remote-access', + title: 'Remote method access control', + mode: 'core', + consumers: ['api-gateway', 'api-session-controller', 'api-workspace-controller'], + note: 'Decides which Remote methods a non-owner Peer may call through business-registered role-based policies, filters list results, and streams each Peer access snapshot.', + }, { key: 'sessionPersistence', pkg: 'session-persistence', diff --git a/scripts/no-unknown-casts.baseline.json b/scripts/no-unknown-casts.baseline.json index 8bc98453b4..e56e922d85 100644 --- a/scripts/no-unknown-casts.baseline.json +++ b/scripts/no-unknown-casts.baseline.json @@ -176,7 +176,6 @@ }, "packages/api/gateway/tests/gateway.host.spec.ts": { "344e2cbbed2a8ef8d5356a24def92ac3453ffaa2efa58f5481d59206f5b79cbc": 1, - "36f7992641e8249fd0972dedd8b989d5fcbf97d60047120c5d4cf4182e76da0a": 1, "529f85662621d1be427dd178aee815ab0595dc05bc2324545d4914e17515dace": 1, "52f51bf0e468505650c8d072d3f1d77b1d2e16492a6c7b5082e7e6c15240d7be": 1, "7fedb21312a9c9efcaa4f269ad32fb590191f31e6ca49b000abcda55e9dac795": 1, diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 2eae1cf2fa..528686de8b 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -2086,6 +2086,16 @@ "symbol": "RemoteStreamHandle", "source": "packages/typert/protocol/src/types.ts" }, + { + "doc": "docs/subsystems/typert.md", + "symbol": "PrincipalMap", + "source": "packages/typert/protocol/src/types.ts" + }, + { + "doc": "docs/subsystems/typert.md", + "symbol": "Principal", + "source": "packages/typert/protocol/src/types.ts" + }, { "doc": "docs/subsystems/typert.md", "symbol": "PeerScope", @@ -2101,6 +2111,31 @@ "symbol": "RemoteInvocation", "source": "packages/typert/protocol/src/types.ts" }, + { + "doc": "docs/subsystems/typert.md", + "symbol": "TypertResourceMap", + "source": "packages/typert/protocol/src/types.ts" + }, + { + "doc": "docs/subsystems/typert.md", + "symbol": "RemoteAccessResourceKind", + "source": "packages/typert/protocol/src/remote-access.ts" + }, + { + "doc": "docs/subsystems/typert.md", + "symbol": "RemoteAccessResource", + "source": "packages/typert/protocol/src/remote-access.ts" + }, + { + "doc": "docs/subsystems/typert.md", + "symbol": "RemoteScopeOptions", + "source": "packages/typert/protocol/src/index.ts" + }, + { + "doc": "docs/subsystems/typert.md", + "symbol": "RemoteMethodOptions", + "source": "packages/typert/protocol/src/index.ts" + }, { "doc": "docs/subsystems/typert.md", "symbol": "TypertRegistryContract", From 0c4a7e3e8ce54db440f0dd8bdd68eb034c807f25 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 13 Oct 2026 23:01:11 +0800 Subject: [PATCH 22/35] fix(api-gateway): resolve access resources from typert-validated arguments --- ...peer-principal-and-remote-access.i18n.yaml | 12 +- ...-10-12-peer-principal-and-remote-access.md | 17 +-- ...-12-peer-principal-and-remote-access.zh.md | 17 +-- docs/subsystems/typert.md | 24 ++-- docs/subsystems/typert.zh.md | 24 ++-- packages/api/gateway/README.i18n.yaml | 4 +- packages/api/gateway/README.md | 4 +- packages/api/gateway/README.zh.md | 4 +- packages/api/gateway/src/access.ts | 16 ++- packages/api/gateway/src/index.ts | 38 ++++-- .../gateway/tests/gateway-access.host.spec.ts | 121 +++++++++++++++++- packages/api/remote-access/README.i18n.yaml | 4 +- packages/api/remote-access/README.md | 2 +- packages/api/remote-access/README.zh.md | 2 +- packages/api/remote-access/src/types.ts | 10 +- packages/typert/protocol/src/index.ts | 24 ++-- 16 files changed, 234 insertions(+), 89 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.i18n.yaml index 3bd834903b..9e7d540696 100644 --- a/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.i18n.yaml @@ -33,8 +33,8 @@ en: a78a2410eb52f777 zh: a868bf1b0e690c9f /agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/resources-and-resource-readers-deepseek-ai-dsh-typert-protocol-deepseek-ai-dsh-typert-registry: - en: 9680128eec80add7 - zh: 93cb356641781ad8 + en: 59a8b8c5b24d41a3 + zh: 66d0483811190402 /agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/endpoint-resources: en: 471ae9be4041846a zh: 84f2f6052ca8ec10 @@ -45,8 +45,8 @@ en: 3c911f57f0f6ca4b zh: 1509781eeda4c922 /agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/host-access-check-order: - en: d22555466f55b134 - zh: bc8b1f54789dc4b0 + en: 6c58dc8777e107ef + zh: bc14605c96e4d8f3 /agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/forwarded-events: en: 5fe34ee5ec31942a zh: ef39b020b0539b8f @@ -81,8 +81,8 @@ en: 89452c3c3f042e75 zh: 9bcad00a60c65303 /agent-note-peer-principals-and-method-level-remote-access-control/consequences/verification: - en: 859bdb170d0244b3 - zh: eb68208eaa4b310a + en: e5d289959607bf23 + zh: b746cb9387581d21 /agent-note-peer-principals-and-method-level-remote-access-control/consequences/costs-and-limits: en: 1e254cec680b1a37 zh: 06a31e057ba965aa diff --git a/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md b/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md index 8270786b81..97733a2a59 100644 --- a/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md +++ b/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md @@ -213,9 +213,10 @@ export function RemoteScope | Syntax | `@Remote({ name: 'page', resources: { session: (request) => request.address.kind === 'session' ? request.address.sessionId : request.address.childSessionId } })`; a Context method writes `@RemoteScope('agent')` and gets its Session resource from the Agent Context's `resource: 'session'`, and writes `@RemoteScope('agent', { resources: { workspace: (request) => request.workspaceId } })` when it needs another resource. Most methods write no `resources` and get their resources from automatic resolution | | Resource kinds | The lower-level packages that declare the identity types merge the resource kinds: `session: TypertResource` in `@deepseek-ai/dsh-session/types` and `workspace: TypertResource` in `@deepseek-ai/dsh-workspace/types`. session-controller references the commands package, so a merge in session-controller would make commands reference session-controller and form a project-reference cycle. remote-access builds in `global`, which has no id and stays out of `TypertResourceMap`; typert reserves the name, and `typert.resources.register('global', …)` throws | | Default fields and declarations | The domain that owns a kind registers its default wire field in apply: session-controller calls `typert.resources.register('session', { field: 'sessionId' })` and workspace-controller registers `workspace` → `workspaceId`; registering a kind twice throws. The Agent and Session lookups declare `resource: 'session'`, and the agent package's Agent Context declares `resource: 'session'` | -| Resolution precedence | Each resource kind takes, in order: ① that kind's reader in the method's `resources`; ② every lookup parameter that declares that kind as its `resource` contributes its wire identity, and an omitted optional lookup parameter contributes none; when the Context of a Context method declares that kind as its `resource`, the Context's wire identity contributes too; ③ that kind's default field: a top-level named wire argument of that name when one exists, otherwise that field of the first object argument. A default field names one identity, so an array there is an invalid value | +| Resolution precedence | Each resource kind takes, in order: ① that kind's reader in the method's `resources`; ② every lookup parameter that declares that kind as its `resource` contributes its wire identity, and an omitted optional lookup parameter contributes none; when the Context of a Context method declares that kind as its `resource`, the Context's wire identity contributes too; ③ that kind's default field: a top-level named argument of that name when one exists, otherwise that field of the first object argument. A default field names one identity, so an array there is an invalid value | +| Argument values | Resource resolution and the `args` of `decide()` read the named arguments validated against their Typert-compiled types: each JSON argument is decoded once by its codec, which drops object fields its type does not declare, and dispatch reuses that value, so a caller cannot change the resources with an undeclared field; lookup parameters and the Context identity keep their wire identities, and no lookup or Agent is resolved at that point | | Resolution result | The resources of every kind together are the resources the call touches; with none, the call touches `[global]`; when resources resolve, only those are used and `global` is not added | -| Readers | Called with the method's positional arguments: the named wire arguments mapped by the descriptor back to the method's parameter order (undecoded JSON, the wire identity in a lookup position, and the call's signal in the cancellation position); return one id, an id array (every one must be allowed), or `undefined` (no resource of that kind) | +| Readers | Called with the method's positional arguments: the named arguments mapped by the descriptor back to the method's parameter order (the validated value in a JSON position, the wire identity in a lookup position, and the call's signal in the cancellation position); return one id, an id array (every one must be allowed), or `undefined` (no resource of that kind) | | Invalid values | A reader that throws or returns an empty array, or a read value (including a Context's wire identity) that is not a non-empty string, makes the resources invalid: the Gateway refuses the call with `remote-access/denied` (reason `resource-invalid`) without asking `decide()` | | Completeness | `resources` and automatic resolution must cover every resource the method touches. A call that resolves no resource falls to `global`, which only a `global` role can allow, so it is refused by default; when only some resources resolve, the rest go unchecked and a principal granted on the resolved resources can reach them, which the method author must prevent | | Type inference | The parameter types of `@Remote` and `@RemoteScope` readers are inferred from the decorated method: the factory parameter uses `NoInfer`, so `Args` is inferred only from the returned decorator; lookup parameters map to wire types through `TypertLookupMap` (for a method whose first parameter is `agent: Agent`, the reader's first parameter is `SessionId`). A missing property, more parameters than the method has, and a `global` key fail compilation | @@ -291,7 +292,7 @@ export interface RemoteAccessRequest

{ readonly action: RemoteAccessAction /** Allow only when the action is allowed on every listed resource. */ readonly resources: readonly RemoteAccessResource[] - /** Named wire arguments of a concrete call, undecoded; absent for events, snapshots, and list filtering. */ + /** Named arguments of a concrete call, validated against their Typert-compiled types with lookup parameters as wire identities; absent for events, snapshots, and list filtering. */ readonly args?: Readonly> } @@ -386,12 +387,12 @@ Every call path (HTTP `/api`, WebSocket streams, in-process `fetch(request, peer | Step | Action | Failure | |---|---|---| | 1 | Admission yields the Peer: network paths from Connection `admit()` (`browserPeer`), in-process paths from the caller. The Gateway checks `peer.signal` before it reads the wire payload | Failed network admission answers HTTP 401 / 403 without entering the Gateway; a disposed Peer: `gateway/peer-disposed` | -| 2 | Resolve the endpoint and validate the argument field set (`resolveDescriptor`, `assertExactArguments`) | `gateway/invocation-unavailable`, `gateway/ambiguous-endpoint`, `gateway/definition-unavailable`; extra or missing fields: `gateway/arguments-invalid` | +| 2 | Resolve the endpoint and validate the argument field set (`resolveDescriptor`, `assertExactArguments`), then validate the JSON arguments against their Typert-compiled types, decoding each once | `gateway/invocation-unavailable`, `gateway/ambiguous-endpoint`, `gateway/definition-unavailable`; extra or missing fields: `gateway/arguments-invalid`; a JSON argument failing validation: `gateway/input-invalid` (for owners and non-owners alike, before the access check) | | 3 | Owner pass-through: for `principal.kind === 'owner'` go straight to step 5 | — | -| 4 | Non-owner: the Gateway resolves resources (`resources` readers, then lookup parameters and the `resource` a Context declares, then default fields; a Context identity is read directly from args without resolving the Context), then asks `remoteAccess.decide({ principal, action: endpoint, resources, args })`; invalid resources are refused with `resource-invalid` without asking `decide()`; a `deny` decision becomes the thrown error | `remote-access/denied` with details `{ action, reason }` | -| 5 | Dispatch: `resolveReceiverContext` (which can activate an Agent), argument decoding, and the method call | Existing codes unchanged, such as `gateway/lookup-not-found`, `gateway/context-not-found`, `gateway/input-invalid`, `gateway/service-unavailable`, `gateway/method-unavailable`, and business errors | +| 4 | Non-owner: the Gateway resolves resources from the arguments validated in step 2 (`resources` readers, then lookup parameters and the `resource` a Context declares, then default fields; lookup and Context identities are wire identities, and neither a lookup nor the Context is resolved), then asks `remoteAccess.decide({ principal, action: endpoint, resources, args })` with the validated arguments as `args`; invalid resources are refused with `resource-invalid` without asking `decide()`; a `deny` decision becomes the thrown error | `remote-access/denied` with details `{ action, reason }` | +| 5 | Dispatch: `resolveReceiverContext` (which can activate an Agent), lookup resolution (JSON arguments reuse the step 2 values), and the method call | Existing codes unchanged, such as `gateway/lookup-not-found`, `gateway/context-not-found`, `gateway/input-invalid`, `gateway/service-unavailable`, `gateway/method-unavailable`, and business errors | -The access check (steps 3–4) precedes lookups and argument decoding, so a refused call activates no Session and causes no other side effect. The only new Gateway error code is `gateway/peer-disposed`; remote-access declares the refusal code `remote-access/denied`. +The access check (steps 3–4) precedes lookup and Context resolution, so a refused call activates no Session and causes no other side effect. The only new Gateway error code is `gateway/peer-disposed`; remote-access declares the refusal code `remote-access/denied`. Gateway-owned endpoints: a non-owner may subscribe to `$events` but never receives waterfall frames it could answer; `$events/result` from a non-owner is refused with the existing `gateway/invocation-unavailable`; both run the step-1 check. @@ -630,7 +631,7 @@ The review rule's home is the "`@Remote` marks capability methods" section of [R | Check order | The owner calls any method normally without reading a policy; a method no role lists refuses non-owners with `remote-access/denied` (`no-allow`); the access check precedes lookups, so a refused call activates no Session | Gateway host spec | | Policy registration | One policy per non-owner principal kind, and a second registration throws; once the disposer withdraws it, that kind is refused with `no-allow`; it is withdrawn with its fiber | remote-access host spec | | Decisions | Every resource needs a matching binding (AND); a role without the action, an unequal resource, or a refusing condition does not allow; the refusal reason follows condition > `explain` > `no-allow`; `explain` never changes the decision; conditions receive args for calls but not for snapshots and list filtering; a throwing `bindings()`, condition, or `explain()` refuses with `policy-failed`, each policy is logged once between `invalidate()` calls, and on the event path the owner stream and the shared event source continue; `remoteAccess/follow` is allowed for every principal | remote-access host spec, Gateway host spec | -| Resource resolution | A default field (top-level or first object argument) yields its resource; a lookup parameter that declares that kind as its `resource` yields its wire identity; a Context method gets its Session resource from the Agent Context identity in args without activating the Agent, and may declare other resources through `resources`; a `resources` reader overrides its kind, an array yields several resources, and `undefined` none; with nothing the call is `[global]`; a throw, an empty array, or a value that is not a non-empty string (including a Context identity) is invalid, refused by the Gateway with `resource-invalid` without asking `decide()`; a Context that declares no `resource` resolves by the ordinary rules | Gateway host spec (gateway-access) | +| Resource resolution | A default field (top-level or first object argument) yields its resource; a lookup parameter that declares that kind as its `resource` yields its wire identity; a Context method gets its Session resource from the Agent Context identity in args without activating the Agent, and may declare other resources through `resources`; a `resources` reader overrides its kind, an array yields several resources, and `undefined` none; with nothing the call is `[global]`; a throw, an empty array, or a value that is not a non-empty string (including a Context identity) is invalid, refused by the Gateway with `resource-invalid` without asking `decide()`; a Context that declares no `resource` resolves by the ordinary rules; resources and `args` read the validated arguments, so an undeclared nested field changes no resource and no condition sees it, and a JSON argument failing validation returns `gateway/input-invalid` before the access check | Gateway host spec (gateway-access) | | Resource registry | `typert.resources` registers one default field per kind, rejects a second registration and `global`, and withdraws the registration with its fiber; a configured lookup resolver keeps the provider's `resource`; a Host Context adapter keeps its `resource` | typert-registry spec | | Forwarded events | A non-owner `$events` stream receives an event, whole, only when `event:` is granted on its resources; an event that declares no resources is decided on `global`; an event whose reader returns `undefined` does not reach non-owners, and `decide()` is not asked; the owner receives all | Gateway host spec (gateway-access) | | First snapshot | `snapshotFor()` returns the owner baseline or the principal's grants; remote-access injects the owner baseline into every rendered index; the Client starts from the injected baseline and rejects a malformed one | remote-access host and client specs | diff --git a/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md b/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md index 714348f3b2..1678e2cbb1 100644 --- a/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md +++ b/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md @@ -213,9 +213,10 @@ export function RemoteScope | 写法 | `@Remote({ name: 'page', resources: { session: (request) => request.address.kind === 'session' ? request.address.sessionId : request.address.childSessionId } })`;Context 方法写 `@RemoteScope('agent')`,由 Agent Context 的 `resource: 'session'` 得到会话资源,需要其他资源时写 `@RemoteScope('agent', { resources: { workspace: (request) => request.workspaceId } })`。多数方法不写 `resources`,由自动解析得到资源 | | 资源种类 | 资源种类由声明 id 类型的底层包合并:`session: TypertResource` 在 `@deepseek-ai/dsh-session/types`,`workspace: TypertResource` 在 `@deepseek-ai/dsh-workspace/types`。session-controller 引用 commands 包,若在 session-controller 合并,commands 要引用 session-controller,形成 project reference 环。`global` 由 remote-access 内置,没有 id,不进 `TypertResourceMap`;typert 保留该名,`typert.resources.register('global', …)` 抛错 | | 默认字段与声明 | 拥有该种类的领域在 apply 中登记默认 wire 字段:session-controller 调用 `typert.resources.register('session', { field: 'sessionId' })`,workspace-controller 登记 `workspace` → `workspaceId`;同一种类重复登记即抛错。Agent 与 Session lookup 声明 `resource: 'session'`,agent 包的 Agent Context 声明 `resource: 'session'` | -| 解析优先级 | 对每个资源种类依次取:① 该方法 `resources` 中该种类的读取函数;② 声明了该种类 `resource` 的每个 lookup 参数贡献其 wire 身份,省略的可选 lookup 参数不贡献;Context 方法的 Context 声明了该种类 `resource` 时,其 wire 身份同样贡献;③ 该种类的默认字段:命名 wire 参数中有同名顶层参数就读它,否则读第一个对象参数的同名字段。默认字段只表示一个身份,读到数组即为非法值 | +| 解析优先级 | 对每个资源种类依次取:① 该方法 `resources` 中该种类的读取函数;② 声明了该种类 `resource` 的每个 lookup 参数贡献其 wire 身份,省略的可选 lookup 参数不贡献;Context 方法的 Context 声明了该种类 `resource` 时,其 wire 身份同样贡献;③ 该种类的默认字段:命名参数中有同名顶层参数就读它,否则读第一个对象参数的同名字段。默认字段只表示一个身份,读到数组即为非法值 | +| 参数来源 | 资源解析与 `decide()` 的 `args` 读取经 Typert 编译类型校验后的命名参数:每个 JSON 参数由其 codec 解码一次,类型未声明的对象字段被移除,分派复用该值,因此调用方无法用未声明字段改变资源;lookup 参数与 Context 身份取 wire 身份,此时不解析 lookup、不激活 Agent | | 解析结果 | 各种类的资源合在一起即本次调用访问的资源;一个都没有即 `[global]`;解析出资源时只用这些资源,不附加 `global` | -| 读取函数 | 以方法的位置参数调用:按描述符把命名 wire 参数映射回方法参数顺序(未解码 JSON,lookup 位为 wire 身份,取消参数位为调用的 signal);返回一个 id、id 数组(每个都须被允许)或 `undefined`(该种类无资源) | +| 读取函数 | 以方法的位置参数调用:按描述符把命名参数映射回方法参数顺序(JSON 位为校验后的值,lookup 位为 wire 身份,取消参数位为调用的 signal);返回一个 id、id 数组(每个都须被允许)或 `undefined`(该种类无资源) | | 非法值 | 读取函数抛错或返回空数组,或读到的值(含 Context 的 wire 身份)不是非空字符串即资源非法:Gateway 不询问 `decide()`,直接以 `remote-access/denied`(reason `resource-invalid`)拒绝调用 | | 完整性 | `resources` 与自动解析必须覆盖方法访问的全部资源。一个资源都没解析出的调用落到 `global`,只有 `global` 级 role 能放行,因而默认被拒;只解析出部分资源时,其余资源不受检查,持有已解析资源授权的 principal 可越权访问它们,由方法作者负责避免 | | 类型推断 | `@Remote` 与 `@RemoteScope` 读取函数的参数类型从被装饰方法自动推断:工厂参数用 `NoInfer`,`Args` 只从返回的装饰器推断;lookup 参数按 `TypertLookupMap` 映射为 wire 类型(首个参数为 `agent: Agent` 的方法,其读取函数的第一个参数为 `SessionId`)。访问不存在的属性、参数多于方法参数与 `global` 键均为编译错误 | @@ -291,7 +292,7 @@ export interface RemoteAccessRequest

{ readonly action: RemoteAccessAction /** Allow only when the action is allowed on every listed resource. */ readonly resources: readonly RemoteAccessResource[] - /** Named wire arguments of a concrete call, undecoded; absent for events, snapshots, and list filtering. */ + /** Named arguments of a concrete call, validated against their Typert-compiled types with lookup parameters as wire identities; absent for events, snapshots, and list filtering. */ readonly args?: Readonly> } @@ -386,12 +387,12 @@ const childPrompt = child => ({ action, args }) => | 步 | 动作 | 失败结果 | |---|---|---| | 1 | 准入得到 Peer:网络路径由 Connection `admit()` 给出(`browserPeer`);进程内路径由调用方传入。Gateway 在读取 wire payload 之前检查 `peer.signal` | 网络准入失败返回 HTTP 401 / 403(不进入 Gateway);Peer 已 dispose:`gateway/peer-disposed` | -| 2 | 解析端点并做参数字段集校验(`resolveDescriptor`、`assertExactArguments`) | `gateway/invocation-unavailable`、`gateway/ambiguous-endpoint`、`gateway/definition-unavailable`;字段多余或缺失:`gateway/arguments-invalid` | +| 2 | 解析端点并做参数字段集校验(`resolveDescriptor`、`assertExactArguments`),再按 Typert 编译类型校验 JSON 参数,每个参数解码一次 | `gateway/invocation-unavailable`、`gateway/ambiguous-endpoint`、`gateway/definition-unavailable`;字段多余或缺失:`gateway/arguments-invalid`;JSON 参数校验失败:`gateway/input-invalid`(owner 与非 owner 相同,先于访问检查) | | 3 | owner 直通:`principal.kind === 'owner'` 时直接进入第 5 步 | — | -| 4 | 非 owner:Gateway 解析资源(`resources` 读取函数、其次 lookup 参数与 Context 声明的 `resource`、再次默认字段;Context 身份直接从 args 读取,不解析 Context),再询问 `remoteAccess.decide({ principal, action: endpoint, resources, args })`;资源非法时不询问 `decide()`,直接以 `resource-invalid` 拒绝;`deny` 结论变为抛出的错误 | `remote-access/denied`,details `{ action, reason }` | -| 5 | 分派:`resolveReceiverContext`(可能激活 Agent)、参数解码、调用方法 | 既有错误码不变,如 `gateway/lookup-not-found`、`gateway/context-not-found`、`gateway/input-invalid`、`gateway/service-unavailable`、`gateway/method-unavailable` 与业务错误 | +| 4 | 非 owner:Gateway 从第 2 步校验后的参数解析资源(`resources` 读取函数、其次 lookup 参数与 Context 声明的 `resource`、再次默认字段;lookup 与 Context 身份取 wire 身份,不解析 lookup 与 Context),再以校验后的参数为 `args` 询问 `remoteAccess.decide({ principal, action: endpoint, resources, args })`;资源非法时不询问 `decide()`,直接以 `resource-invalid` 拒绝;`deny` 结论变为抛出的错误 | `remote-access/denied`,details `{ action, reason }` | +| 5 | 分派:`resolveReceiverContext`(可能激活 Agent)、lookup 解析(JSON 参数复用第 2 步的值)、调用方法 | 既有错误码不变,如 `gateway/lookup-not-found`、`gateway/context-not-found`、`gateway/input-invalid`、`gateway/service-unavailable`、`gateway/method-unavailable` 与业务错误 | -鉴权(第 3–4 步)位于 lookup 与参数解码之前,未授权调用不会激活会话或产生其他副作用。Gateway 新增的错误码只有 `gateway/peer-disposed`;拒绝码 `remote-access/denied` 由 remote-access 声明。 +鉴权(第 3–4 步)位于 lookup 与 Context 解析之前,未授权调用不会激活会话或产生其他副作用。Gateway 新增的错误码只有 `gateway/peer-disposed`;拒绝码 `remote-access/denied` 由 remote-access 声明。 Gateway 自有端点:非 owner 可订阅 `$events`,但从不收到需要回答的 waterfall 帧;`$events/result` 对非 owner 以既有的 `gateway/invocation-unavailable` 拒绝;两者都做第 1 步的检查。 @@ -630,7 +631,7 @@ GlobalStandardProps.useWorkspaceAccess: (workspaceId: WorkspaceId, action: Remot | 鉴权顺序 | owner 调用任何方法照常分派且不读取 policy;没有 role 列出的方法对非 owner 以 `remote-access/denied`(`no-allow`)拒绝;访问检查早于 lookup,被拒调用不激活会话 | Gateway host spec | | policy 注册 | 每个非 owner principal kind 一个 policy,重复注册抛错;disposer 撤回后该 kind 以 `no-allow` 拒绝;随 fiber 撤回 | remote-access host spec | | 判定 | 每个资源都需要匹配的 binding(AND);role 不含该 action、资源不相等或 condition 拒绝都不放行;拒绝原因按 condition > `explain` > `no-allow` 取;`explain` 不改变结论;condition 在调用时收到 args,在快照与列表过滤时不收;`bindings()`、condition 或 `explain()` 抛错以 `policy-failed` 拒绝,每个 policy 在两次 `invalidate()` 之间只记录一次日志,事件路径上 owner 流与共享事件源照常;`remoteAccess/follow` 对任何 principal 放行 | remote-access host spec、Gateway host spec | -| 资源解析 | 默认字段(顶层或首个对象参数)得到对应资源;声明了该种类 `resource` 的 lookup 参数得到其 wire 身份;Context 方法从 args 中的 Agent Context 身份得到其 session 资源且不激活 Agent,另可经 `resources` 声明其他资源;`resources` 读取函数覆盖该种类,数组为多个资源,`undefined` 为无;都没有为 `[global]`;抛错、空数组或非非空字符串(含 Context 身份)为非法,Gateway 以 `resource-invalid` 拒绝且不询问 `decide()`;未声明 `resource` 的 Context 按普通规则解析 | Gateway host spec(gateway-access) | +| 资源解析 | 默认字段(顶层或首个对象参数)得到对应资源;声明了该种类 `resource` 的 lookup 参数得到其 wire 身份;Context 方法从 args 中的 Agent Context 身份得到其 session 资源且不激活 Agent,另可经 `resources` 声明其他资源;`resources` 读取函数覆盖该种类,数组为多个资源,`undefined` 为无;都没有为 `[global]`;抛错、空数组或非非空字符串(含 Context 身份)为非法,Gateway 以 `resource-invalid` 拒绝且不询问 `decide()`;未声明 `resource` 的 Context 按普通规则解析;资源与 `args` 读取校验后的参数,未声明的嵌套字段不改变资源、condition 也读不到它,JSON 参数校验失败在访问检查前返回 `gateway/input-invalid` | Gateway host spec(gateway-access) | | 资源注册表 | `typert.resources` 每个种类登记一个默认字段,拒绝重复登记与 `global`,并随 fiber 撤回;配置的 lookup resolver 保留 provider 的 `resource`;Host Context adapter 保留其 `resource` | typert-registry spec | | 转发事件 | 非 owner 的 `$events` 流只在 `event:` 于其资源上被授权时整条收到事件;未声明资源的事件按 `global` 判定;读取函数返回 `undefined` 的事件不送达非 owner,且不询问 `decide()`;owner 全部收到 | Gateway host spec(gateway-access) | | 首份快照 | `snapshotFor()` 返回 owner baseline 或该 principal 的 grant;remote-access 为渲染的每个 index 注入 owner baseline;Client 从注入的 baseline 起步并拒绝格式错误的快照 | remote-access host 与 client spec | diff --git a/docs/subsystems/typert.md b/docs/subsystems/typert.md index dc2beb2f9e..fb5bca48fd 100644 --- a/docs/subsystems/typert.md +++ b/docs/subsystems/typert.md @@ -325,18 +325,18 @@ interface RemoteScopeOptions { readonly name?: string /** * Per-kind readers of the resources one call touches. Each receives the - * method's arguments as its own parameters in undecoded wire JSON form, a - * lookup parameter as its wire identity and a cancellation parameter as the - * call's signal, so it performs plain reads only, and returns one identity, - * several, or `undefined` for none. A throw, an empty list, or an identity - * other than a non-empty string refuses the call. For a kind without a - * reader, the Gateway reads the identity of a scoped Context whose Host - * adapter declares that `resource` and of lookup parameters declaring it, - * else the field registered in `typert.resources`: a top-level wire - * argument or, failing that, the first object argument. A call that carries - * no identity touches `global`. The readers and these defaults together - * must name every resource the method accesses: access control never checks - * a resource they omit. + * method's arguments as its own parameters, JSON arguments as their Typert + * codecs validated them, a lookup parameter as its wire identity and a + * cancellation parameter as the call's signal, so it performs plain reads + * only, and returns one identity, several, or `undefined` for none. A + * throw, an empty list, or an identity other than a non-empty string + * refuses the call. For a kind without a reader, the Gateway reads the + * identity of a scoped Context whose Host adapter declares that `resource` + * and of lookup parameters declaring it, else the field registered in + * `typert.resources`: a top-level argument or, failing that, the first + * object argument. A call that carries no identity touches `global`. The + * readers and these defaults together must name every resource the method + * accesses: access control never checks a resource they omit. */ readonly resources?: TypertResourceReaders } diff --git a/docs/subsystems/typert.zh.md b/docs/subsystems/typert.zh.md index 22a6e6c9ee..11b3fd8dbb 100644 --- a/docs/subsystems/typert.zh.md +++ b/docs/subsystems/typert.zh.md @@ -325,18 +325,18 @@ interface RemoteScopeOptions { readonly name?: string /** * Per-kind readers of the resources one call touches. Each receives the - * method's arguments as its own parameters in undecoded wire JSON form, a - * lookup parameter as its wire identity and a cancellation parameter as the - * call's signal, so it performs plain reads only, and returns one identity, - * several, or `undefined` for none. A throw, an empty list, or an identity - * other than a non-empty string refuses the call. For a kind without a - * reader, the Gateway reads the identity of a scoped Context whose Host - * adapter declares that `resource` and of lookup parameters declaring it, - * else the field registered in `typert.resources`: a top-level wire - * argument or, failing that, the first object argument. A call that carries - * no identity touches `global`. The readers and these defaults together - * must name every resource the method accesses: access control never checks - * a resource they omit. + * method's arguments as its own parameters, JSON arguments as their Typert + * codecs validated them, a lookup parameter as its wire identity and a + * cancellation parameter as the call's signal, so it performs plain reads + * only, and returns one identity, several, or `undefined` for none. A + * throw, an empty list, or an identity other than a non-empty string + * refuses the call. For a kind without a reader, the Gateway reads the + * identity of a scoped Context whose Host adapter declares that `resource` + * and of lookup parameters declaring it, else the field registered in + * `typert.resources`: a top-level argument or, failing that, the first + * object argument. A call that carries no identity touches `global`. The + * readers and these defaults together must name every resource the method + * accesses: access control never checks a resource they omit. */ readonly resources?: TypertResourceReaders } diff --git a/packages/api/gateway/README.i18n.yaml b/packages/api/gateway/README.i18n.yaml index 96eee8a741..4b8cb2e1f4 100644 --- a/packages/api/gateway/README.i18n.yaml +++ b/packages/api/gateway/README.i18n.yaml @@ -15,8 +15,8 @@ en: d9859219cdb0f367 zh: 2a3cfa8cb786e327 /deepseek-ai-dsh-api-gateway/host-service-typertgatewayservice-ctx-key-typertgateway: - en: 5427b6ade7a2e324 - zh: 7ca42589a6572c5d + en: 6019f9b47d9ab474 + zh: b72d9c378bdafffb /deepseek-ai-dsh-api-gateway/client-service-clientremote-ctx-key-remote: en: c106f0ca3e547f84 zh: 8eadd3bffde9e49d diff --git a/packages/api/gateway/README.md b/packages/api/gateway/README.md index 97aeec7da3..e31fa33e80 100644 --- a/packages/api/gateway/README.md +++ b/packages/api/gateway/README.md @@ -30,9 +30,9 @@ Strict mode reads generated invocation descriptors from `ctx.typert.local`. Look 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. -Every call names the Peer it speaks for: `InvokeRemoteRequest.peer` and the `peer` argument of `wireStream.open()` are required, and the Gateway never supplies a default Peer. Dispatch from a disposed Peer fails with `gateway/peer-disposed`, for unary calls, streams, `$events`, and `$events/result` alike, and wire paths check it before they read the payload. The Host service injects `remoteAccess` and does not start without [`dsh-api-remote-access`](../remote-access/README.md). For a call whose Peer principal is not the owner, after the endpoint and argument-field checks and before lookup and Context resolution, the Gateway resolves the resources the call touches and asks `ctx.remoteAccess.decide({ principal, action, resources, args })` with the endpoint as the action, or refuses with reason `resource-invalid` without asking when the resources are invalid; a refused call throws `remote-access/denied` with details `{ action, reason }`, so a refused call fails before it activates any Session. An owner call is dispatched without resource resolution or a decision. +Every call names the Peer it speaks for: `InvokeRemoteRequest.peer` and the `peer` argument of `wireStream.open()` are required, and the Gateway never supplies a default Peer. Dispatch from a disposed Peer fails with `gateway/peer-disposed`, for unary calls, streams, `$events`, and `$events/result` alike, and wire paths check it before they read the payload. The Host service injects `remoteAccess` and does not start without [`dsh-api-remote-access`](../remote-access/README.md). After the endpoint and argument-field checks, every call validates its JSON arguments once against their Typert-compiled types, failing with `gateway/input-invalid`, and dispatch reuses the validated values. For a call whose Peer principal is not the owner, after that validation and before lookup and Context resolution, the Gateway resolves the resources the call touches and asks `ctx.remoteAccess.decide({ principal, action, resources, args })` with the endpoint as the action and the validated arguments as `args`, or refuses with reason `resource-invalid` without asking when the resources are invalid; a refused call throws `remote-access/denied` with details `{ action, reason }`, so a refused call fails before it activates any Session. An owner call is dispatched without resource resolution or a decision. -Resources are read from the undecoded wire arguments. For each resource kind, a reader in the method's `@Remote({ resources })` or `@RemoteScope(key, { resources })` options wins; it is called with the method's positional wire arguments, where lookup positions hold wire identities and the cancellation position holds the call's signal, and is read from the class metadata of the root Service instance. Otherwise every lookup parameter whose provider declares that kind through `resource` contributes its wire identity, an omitted optional one contributing none, and a Context method whose Host Context adapter declares that kind contributes its Context identity, read from the arguments before the Context is resolved. Otherwise the kind's default field registered in `typert.resources` is read from a top-level wire argument of that name or, failing that, from the first object argument. A reader that throws or returns an empty list, a default field holding an array, or an identity, including a Context identity, that is not a non-empty string makes the resolution `invalid`; a call that names no resource touches the `global` resource alone, and a call that names resources touches only those. A forwarded event carries every resource its entry declares, so there a reader returning `undefined` also makes the resolution `invalid`, while an entry without readers touches `global`. +Resources are read from the arguments validated against their Typert-compiled types, which drop object fields those types do not declare, while lookup and Context identities keep their wire values. For each resource kind, a reader in the method's `@Remote({ resources })` or `@RemoteScope(key, { resources })` options wins; it is called with the method's positional arguments, where lookup positions hold wire identities and the cancellation position holds the call's signal, and is read from the class metadata of the root Service instance. Otherwise every lookup parameter whose provider declares that kind through `resource` contributes its wire identity, an omitted optional one contributing none, and a Context method whose Host Context adapter declares that kind contributes its Context identity, read from the arguments before the Context is resolved. Otherwise the kind's default field registered in `typert.resources` is read from a top-level argument of that name or, failing that, from the first object argument. A reader that throws or returns an empty list, a default field holding an array, or an identity, including a Context identity, that is not a non-empty string makes the resolution `invalid`; a call that names no resource touches the `global` resource alone, and a call that names resources touches only those. A forwarded event carries every resource its entry declares, so there a reader returning `undefined` also makes the resolution `invalid`, while an entry without readers touches `global`. 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. diff --git a/packages/api/gateway/README.zh.md b/packages/api/gateway/README.zh.md index 68e516dba6..4160546db6 100644 --- a/packages/api/gateway/README.zh.md +++ b/packages/api/gateway/README.zh.md @@ -30,9 +30,9 @@ kind: "package-reference" 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`,它选定的码原样到达调用方。 -每次调用都指明它所代表的 Peer:`InvokeRemoteRequest.peer` 与 `wireStream.open()` 的 `peer` 参数都是必填项,Gateway 从不提供默认 Peer。来自已释放 Peer 的分发以 `gateway/peer-disposed` 失败,一元调用、流、`$events` 与 `$events/result` 一律如此,wire 路径在读取 payload 之前检查。Host 服务注入 `remoteAccess`,没有 [`dsh-api-remote-access`](../remote-access/README.zh.md) 就不启动。对 Peer 的 principal 不是 owner 的调用,Gateway 在 endpoint 与参数字段检查之后、lookup 与 Context 解析之前解析调用访问的资源,并以端点为 action 询问 `ctx.remoteAccess.decide({ principal, action, resources, args })`,资源非法时不询问而以原因 `resource-invalid` 拒绝;被拒的调用抛出 details 为 `{ action, reason }` 的 `remote-access/denied`,因此被拒绝的调用在激活任何 Session 之前就失败。owner 的调用不解析资源、不询问判定即分派。 +每次调用都指明它所代表的 Peer:`InvokeRemoteRequest.peer` 与 `wireStream.open()` 的 `peer` 参数都是必填项,Gateway 从不提供默认 Peer。来自已释放 Peer 的分发以 `gateway/peer-disposed` 失败,一元调用、流、`$events` 与 `$events/result` 一律如此,wire 路径在读取 payload 之前检查。Host 服务注入 `remoteAccess`,没有 [`dsh-api-remote-access`](../remote-access/README.zh.md) 就不启动。每次调用在 endpoint 与参数字段检查之后按 Typert 编译类型校验一次 JSON 参数,失败为 `gateway/input-invalid`,分派复用校验后的值。对 Peer 的 principal 不是 owner 的调用,Gateway 在该校验之后、lookup 与 Context 解析之前解析调用访问的资源,并以端点为 action、校验后的参数为 `args` 询问 `ctx.remoteAccess.decide({ principal, action, resources, args })`,资源非法时不询问而以原因 `resource-invalid` 拒绝;被拒的调用抛出 details 为 `{ action, reason }` 的 `remote-access/denied`,因此被拒绝的调用在激活任何 Session 之前就失败。owner 的调用不解析资源、不询问判定即分派。 -资源从未解码的 wire 参数中读取。对每个资源种类,方法 `@Remote({ resources })` 或 `@RemoteScope(key, { resources })` options 中的读取函数优先;它以方法的位置 wire 参数调用(lookup 位为 wire 身份,取消参数位为调用的 signal),从根 Service 实例的类元数据读取。否则 provider 经 `resource` 声明了该种类的每个 lookup 参数贡献其 wire 身份,省略的可选 lookup 参数不贡献;Host Context adapter 声明了该种类的 Context 方法贡献其 Context 身份,在解析 Context 之前直接从参数读取。再否则从同名顶层 wire 参数读取该种类在 `typert.resources` 中登记的默认字段,没有同名参数时读第一个对象参数的同名字段。读取函数抛错或返回空列表、默认字段读到数组、或某个身份(含 Context 身份)不是非空字符串时,解析结果为 `invalid`;不指明任何资源的调用只访问 `global` 资源,指明了资源的调用只访问这些资源。转发事件携带其条目声明的每个资源,因此在事件路径上读取函数返回 `undefined` 同样使解析结果为 `invalid`,而没有读取函数的条目访问 `global`。 +资源从经 Typert 编译类型校验后的参数中读取,校验会移除类型未声明的对象字段,lookup 与 Context 身份保持 wire 值。对每个资源种类,方法 `@Remote({ resources })` 或 `@RemoteScope(key, { resources })` options 中的读取函数优先;它以方法的位置参数调用(lookup 位为 wire 身份,取消参数位为调用的 signal),从根 Service 实例的类元数据读取。否则 provider 经 `resource` 声明了该种类的每个 lookup 参数贡献其 wire 身份,省略的可选 lookup 参数不贡献;Host Context adapter 声明了该种类的 Context 方法贡献其 Context 身份,在解析 Context 之前直接从参数读取。再否则从同名顶层参数读取该种类在 `typert.resources` 中登记的默认字段,没有同名参数时读第一个对象参数的同名字段。读取函数抛错或返回空列表、默认字段读到数组、或某个身份(含 Context 身份)不是非空字符串时,解析结果为 `invalid`;不指明任何资源的调用只访问 `global` 资源,指明了资源的调用只访问这些资源。转发事件携带其条目声明的每个资源,因此在事件路径上读取函数返回 `undefined` 同样使解析结果为 `invalid`,而没有读取函数的条目访问 `global`。 支持取消的 Remote 方法会把 `signal: AbortSignal` 声明为最后一个 Host 参数。signal 是 descriptor 元数据,而不是 wire 参数:Connection 将它提供给 Gateway,Gateway 则在已解码的业务参数之后注入它。SRC 识别这个保留的末位参数名,严格生成还要求它具有全局 `AbortSignal` 类型。 diff --git a/packages/api/gateway/src/access.ts b/packages/api/gateway/src/access.ts index 5c290e9f70..d85ebb3e65 100644 --- a/packages/api/gateway/src/access.ts +++ b/packages/api/gateway/src/access.ts @@ -1,7 +1,7 @@ /** * Resource resolution for non-owner access decisions: the resources one call - * or one forwarded event touches, read from undecoded wire values before any - * lookup or Context resolves them. + * or one forwarded event touches, read from Typert-validated arguments before + * any lookup or Context resolves them. * @module @deepseek-ai/dsh-api-gateway/src/access */ @@ -23,12 +23,14 @@ type ResourceIds = readonly unknown[] | 'invalid' * whose provider declares the kind contributes its wire resource id, and so * does the Context identity of a Context method whose Host adapter declares * the kind; otherwise the kind's default field registered in - * `typert.resources` is read from a top-level wire argument or, failing - * that, from the first object argument. + * `typert.resources` is read from a top-level argument or, failing that, + * from the first object argument. * @param typert - registry holding default fields, lookup providers, and Host Context adapters. * @param readers - the method's `resources` readers, or `undefined` when it declares none. * @param descriptor - receiver, parameter order, lookups, and cancellation slot of the method. - * @param args - named wire arguments, already matched to the descriptor's field set. + * @param args - named arguments matched to the descriptor's field set: JSON + * arguments as their Typert codecs validated them, lookup and Context + * identities as wire values. * @param signal - value placed in the cancellation slot of the positional arguments. * @returns the touched resources, the global resource alone when none; * `undefined` when the resources are invalid: a reader throws or returns @@ -133,7 +135,7 @@ function read(reader: TypertResourceReader, args: readonly unknown[], absent: Re try { value = reader(...args) } catch { - // A reader over unvalidated wire JSON fails on a malformed call; the call is refused. + // Any reader error means it cannot name its resources, so they are invalid. return 'invalid' } if (value === undefined) return absent @@ -152,7 +154,7 @@ function collect(resources: RemoteAccessResource[], kind: string, ids: ResourceI return true } -/** The default field: a top-level wire field, else that field of the first object argument. */ +/** The default field: a top-level argument, else that field of the first object argument. */ function defaultFieldId( descriptor: Pick, args: Readonly>, diff --git a/packages/api/gateway/src/index.ts b/packages/api/gateway/src/index.ts index 6259358b21..fdea92f9a9 100644 --- a/packages/api/gateway/src/index.ts +++ b/packages/api/gateway/src/index.ts @@ -742,6 +742,7 @@ export class TypertGatewayService extends Service implements TypertGateway { assertPeerLive(request.peer, endpoint) const descriptor = this.resolveDescriptor(request.namespace, request.method, endpoint) assertExactArguments(request.args, descriptor, endpoint) + const validated = validateArguments(descriptor, request.args, endpoint) // Access precedes lookup resolution, so a refused call activates nothing. // oxlint-disable-next-line typescript/no-unnecessary-condition -- other packages merge non-owner kinds into PrincipalMap. if (request.peer.principal.kind !== 'owner') { @@ -749,7 +750,7 @@ export class TypertGatewayService extends Service implements TypertGateway { this.ctx.typert, this.resourceReaders(descriptor), descriptor, - request.args, + validated, request.signal ?? NEVER_ABORTED_SIGNAL, ) const decision: RemoteAccessDecision = resources === undefined @@ -758,7 +759,7 @@ export class TypertGatewayService extends Service implements TypertGateway { principal: request.peer.principal, action: `${descriptor.namespace}/${descriptor.method}`, resources, - args: request.args, + args: validated, }) if (decision.kind === 'deny') { throw new RemoteError('remote-access/denied', `remote access: ${endpoint}: access denied (${decision.reason})`, { @@ -767,7 +768,7 @@ export class TypertGatewayService extends Service implements TypertGateway { }) } } - const receiverContext = await this.resolveReceiverContext(descriptor, request.args, endpoint) + const receiverContext = await this.resolveReceiverContext(descriptor, validated, endpoint) const receiver: unknown = receiverContext.get(descriptor.service) if (!isObject(receiver)) { throw new TypertGatewayError( @@ -778,7 +779,7 @@ 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))) + this.resolveParameter(parameter, validated, endpoint))) const signal = methodSignal(request, control, descriptor.mode === 'stream') const invocation = new GatewayInvocation( { namespace: request.namespace, method: request.method, args: request.args }, @@ -1007,13 +1008,10 @@ export class TypertGatewayService extends Service implements TypertGateway { args: Readonly>, endpoint: string, ): Promise { - // An absent field reached assertExactArguments' allowance, so this parameter - // takes undefined; a present-but-undefined field is not JSON-safe input and - // still fails decode. Lookup ids are never omissible, so absence here only - // ever belongs to a json parameter. + // Lookup ids are never omissible, so an absent field belongs to a json parameter that takes undefined. if (!Object.hasOwn(args, parameter.wire)) return undefined + if (parameter.source === 'json') return args[parameter.wire] const value = decode(parameter.codec, args[parameter.wire], endpoint, parameter.wire) - if (parameter.source === 'json') return value const key = parameter.lookup /* v8 ignore next -- registry validation rejects strict descriptors without a key, and SRC derivation always supplies one. */ if (key === undefined) { @@ -1586,6 +1584,28 @@ function assertExactArguments( throw new TypertGatewayError('gateway/arguments-invalid', endpoint, `args fields do not match the descriptor: ${clauses.join('; ')}`) } +/** + * Decode every present JSON argument once through its parameter codec; a + * Typert codec drops object fields its compiled type does not declare. Lookup + * and Context identities keep their wire values; dispatch decodes and resolves + * them only after the access check. An absent field stays absent; a present + * `undefined` is not JSON-safe and fails decoding. + */ +function validateArguments( + descriptor: InvocationDescriptor, + args: Readonly>, + endpoint: string, +): Readonly> { + const entries: [string, unknown][] = [] + if (descriptor.invocation.kind === 'context') entries.push([descriptor.invocation.wire, args[descriptor.invocation.wire]]) + for (const parameter of descriptor.parameters) { + if (!Object.hasOwn(args, parameter.wire)) continue + const value = args[parameter.wire] + entries.push([parameter.wire, parameter.source === 'json' ? decode(parameter.codec, value, endpoint, parameter.wire) : value]) + } + return Object.fromEntries(entries) +} + function decode( codec: TypertCodec, value: unknown, diff --git a/packages/api/gateway/tests/gateway-access.host.spec.ts b/packages/api/gateway/tests/gateway-access.host.spec.ts index fe7aeb4701..41d0f256d2 100644 --- a/packages/api/gateway/tests/gateway-access.host.spec.ts +++ b/packages/api/gateway/tests/gateway-access.host.spec.ts @@ -4,6 +4,7 @@ import { apply as applyConnection, inject as connectionInject } from '@deepseek- import RemoteAccessService, { type RemoteAccessAction, type RemoteAccessBinding, + type RemoteAccessCondition, type RemoteAccessResource, type RemoteAccessResourceKind, type RemoteAccessRole, @@ -61,7 +62,7 @@ const desk = (id: string) => ({ kind: 'test-desk', id }) as const const FIXTURE_ACTIONS: readonly RemoteAccessAction[] = [ 'accessFixture/open', 'accessFixture/read', 'accessFixture/byAgent', 'accessFixture/byLookup', 'accessFixture/byLookupAndOption', 'accessFixture/byList', 'accessFixture/scoped', 'accessFixture/scopedOnDesk', - 'accessFixture/scopedByReader', 'absentAccess/read', 'event:fixture/notice', 'event:fixture/shelf', + 'accessFixture/scopedByReader', 'accessFixture/annotate', 'absentAccess/read', 'event:fixture/notice', 'event:fixture/shelf', ] function role(resource: K): RemoteAccessRole { @@ -84,6 +85,7 @@ function on(resource: RemoteAccessResource): RemoteAccessBinding { class AccessFixture extends TypertRemoteService { readonly principals: Principal[] = [] + readonly requests: unknown[] = [] release: PromiseWithResolvers = Promise.withResolvers() constructor(ctx: Context) { @@ -124,6 +126,12 @@ class AccessFixture extends TypertRemoteService { return this.seen() } + @Remote + annotate(request: { readonly title: string }): string { + this.requests.push(request) + return this.seen() + } + @RemoteScope('gatewayAccessScope') scoped(): string { return this.seen() @@ -220,6 +228,29 @@ function registerScope(ctx: Context, resource: 'test-shelf' | undefined, resolve return resolve } +function strictCodec(name: string, create: () => z.ZodType): InvocationParameterDescriptor['codec'] { + return { mode: 'strict', typeSymbol: `@fixture/access#${name}`, create } +} + +/** Give each listed fixture method a strict descriptor with these parameters. */ +function registerStrict(ctx: Context, methods: Readonly>): void { + ctx.typert.register({ + package: '@fixture/gateway-access-strict', + face: 'host', + schemas: [], + model: { services: [], events: [], objects: [] }, + invocations: Object.entries(methods).map(([method, parameters]) => ({ + id: `@fixture/gateway-access-strict#accessFixture/${method}`, + service: 'accessFixture', + namespace: 'accessFixture', + method, + invocation: { kind: 'direct' }, + parameters, + result: { mode: 'strict', typeSymbol: '@fixture/access#Result', create: () => z.string() }, + })), + }) +} + describe('Gateway access checks', () => { it('dispatches the owner without resolving resources or reading the policy', async () => { const { ctx, bindings } = await setup() @@ -388,6 +419,94 @@ describe('Gateway access checks', () => { }) }) +describe('Gateway access checks on Typert-validated arguments', () => { + const titleRequest = (create: () => z.ZodType = () => z.object({ title: z.string() })): InvocationParameterDescriptor => + ({ name: 'request', wire: 'request', source: 'json', codec: strictCodec('TitleRequest', create) }) + + it('resolves resources from the decoded arguments, so an undeclared nested field names no resource', async () => { + const { ctx, fixture, hold } = await setup() + const create = vi.fn(() => z.object({ title: z.string() })) + registerStrict(ctx, { + annotate: [titleRequest(create)], + read: [{ name: 'request', wire: 'request', source: 'json', codec: strictCodec('ShelfRequest', () => z.object({ shelfId: z.string() })) }], + }) + const decide = vi.spyOn(RemoteAccessService.prototype, 'decide') + const guest = peerFor(ctx, GUEST) + hold(shelf('s1')) + await expect(call(ctx, guest, 'annotate', { request: { title: 't', shelfId: 's1' } })).rejects.toMatchObject({ + code: 'remote-access/denied', details: { action: 'accessFixture/annotate', reason: 'no-allow' }, + }) + await expect(call(ctx, guest, 'read', { request: { shelfId: 's1', principal: OWNER } })).resolves.toBe('test-guest') + hold(GLOBAL) + await expect(call(ctx, guest, 'annotate', { request: { title: 't', shelfId: 's1' } })).resolves.toBe('test-guest') + expect(decide.mock.calls.map(([request]) => request)).toEqual([ + { principal: GUEST, action: 'accessFixture/annotate', resources: [GLOBAL], args: { request: { title: 't' } } }, + { principal: GUEST, action: 'accessFixture/read', resources: [shelf('s1')], args: { request: { shelfId: 's1' } } }, + { principal: GUEST, action: 'accessFixture/annotate', resources: [GLOBAL], args: { request: { title: 't' } } }, + ]) + // Dispatch receives the value the access check read, decoded once per call. + expect(fixture.requests).toEqual([{ title: 't' }]) + expect(create).toHaveBeenCalledTimes(2) + }) + + it('hands a condition the decoded arguments, so it cannot read an undeclared field', async () => { + const { ctx, bindings } = await setup() + registerStrict(ctx, { annotate: [titleRequest()] }) + const seen: unknown[] = [] + const condition: RemoteAccessCondition<'global'> = ({ args }) => { + seen.push(args) + return { kind: 'allow' } + } + bindings.mockImplementation(() => [{ role: role('global'), resource: GLOBAL, condition }]) + await expect(call(ctx, peerFor(ctx, GUEST), 'annotate', { request: { title: 't', readOnly: false } })).resolves.toBe('test-guest') + expect(seen).toEqual([{ request: { title: 't' } }]) + }) + + it('passes readers the decoded arguments and lookup wire identities, resolving no lookup for a refused call', async () => { + const { ctx, hold } = await setup() + const resolve = vi.fn((id: string) => ({ id })) + ctx.typert.lookups.register('gatewayAccessAgent', { + parameter: 'agent', + wire: 'agentId', + resource: 'test-desk', + hostTypeSymbol: '@fixture/access#Agent', + wireTypeSymbol: '@fixture/access#AgentId', + resolve, + }) + registerStrict(ctx, { + byLookupAndOption: [ + { name: 'agent', wire: 'agentId', source: 'lookup', lookup: 'gatewayAccessAgent', codec: strictCodec('AgentId', () => z.string()) }, + { name: 'request', wire: 'request', source: 'json', codec: strictCodec('DeskRequest', () => z.object({ deskId: z.string() })) }, + ], + }) + const decisions = recordDecisions() + const guest = peerFor(ctx, GUEST) + const args = { agentId: 'a2', request: { deskId: 'd9', shelfId: 's9' } } + hold(shelf('s9'), desk('a2')) + await expect(call(ctx, guest, 'byLookupAndOption', args)).rejects.toMatchObject({ details: { reason: 'no-allow' } }) + expect(resolve).not.toHaveBeenCalled() + hold(desk('a2'), desk('d9')) + await expect(call(ctx, guest, 'byLookupAndOption', args)).resolves.toBe('a2:test-guest') + expect(resolve).toHaveBeenCalledExactlyOnceWith('a2') + // The undeclared `shelfId` names no `test-shelf` resource. + expect(decisions().map(({ resources }) => resources)).toEqual([[desk('a2'), desk('d9')], [desk('a2'), desk('d9')]]) + }) + + it('refuses arguments their codec rejects with gateway/input-invalid before the access check', async () => { + const { ctx, fixture, bindings } = await setup() + registerStrict(ctx, { annotate: [titleRequest()] }) + const decide = vi.spyOn(RemoteAccessService.prototype, 'decide') + for (const peer of [peerFor(ctx, GUEST), ownerPeer(ctx)]) { + await expect(call(ctx, peer, 'annotate', { request: { title: 7, shelfId: 's1' } })).rejects.toMatchObject({ + code: 'gateway/input-invalid', details: { endpoint: 'accessFixture/annotate', field: 'request' }, + }) + } + expect(decide).not.toHaveBeenCalled() + expect(bindings).not.toHaveBeenCalled() + expect(fixture.requests).toEqual([]) + }) +}) + describe('Gateway access checks for Context methods', () => { it('reads the Context identity as the resource its adapter declares, before resolving the Context', async () => { const { ctx, fixture, hold } = await setup() diff --git a/packages/api/remote-access/README.i18n.yaml b/packages/api/remote-access/README.i18n.yaml index 6a423304bb..0da490b8fa 100644 --- a/packages/api/remote-access/README.i18n.yaml +++ b/packages/api/remote-access/README.i18n.yaml @@ -15,8 +15,8 @@ en: a18ecc232e3c3933 zh: b8e43580403f6d77 /deepseek-ai-dsh-api-remote-access/host-service-remoteaccessservice-ctx-key-remoteaccess: - en: 42dd46f104e790f2 - zh: c15b37ecda2a1f0d + en: 410d404b82291cb1 + zh: 7fd6749ebdfdd888 /deepseek-ai-dsh-api-remote-access/client-face: en: 1db01235b6af84fa zh: c0ced511cc1841a0 diff --git a/packages/api/remote-access/README.md b/packages/api/remote-access/README.md index 5ccfb9e3f4..c835f5cdc8 100644 --- a/packages/api/remote-access/README.md +++ b/packages/api/remote-access/README.md @@ -30,7 +30,7 @@ Every composition that mounts [API Gateway](../gateway/README.md) mounts this se |---|---| | `registerPolicy(kind, policy)` | Registers the `RemoteAccessPolicy` of one non-owner principal kind; its `bindings()` and `explain()` receive principals typed by that kind. A second policy for the same kind throws. The registration is a Cordis effect of the caller's fiber; registration and removal recompute open snapshots. | | `invalidate(principal?)` | Reports that bindings or condition answers for one principal, or for every principal, may have changed. A business package calls it on every grant, revocation, mode change, and expiry. | -| `decide(request)` | Gateway-only. `request` is `{ principal, action, resources, args? }`: the caller or recipient, the endpoint `ns/method` or `event:`, the valid resources the Gateway resolved, and a call's wire arguments. Returns `{ kind: 'allow' }` or `{ kind: 'deny', reason }`; the Gateway throws `remote-access/denied` with details `{ action, reason }` for a denied call and drops a denied event without building an error. The owner and `remoteAccess/follow` are always allowed. | +| `decide(request)` | Gateway-only. `request` is `{ principal, action, resources, args? }`: the caller or recipient, the endpoint `ns/method` or `event:`, the valid resources the Gateway resolved, and a call's arguments validated against their Typert-compiled types, with lookup parameters as wire identities. Returns `{ kind: 'allow' }` or `{ kind: 'deny', reason }`; the Gateway throws `remote-access/denied` with details `{ action, reason }` for a denied call and drops a denied event without building an error. The owner and `remoteAccess/follow` are always allowed. | | `allows(resource)` | Reads the invocation of the Context that accessed the service, so a list method calls `this.ctx.remoteAccess.allows(resource)` for each item. Decides the call's endpoint action on that resource without arguments, reading the bindings again on every check. True for the owner and outside a Remote call. | | `snapshotFor(principal)` | The baseline a page opens with: `{ type: 'baseline', owner: true }` for the owner, otherwise the principal's current grants. | | `follow(signal)` | Remote stream `remoteAccess/follow`: one `baseline`, then `upsert` and `remove` frames. The owner receives only `{ type: 'baseline', owner: true }`. The stream ends when `signal` aborts or the service unloads, so a reloaded service starts every reader from a fresh baseline. | diff --git a/packages/api/remote-access/README.zh.md b/packages/api/remote-access/README.zh.md index c14033e993..14c836ed31 100644 --- a/packages/api/remote-access/README.zh.md +++ b/packages/api/remote-access/README.zh.md @@ -30,7 +30,7 @@ kind: "package-reference" |---|---| | `registerPolicy(kind, policy)` | 为一种非 owner principal kind 注册 `RemoteAccessPolicy`;其 `bindings()` 与 `explain()` 收到的 principal 按该 kind 定型。同一 kind 再注册一个 policy 即抛错。登记是调用方 fiber 的 Cordis effect;注册与撤回都会重新计算已打开的快照。 | | `invalidate(principal?)` | 报告某个 principal(省略时为所有 principal)的 binding 或 condition 结论可能已变化。业务包在每次授权、撤权、模式切换与过期时调用。 | -| `decide(request)` | 仅供 Gateway 使用。`request` 为 `{ principal, action, resources, args? }`:调用方或接收方、端点 `ns/method` 或 `event:`、Gateway 解析出的合法资源,以及调用的 wire 参数。返回 `{ kind: 'allow' }` 或 `{ kind: 'deny', reason }`;调用被拒时由 Gateway 抛出 details 为 `{ action, reason }` 的 `remote-access/denied`,事件被拒时由 Gateway 直接丢弃、不构造错误。owner 与 `remoteAccess/follow` 恒放行。 | +| `decide(request)` | 仅供 Gateway 使用。`request` 为 `{ principal, action, resources, args? }`:调用方或接收方、端点 `ns/method` 或 `event:`、Gateway 解析出的合法资源,以及调用中按 Typert 编译类型校验后的参数(lookup 参数为 wire 身份)。返回 `{ kind: 'allow' }` 或 `{ kind: 'deny', reason }`;调用被拒时由 Gateway 抛出 details 为 `{ action, reason }` 的 `remote-access/denied`,事件被拒时由 Gateway 直接丢弃、不构造错误。owner 与 `remoteAccess/follow` 恒放行。 | | `allows(resource)` | 读访问本服务的 Context 上的 invocation,因此列表方法对每一项调用 `this.ctx.remoteAccess.allows(resource)`。不带参数判定该调用的端点 action 在该资源上是否被允许,每次判定都重新读取 binding。owner 与 Remote 调用之外恒为 true。 | | `snapshotFor(principal)` | 页面打开时的 baseline:owner 为 `{ type: 'baseline', owner: true }`,否则为该 principal 当前的 grant。 | | `follow(signal)` | Remote stream `remoteAccess/follow`:一个 `baseline`,之后是 `upsert` 与 `remove` 帧。owner 只收到 `{ type: 'baseline', owner: true }`。`signal` abort 或服务卸载时流结束,因此重载后的服务从新的 baseline 回答每个读者。 | diff --git a/packages/api/remote-access/src/types.ts b/packages/api/remote-access/src/types.ts index fe38b77eef..131391c6b5 100644 --- a/packages/api/remote-access/src/types.ts +++ b/packages/api/remote-access/src/types.ts @@ -43,9 +43,11 @@ export type RemoteAccessDecision = /** * Refines one binding for one action on its resource. Called synchronously, * only for a binding whose resource matches and whose role lists the action. - * @param request - the action, the binding's resource, and the named wire - * arguments of a concrete call; `args` is absent for events, snapshots, and - * list filtering, where the condition answers for the action alone. + * @param request - the action, the binding's resource, and the named + * arguments of a concrete call, validated against their Typert-compiled + * types with lookup parameters as wire identities; `args` is absent for + * events, snapshots, and list filtering, where the condition answers for + * the action alone. * @returns allow, or deny with the reason a refused call reports. */ export type RemoteAccessCondition = (request: { @@ -73,7 +75,7 @@ export interface RemoteAccessRequest

{ * names none; allowed only when the action is allowed on every listed resource. */ readonly resources: readonly RemoteAccessResource[] - /** Named wire arguments of a concrete call, undecoded; absent for events, snapshots, and list filtering. */ + /** Named arguments of a concrete call, validated against their Typert-compiled types with lookup parameters as wire identities; absent for events, snapshots, and list filtering. */ readonly args?: Readonly> } diff --git a/packages/typert/protocol/src/index.ts b/packages/typert/protocol/src/index.ts index 0d5c99621d..42b6de0ecd 100644 --- a/packages/typert/protocol/src/index.ts +++ b/packages/typert/protocol/src/index.ts @@ -150,18 +150,18 @@ export interface RemoteScopeOptions readonly name?: string /** * Per-kind readers of the resources one call touches. Each receives the - * method's arguments as its own parameters in undecoded wire JSON form, a - * lookup parameter as its wire identity and a cancellation parameter as the - * call's signal, so it performs plain reads only, and returns one identity, - * several, or `undefined` for none. A throw, an empty list, or an identity - * other than a non-empty string refuses the call. For a kind without a - * reader, the Gateway reads the identity of a scoped Context whose Host - * adapter declares that `resource` and of lookup parameters declaring it, - * else the field registered in `typert.resources`: a top-level wire - * argument or, failing that, the first object argument. A call that carries - * no identity touches `global`. The readers and these defaults together - * must name every resource the method accesses: access control never checks - * a resource they omit. + * method's arguments as its own parameters, JSON arguments as their Typert + * codecs validated them, a lookup parameter as its wire identity and a + * cancellation parameter as the call's signal, so it performs plain reads + * only, and returns one identity, several, or `undefined` for none. A + * throw, an empty list, or an identity other than a non-empty string + * refuses the call. For a kind without a reader, the Gateway reads the + * identity of a scoped Context whose Host adapter declares that `resource` + * and of lookup parameters declaring it, else the field registered in + * `typert.resources`: a top-level argument or, failing that, the first + * object argument. A call that carries no identity touches `global`. The + * readers and these defaults together must name every resource the method + * accesses: access control never checks a resource they omit. */ readonly resources?: TypertResourceReaders } From 7e335ee6086b1bb89fcd8334dbed79d1fb01a345 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 13 Oct 2026 23:13:36 +0800 Subject: [PATCH 23/35] style(remote-access): wrap the request args JSDoc --- packages/api/remote-access/src/types.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/api/remote-access/src/types.ts b/packages/api/remote-access/src/types.ts index 131391c6b5..c598ee60a2 100644 --- a/packages/api/remote-access/src/types.ts +++ b/packages/api/remote-access/src/types.ts @@ -75,7 +75,10 @@ export interface RemoteAccessRequest

{ * names none; allowed only when the action is allowed on every listed resource. */ readonly resources: readonly RemoteAccessResource[] - /** Named arguments of a concrete call, validated against their Typert-compiled types with lookup parameters as wire identities; absent for events, snapshots, and list filtering. */ + /** + * Named arguments of a concrete call, validated against their Typert-compiled types + * with lookup parameters as wire identities; absent for events, snapshots, and list filtering. + */ readonly args?: Readonly> } From 529706d163e1f32646647ffea9a861a1b6661278 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 13 Oct 2026 23:55:05 +0800 Subject: [PATCH 24/35] fix(remote-access): drop buffered events on revoke, filter search, declare workspace file resources --- ...peer-principal-and-remote-access.i18n.yaml | 16 ++++----- ...-10-12-peer-principal-and-remote-access.md | 7 ++-- ...-12-peer-principal-and-remote-access.zh.md | 7 ++-- packages/api/gateway/src/index.ts | 8 ++++- .../gateway/tests/gateway-access.host.spec.ts | 29 ++++++++++++++++ packages/api/remote-access/README.i18n.yaml | 4 +-- packages/api/remote-access/README.md | 2 +- packages/api/remote-access/README.zh.md | 2 +- .../api/session-controller/README.i18n.yaml | 4 +-- packages/api/session-controller/README.md | 2 +- packages/api/session-controller/README.zh.md | 2 +- packages/api/session-controller/src/index.ts | 9 +++-- .../tests/remote-access.host.spec.ts | 33 ++++++++++++++++--- packages/api/workspace-files/src/index.ts | 1 + .../api/workspace-files/tests/scope.spec.ts | 2 ++ 15 files changed, 98 insertions(+), 30 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.i18n.yaml index 9e7d540696..363cc2fb3c 100644 --- a/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.i18n.yaml @@ -33,11 +33,11 @@ en: a78a2410eb52f777 zh: a868bf1b0e690c9f /agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/resources-and-resource-readers-deepseek-ai-dsh-typert-protocol-deepseek-ai-dsh-typert-registry: - en: 59a8b8c5b24d41a3 - zh: 66d0483811190402 + en: 625b20552962e268 + zh: 1136c0fd7cdc6901 /agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/endpoint-resources: - en: 471ae9be4041846a - zh: 84f2f6052ca8ec10 + en: ec1677d8e338e115 + zh: 6bedd163f208eaa9 /agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/roles-bindings-and-policies-deepseek-ai-dsh-api-remote-access: en: e57f396239ea9f56 zh: ddb26278b1521ceb @@ -51,8 +51,8 @@ en: 5fe34ee5ec31942a zh: ef39b020b0539b8f /agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/list-and-aggregate-methods: - en: 745028bb7b8d4765 - zh: f5e83362c5919360 + en: 7865e159528e2d30 + zh: 1399d54632f4d926 /agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/access-snapshot-stream: en: 41d7ac1faf2448d7 zh: 6d523895a9abae68 @@ -81,8 +81,8 @@ en: 89452c3c3f042e75 zh: 9bcad00a60c65303 /agent-note-peer-principals-and-method-level-remote-access-control/consequences/verification: - en: e5d289959607bf23 - zh: b746cb9387581d21 + en: 048067eb02e6bf8a + zh: 12508b59f6b206b8 /agent-note-peer-principals-and-method-level-remote-access-control/consequences/costs-and-limits: en: 1e254cec680b1a37 zh: 06a31e057ba965aa diff --git a/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md b/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md index 97733a2a59..a62f0a5e74 100644 --- a/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md +++ b/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md @@ -212,7 +212,7 @@ export function RemoteScope |---|---| | Syntax | `@Remote({ name: 'page', resources: { session: (request) => request.address.kind === 'session' ? request.address.sessionId : request.address.childSessionId } })`; a Context method writes `@RemoteScope('agent')` and gets its Session resource from the Agent Context's `resource: 'session'`, and writes `@RemoteScope('agent', { resources: { workspace: (request) => request.workspaceId } })` when it needs another resource. Most methods write no `resources` and get their resources from automatic resolution | | Resource kinds | The lower-level packages that declare the identity types merge the resource kinds: `session: TypertResource` in `@deepseek-ai/dsh-session/types` and `workspace: TypertResource` in `@deepseek-ai/dsh-workspace/types`. session-controller references the commands package, so a merge in session-controller would make commands reference session-controller and form a project-reference cycle. remote-access builds in `global`, which has no id and stays out of `TypertResourceMap`; typert reserves the name, and `typert.resources.register('global', …)` throws | -| Default fields and declarations | The domain that owns a kind registers its default wire field in apply: session-controller calls `typert.resources.register('session', { field: 'sessionId' })` and workspace-controller registers `workspace` → `workspaceId`; registering a kind twice throws. The Agent and Session lookups declare `resource: 'session'`, and the agent package's Agent Context declares `resource: 'session'` | +| Default fields and declarations | The domain that owns a kind registers its default wire field when its controller is constructed: the `SessionController` constructor calls `typert.resources.register('session', { field: 'sessionId' })` and the `WorkspaceController` constructor registers `workspace` → `workspaceId`; registering a kind twice throws. The Agent, Session, and `workspaceFileScope` lookups declare `resource: 'session'`, and the agent package's Agent Context declares `resource: 'session'` | | Resolution precedence | Each resource kind takes, in order: ① that kind's reader in the method's `resources`; ② every lookup parameter that declares that kind as its `resource` contributes its wire identity, and an omitted optional lookup parameter contributes none; when the Context of a Context method declares that kind as its `resource`, the Context's wire identity contributes too; ③ that kind's default field: a top-level named argument of that name when one exists, otherwise that field of the first object argument. A default field names one identity, so an array there is an invalid value | | Argument values | Resource resolution and the `args` of `decide()` read the named arguments validated against their Typert-compiled types: each JSON argument is decoded once by its codec, which drops object fields its type does not declare, and dispatch reuses that value, so a caller cannot change the resources with an undeclared field; lookup parameters and the Context identity keep their wire identities, and no lookup or Agent is resolved at that point | | Resolution result | The resources of every kind together are the resources the call touches; with none, the call touches `[global]`; when resources resolve, only those are used and `global` is not added | @@ -250,7 +250,7 @@ The table lists the endpoints #3854 viewers use and where their resources come f A subagent address reads the child transcript, so its resource is the child Session. A Client rereads the projections of known Sessions after a reconnect, including the parent of a subagent Session; a reader without a grant on that parent has its projection read refused. Whether a grant on a parent Session covers its subagent children is the policy's decision, that is, whether it gives a binding for the child Session; the generic layer derives no parent resource. -Methods that need no non-owner access declare nothing, and a method no role lists is refused. The request fields of subagent `prompt` are `parentSessionId` / `childSessionId`, so automatic resolution finds no Session and resolves it to `global`; it must therefore stay out of every `global` role. No role lists `session/search`, so it is unreachable for non-owners and gains no filtering. `subagents/list` is not a Remote endpoint (`SubagentRuntime.list()` carries no `@Remote`). +Methods that need no non-owner access declare nothing, and a method no role lists is refused. The request fields of subagent `prompt` are `parentSessionId` / `childSessionId`, so automatic resolution finds no Session and resolves it to `global`; it must therefore stay out of every `global` role. `session/search` filters its hits per Session inside the method. `subagents/list` is not a Remote endpoint (`SubagentRuntime.list()` carries no `@Remote`). #### Roles, bindings, and policies (`@deepseek-ai/dsh-api-remote-access`) @@ -418,6 +418,7 @@ Calls of list, paging, and aggregate methods resolve to `global` and are allowed | Method | Filtered items | |---|---| | `session/list` | Session rows (`session` resource) | +| `session/search` | search hits (`session` resource) | | `session/control` | baseline projections and projection increments (`session` resource) | | `workspace/follow` | Workspaces in the baseline, `upsert` frames, and `order` ids (`workspace` resource), the Sessions inside Workspaces, and the archived / pinned Session sets (`session` resource). The stream tracks the Workspaces it delivered; when one is no longer allowed at its next change or in a new order, it sends a `replace` increment with the complete filtered state instead of a `remove`, because the Client never resurrects a removed id, and the Workspace reappears through an ordinary `upsert` once access returns. A `remove` frame reports a deletion and reaches only a caller that received that Workspace | @@ -636,7 +637,7 @@ The review rule's home is the "`@Remote` marks capability methods" section of [R | Forwarded events | A non-owner `$events` stream receives an event, whole, only when `event:` is granted on its resources; an event that declares no resources is decided on `global`; an event whose reader returns `undefined` does not reach non-owners, and `decide()` is not asked; the owner receives all | Gateway host spec (gateway-access) | | First snapshot | `snapshotFor()` returns the owner baseline or the principal's grants; remote-access injects the owner baseline into every rendered index; the Client starts from the injected baseline and rejects a malformed one | remote-access host and client specs | | Type inference | The parameter types of `resources` readers are inferred from the method; lookup positions have wire types; a missing property, more parameters than the method has, and a `global` key fail compilation; a binding whose resource kind differs from its role's fails compilation | typert-protocol spec, remote-access spec (type tests) | -| List filtering | `session/list`, `session/control`, and `workspace/follow` return non-owners only items for which `allows()` is true; `workspace/follow` sends a `replace` increment when a delivered Workspace loses visibility, and the Workspace reappears once access returns; owner results are unchanged | session-controller / workspace-controller host spec | +| List filtering | `session/list`, `session/search`, `session/control`, and `workspace/follow` return non-owners only items for which `allows()` is true; `workspace/follow` sends a `replace` increment when a delivered Workspace loses visibility, and the Workspace reappears once access returns; owner results are unchanged | session-controller / workspace-controller host spec | | Access snapshots | The allowed set in a snapshot equals the Host decision for the same principal, action, and resource without arguments; it contains endpoint actions only; a resource with no allowed action gets no grant, and one whose allowed set becomes empty is removed; `invalidate()` and policy registration or removal push increments; a wait that a change ends leaves no abort listener behind; unloading the service ends every open snapshot stream; the owner receives only `{ owner: true }` | remote-access host spec | | Access hooks | `source()`, `useRemoteAccess`, `useAccess`, `useSessionAccess`, and `useWorkspaceAccess` follow the snapshot; `useAccess` on a Session resource reads that Session's answer; the owner answers true; a non-owner without a snapshot or after its snapshot stream fails answers false; hooks issue no requests | remote-access client spec, ui-session / ui-workspace client spec | | Two-argument keyed hooks | A string second argument reads the `(key, member)` source; existing call forms are unchanged | ui-renderer client spec | diff --git a/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md b/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md index 1678e2cbb1..30d47c9ccb 100644 --- a/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md +++ b/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md @@ -212,7 +212,7 @@ export function RemoteScope |---|---| | 写法 | `@Remote({ name: 'page', resources: { session: (request) => request.address.kind === 'session' ? request.address.sessionId : request.address.childSessionId } })`;Context 方法写 `@RemoteScope('agent')`,由 Agent Context 的 `resource: 'session'` 得到会话资源,需要其他资源时写 `@RemoteScope('agent', { resources: { workspace: (request) => request.workspaceId } })`。多数方法不写 `resources`,由自动解析得到资源 | | 资源种类 | 资源种类由声明 id 类型的底层包合并:`session: TypertResource` 在 `@deepseek-ai/dsh-session/types`,`workspace: TypertResource` 在 `@deepseek-ai/dsh-workspace/types`。session-controller 引用 commands 包,若在 session-controller 合并,commands 要引用 session-controller,形成 project reference 环。`global` 由 remote-access 内置,没有 id,不进 `TypertResourceMap`;typert 保留该名,`typert.resources.register('global', …)` 抛错 | -| 默认字段与声明 | 拥有该种类的领域在 apply 中登记默认 wire 字段:session-controller 调用 `typert.resources.register('session', { field: 'sessionId' })`,workspace-controller 登记 `workspace` → `workspaceId`;同一种类重复登记即抛错。Agent 与 Session lookup 声明 `resource: 'session'`,agent 包的 Agent Context 声明 `resource: 'session'` | +| 默认字段与声明 | 拥有该种类的领域在控制器构造时登记默认 wire 字段:`SessionController` 构造函数调用 `typert.resources.register('session', { field: 'sessionId' })`,`WorkspaceController` 构造函数登记 `workspace` → `workspaceId`;同一种类重复登记即抛错。Agent、Session 与 `workspaceFileScope` lookup 声明 `resource: 'session'`,agent 包的 Agent Context 声明 `resource: 'session'` | | 解析优先级 | 对每个资源种类依次取:① 该方法 `resources` 中该种类的读取函数;② 声明了该种类 `resource` 的每个 lookup 参数贡献其 wire 身份,省略的可选 lookup 参数不贡献;Context 方法的 Context 声明了该种类 `resource` 时,其 wire 身份同样贡献;③ 该种类的默认字段:命名参数中有同名顶层参数就读它,否则读第一个对象参数的同名字段。默认字段只表示一个身份,读到数组即为非法值 | | 参数来源 | 资源解析与 `decide()` 的 `args` 读取经 Typert 编译类型校验后的命名参数:每个 JSON 参数由其 codec 解码一次,类型未声明的对象字段被移除,分派复用该值,因此调用方无法用未声明字段改变资源;lookup 参数与 Context 身份取 wire 身份,此时不解析 lookup、不激活 Agent | | 解析结果 | 各种类的资源合在一起即本次调用访问的资源;一个都没有即 `[global]`;解析出资源时只用这些资源,不附加 `global` | @@ -250,7 +250,7 @@ export function RemoteScope subagent 地址读取的是子会话转录,因此其资源是子会话。Client 在重连后会重新读取已知会话的 projections,其中包括 subagent 会话的父会话;没有该父会话授权的读者,其 projection 读取被拒。父会话的授权是否覆盖其 subagent 子会话由 policy 决定,即是否为子会话给出 binding;通用层不推导父资源。 -不需要非 owner 访问的方法不做任何声明,没有 role 列出它即被拒。subagent `prompt` 的请求字段为 `parentSessionId` / `childSessionId`,自动解析不到会话,解析为 `global`,因此不得放进任何 `global` 级 role;`session/search` 没有 role 列出,非 owner 不可达,因此不加过滤。`subagents/list` 不是 Remote 端点(`SubagentRuntime.list()` 未标 `@Remote`)。 +不需要非 owner 访问的方法不做任何声明,没有 role 列出它即被拒。subagent `prompt` 的请求字段为 `parentSessionId` / `childSessionId`,自动解析不到会话,解析为 `global`,因此不得放进任何 `global` 级 role;`session/search` 在方法内按会话逐项过滤命中。`subagents/list` 不是 Remote 端点(`SubagentRuntime.list()` 未标 `@Remote`)。 #### role、binding 与 policy(`@deepseek-ai/dsh-api-remote-access`) @@ -418,6 +418,7 @@ Gateway 自有端点:非 owner 可订阅 `$events`,但从不收到需要回 | 方法 | 过滤对象 | |---|---| | `session/list` | 会话行(`session` 资源) | +| `session/search` | 搜索命中(`session` 资源) | | `session/control` | baseline 的 projections 与 projection 增量(`session` 资源) | | `workspace/follow` | baseline、`upsert` 帧与 `order` id 中的工作区(`workspace` 资源)、工作区内会话、archived / pinned 会话集合(`session` 资源)。流记录已下发的工作区;某个工作区在下一次变化或新顺序中已不被允许时,发送一个携带完整过滤后状态的 `replace` 增量帧而不是 `remove`,因为 Client 不会复活已移除的 id,访问恢复后该工作区经普通 `upsert` 重新出现。`remove` 帧表示删除,只发给收到过该工作区的调用方 | @@ -636,7 +637,7 @@ GlobalStandardProps.useWorkspaceAccess: (workspaceId: WorkspaceId, action: Remot | 转发事件 | 非 owner 的 `$events` 流只在 `event:` 于其资源上被授权时整条收到事件;未声明资源的事件按 `global` 判定;读取函数返回 `undefined` 的事件不送达非 owner,且不询问 `decide()`;owner 全部收到 | Gateway host spec(gateway-access) | | 首份快照 | `snapshotFor()` 返回 owner baseline 或该 principal 的 grant;remote-access 为渲染的每个 index 注入 owner baseline;Client 从注入的 baseline 起步并拒绝格式错误的快照 | remote-access host 与 client spec | | 类型推断 | `resources` 读取函数的参数类型从方法推断;lookup 位为 wire 类型;访问不存在的属性、参数多于方法参数与 `global` 键编译失败;binding 的资源种类与 role 不符编译失败 | typert-protocol spec、remote-access spec(类型测试) | -| 列表过滤 | `session/list`、`session/control`、`workspace/follow` 对非 owner 只返回 `allows()` 为 true 的项;已下发工作区失去可见性时 `workspace/follow` 发送 `replace` 增量帧,访问恢复后该工作区重新出现;owner 结果不变 | session-controller / workspace-controller host spec | +| 列表过滤 | `session/list`、`session/search`、`session/control`、`workspace/follow` 对非 owner 只返回 `allows()` 为 true 的项;已下发工作区失去可见性时 `workspace/follow` 发送 `replace` 增量帧,访问恢复后该工作区重新出现;owner 结果不变 | session-controller / workspace-controller host spec | | 访问快照 | 快照中的允许集合与 Host 对同一 principal、action、资源(不带参数)的判定一致;只含端点 action;没有放行 action 的资源不出 grant,放行集合变空时推送 `remove`;`invalidate()` 与 policy 注册、撤回后推送增量;被变化结束的等待不残留 abort 监听器;卸载服务即结束所有打开的快照流;owner 只收 `{ owner: true }` | remote-access host spec | | access hooks | `source()`、`useRemoteAccess`、`useAccess`、`useSessionAccess`、`useWorkspaceAccess` 与快照一致;会话资源上的 `useAccess` 读本会话答案;owner 为 true;没有快照或快照流失败的非 owner 为 false;hook 不发请求 | remote-access client spec、ui-session / ui-workspace client spec | | keyed hook 双参数 | 第二个参数为字符串时读取 `(key, member)` source;既有调用形式不变 | ui-renderer client spec | diff --git a/packages/api/gateway/src/index.ts b/packages/api/gateway/src/index.ts index fdea92f9a9..294fe78698 100644 --- a/packages/api/gateway/src/index.ts +++ b/packages/api/gateway/src/index.ts @@ -1148,12 +1148,18 @@ class RemoteEventQueue { this.waiter?.() } + /** + * Yield buffered and later frames until the queue ends or `signal` aborts. + * An abort discards the frames still buffered, so a revoked Peer receives none of them. + * @param signal - Client generation lifetime. + * @returns the queued wire frames in push order. + */ async *iterate(signal: AbortSignal): AsyncGenerator { const abort = (): void => { this.end() } signal.addEventListener('abort', abort, { once: true }) try { while (true) { - while (this.frames.size > 0) yield this.frames.popFront() as RemoteEventWireFrame + while (this.frames.size > 0 && !signal.aborted) yield this.frames.popFront() as RemoteEventWireFrame if (this.closed || signal.aborted) return await new Promise((resolve) => { this.waiter = resolve }) this.waiter = undefined diff --git a/packages/api/gateway/tests/gateway-access.host.spec.ts b/packages/api/gateway/tests/gateway-access.host.spec.ts index 41d0f256d2..42cbb1bbff 100644 --- a/packages/api/gateway/tests/gateway-access.host.spec.ts +++ b/packages/api/gateway/tests/gateway-access.host.spec.ts @@ -749,6 +749,35 @@ describe('Gateway forwarded events for non-owner Peers', () => { for (const events of streams) await events.return?.() }) + it('discards the events buffered for a revoked non-owner Peer and keeps other streams', async () => { + const { ctx, hold } = await setup({ connection: true }) + hold(shelf('s1')) + const push = eventSource(ctx) + const abort = new AbortController() + const open = async (peer: PeerScope): Promise> => + (await ctx.typertGateway.wireStream.open('$events', { args: {} }, (async function* () {})(), peer, abort.signal))[Symbol.asyncIterator]() + const revoked = ctx.connection.createPeer({ principal: GUEST }) + const lagging = await open(revoked) + const other = await open(ctx.connection.createPeer({ principal: GUEST })) + const owner = await open(ctx.connection.browserPeer) + for (const events of [lagging, other, owner]) await events.next() + const resources = { 'test-shelf': (shelfId: unknown) => shelfId } + push({ event: 'fixture/shelf', args: ['s1'], resources }) + push({ event: 'fixture/shelf', args: ['s1'], resources }) + const frame = { done: false, value: { type: 'emit', event: 'fixture/shelf', args: ['s1'] } } + for (const events of [other, owner]) { + await expect(events.next()).resolves.toMatchObject(frame) + await expect(events.next()).resolves.toMatchObject(frame) + } + // Both frames were queued for the lagging stream before its Peer was revoked. + await revoked.dispose() + await expect(lagging.next()).resolves.toEqual({ done: true, value: undefined }) + push({ event: 'fixture/shelf', args: ['s1'], resources }) + for (const events of [other, owner]) await expect(events.next()).resolves.toMatchObject(frame) + abort.abort() + for (const events of [other, owner]) await events.return?.() + }) + it('contains a throwing policy to the non-owner deliveries it decides', async () => { const ctx = new Context() roots.push(ctx) diff --git a/packages/api/remote-access/README.i18n.yaml b/packages/api/remote-access/README.i18n.yaml index 0da490b8fa..df8ad59235 100644 --- a/packages/api/remote-access/README.i18n.yaml +++ b/packages/api/remote-access/README.i18n.yaml @@ -15,8 +15,8 @@ en: a18ecc232e3c3933 zh: b8e43580403f6d77 /deepseek-ai-dsh-api-remote-access/host-service-remoteaccessservice-ctx-key-remoteaccess: - en: 410d404b82291cb1 - zh: 7fd6749ebdfdd888 + en: 9175835ea5c5bff1 + zh: 0e33b266fdac3d13 /deepseek-ai-dsh-api-remote-access/client-face: en: 1db01235b6af84fa zh: c0ced511cc1841a0 diff --git a/packages/api/remote-access/README.md b/packages/api/remote-access/README.md index c835f5cdc8..8d9df9e542 100644 --- a/packages/api/remote-access/README.md +++ b/packages/api/remote-access/README.md @@ -35,7 +35,7 @@ Every composition that mounts [API Gateway](../gateway/README.md) mounts this se | `snapshotFor(principal)` | The baseline a page opens with: `{ type: 'baseline', owner: true }` for the owner, otherwise the principal's current grants. | | `follow(signal)` | Remote stream `remoteAccess/follow`: one `baseline`, then `upsert` and `remove` frames. The owner receives only `{ type: 'baseline', owner: true }`. The stream ends when `signal` aborts or the service unloads, so a reloaded service starts every reader from a fresh baseline. | -A role `{ name, resource, actions }` is a plain value naming an action set over one resource kind, `global` or a kind declared in `TypertResourceMap`. A binding `{ role, resource, condition? }` grants the role on one resource of the role's kind. A policy answers `bindings(principal)` and an optional `explain(request)` synchronously from state it holds in memory. A request is allowed only when, on every resolved resource, some binding names that resource, its role lists the action, and it has no condition or its condition allows. Resources are independent: a grant on `global` implies nothing on any other resource, and no resource implies another. A condition receives `{ action, resource, args? }` and runs only for a binding whose resource and role match; `args` is absent for events, snapshots, and list filtering. `bindings()` runs once per `decide()`, once per snapshot computation, and once per Remote call that filters through `allows()` and again after each `invalidate()` of its principal or policy registration or removal; it is never cached across decisions, so time-dependent state applies to the next call. +A role `{ name, resource, actions }` is a plain value naming an action set over one resource kind, `global` or a kind declared in `TypertResourceMap`. A binding `{ role, resource, condition? }` grants the role on one resource of the role's kind. A policy answers `bindings(principal)` and an optional `explain(request)` synchronously from state it holds in memory. A request is allowed only when, on every resolved resource, some binding names that resource, its role lists the action, and it has no condition or its condition allows. Resources are independent: a grant on `global` implies nothing on any other resource, and no resource implies another. A condition receives `{ action, resource, args? }` and runs only for a binding whose resource and role match; `args` is absent for events, snapshots, and list filtering. `bindings()` runs once per `decide()`, once per `allows()` check, and once per snapshot computation; it is never cached across decisions, so time-dependent state applies to the next call. The Gateway resolves resources from `@Remote` and `@RemoteScope` `resources` readers, lookup and Host Context adapter `resource` declarations, and the default fields in `typert.resources`, and the [API Gateway](../gateway/README.md) owns that order; this package reads no argument field. A call that names no resource touches the `global` resource alone, so only a `global` role can allow it; putting an action in a `global` role lets the principal call it on any resource the call does not name. The Gateway refuses a call whose resources are invalid with `resource-invalid` without asking `decide()`. diff --git a/packages/api/remote-access/README.zh.md b/packages/api/remote-access/README.zh.md index 14c836ed31..8d593f7132 100644 --- a/packages/api/remote-access/README.zh.md +++ b/packages/api/remote-access/README.zh.md @@ -35,7 +35,7 @@ kind: "package-reference" | `snapshotFor(principal)` | 页面打开时的 baseline:owner 为 `{ type: 'baseline', owner: true }`,否则为该 principal 当前的 grant。 | | `follow(signal)` | Remote stream `remoteAccess/follow`:一个 `baseline`,之后是 `upsert` 与 `remove` 帧。owner 只收到 `{ type: 'baseline', owner: true }`。`signal` abort 或服务卸载时流结束,因此重载后的服务从新的 baseline 回答每个读者。 | -role `{ name, resource, actions }` 是普通值,表示限定一种资源种类(`global` 或在 `TypertResourceMap` 中声明的种类)的 action 集合。binding `{ role, resource, condition? }` 在该 role 种类的一个资源上授予该 role。policy 根据预先载入内存的状态同步回答 `bindings(principal)` 与可选的 `explain(request)`。只有在每个已解析资源上都有某个 binding 指向该资源、其 role 列出该 action、且没有 condition 或 condition 放行时,请求才被放行。资源之间相互独立:`global` 上的授权不蕴含任何其他资源上的授权,任何资源也不蕴含另一个资源。condition 收到 `{ action, resource, args? }`,只对资源与 role 都匹配的 binding 调用;事件、快照与列表过滤时没有 `args`。`bindings()` 在每次 `decide()`、每次快照计算、每次经 `allows()` 过滤的 Remote 调用中各调用一次,且在其 principal 每次被 `invalidate()` 或 policy 注册、撤回后再调用一次,不跨判定缓存,因此依赖时间的状态对下一次调用即时生效。 +role `{ name, resource, actions }` 是普通值,表示限定一种资源种类(`global` 或在 `TypertResourceMap` 中声明的种类)的 action 集合。binding `{ role, resource, condition? }` 在该 role 种类的一个资源上授予该 role。policy 根据预先载入内存的状态同步回答 `bindings(principal)` 与可选的 `explain(request)`。只有在每个已解析资源上都有某个 binding 指向该资源、其 role 列出该 action、且没有 condition 或 condition 放行时,请求才被放行。资源之间相互独立:`global` 上的授权不蕴含任何其他资源上的授权,任何资源也不蕴含另一个资源。condition 收到 `{ action, resource, args? }`,只对资源与 role 都匹配的 binding 调用;事件、快照与列表过滤时没有 `args`。`bindings()` 在每次 `decide()`、每次 `allows()` 判定与每次快照计算中各调用一次,不跨判定缓存,因此依赖时间的状态对下一次调用即时生效。 Gateway 依次从 `@Remote` 与 `@RemoteScope` 的 `resources` 读取函数、lookup 与 Host Context adapter 的 `resource` 声明、`typert.resources` 中的默认字段解析资源,解析顺序由 [API Gateway](../gateway/README.zh.md) 负责;本包不读取任何参数字段。不指明任何资源的调用只访问 `global` 资源,因此只有 `global` 级 role 能放行;把某个 action 放进 `global` 级 role,等于允许该 principal 在调用未指明的任何资源上调用它。资源非法的调用由 Gateway 以 `resource-invalid` 拒绝,不询问 `decide()`。 diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index 8ae33a613e..46f670802e 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/README.i18n.yaml @@ -15,8 +15,8 @@ en: 1fdf707322fb6996 zh: 0204f7e9d4aa3cce /session-controller/use-this-package: - en: 807a42e6f5e5fb43 - zh: 23cf50e120c86c26 + en: f9e4042fae88f801 + zh: f379f78966d57c2e /session-controller/client-references: en: 41bdda1ac32df558 zh: aa5d0e325924066b diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index 11f04d6d6e..02dff176ba 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -33,7 +33,7 @@ The Client journal validates current Session event envelopes before publishing f Each endpoint states its activation policy. List reads only stored headers and projection-cache rows: it never calls per-session stat or opens a cold Session body. A current-format cache identity may supply every list hint; a lifecycle-matching predecessor cache may supply only its version-compatible title as a stale display fact, never as an authoritative fold seed. Search, attachment, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Cancellation requires live state; queue mutation, model, rename, prompt, and file-reference operations may resolve or resume an ordinary Session. Prompt rejects content with neither non-whitespace text nor an attachment before resolving the Agent or appending Session events; queue edits accept only non-empty text content. Prompt admission consumes opaque receipts from the injected [`fileUploads`](../../client/file-upload/README.md) Host service and resolves every same-Agent receipt before sending the complete ordered content list through `ctx.attachments`. Prompt retries whose `requestId` is already queued or logged return the original acceptance without inserting another message. Create and fork are the only operations that create a new Agent directly. The service applies one preset-aware resume policy and subagent ownership fence to its own methods and to the Typert Agent and Session lookups used by other Remote namespaces. Queue mutation has one narrow exception: a live child whose current projected identity is continuable and comes from its own non-seed suffix accepts the ordinary Edit, Remove, and QueueDock Steer actions across both inbox destinations. One-shot, missing, unknown, corrupt, seed-only, or cold children remain rejected without resume. The skill catalog uses a live Agent's effective working directory and scoped registry when present. Cold listing uses the projected current directory, falling back to the recorded origin and then the configured directory default. It retains the preset standing scope without activating an Agent. The authenticated delivery routes use `workspaceDesktop()` for the serving Host name and file-manager behavior. `openWorkspacePath({ path, action: "reveal" })` delegates file-manager navigation to the native adapter; omitting `action` opens the file-type association, including HTML and SVG. Both operations require the composed filesystem to map the requested Host path to the same canonical process path; unmapped remote paths are refused before a native command runs. `session.projections` normally reads an exact baseline, including current-format cold logs, and returns `kind: sequenced`. Only a read requiring migration returns `kind: migration-required` hints without `asOfSeq`; missing keys remain absent. It returns null for a missing Session. Fork callers may set `allowMigration: false` to receive `session/migration-required` instead of starting source migration; omission retains automatic migration. The Client exposes shared values and explicit-read state through `projectionsBySession`; domains select their own keys. Session-list summaries carry `agentAvailable`, updated through existing summary and status events independently of durable projections. Sequenced reads and live projection frames use the same sequence ordering; cached reads fill hints without replacing sequenced values. -Every Remote endpoint is access-controlled: a Peer whose principal is not the owner reaches an endpoint only when, on every resource the call touches, a binding of that principal kind's policy in [`dsh-api-remote-access`](../remote-access/README.md) grants a role listing the action ([Peer principals Agent Note](../../../.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md)). The controller injects `remoteAccess` and registers the `session` resource kind with the default field `sessionId` through `typert.resources`. `session/page` and `session/follow` declare a `session` reader in `@Remote({ resources })` that reads `request.address.sessionId` for an ordinary Session address and `request.address.childSessionId` for a subagent address; whether access to the parent Session covers its children is the policy's rule. `session/projections` reads the Session resource from `request.sessionId`, so a reconnecting reader reloads only the projections of Sessions it may read. `session/list` and `session/control` read no resource, so their calls touch the `global` resource and need a `global` role; for a non-owner caller, `session/list` returns only the rows, and `session/control` only the baseline projections and projection frames, whose Session `ctx.remoteAccess.allows()` admits for the same action, so that action also belongs to a `session` role; owner results are unfiltered. `session/prompt` builds the durable `user-rpc` message source in its Remote method and records `principal` only for a non-owner caller, read through `principalOf(this.ctx.invocation)`; a source without `principal` belongs to the owner. +Every Remote endpoint is access-controlled: a Peer whose principal is not the owner reaches an endpoint only when, on every resource the call touches, a binding of that principal kind's policy in [`dsh-api-remote-access`](../remote-access/README.md) grants a role listing the action ([Peer principals Agent Note](../../../.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md)). The controller injects `remoteAccess` and registers the `session` resource kind with the default field `sessionId` through `typert.resources`. `session/page` and `session/follow` declare a `session` reader in `@Remote({ resources })` that reads `request.address.sessionId` for an ordinary Session address and `request.address.childSessionId` for a subagent address; whether access to the parent Session covers its children is the policy's rule. `session/projections` reads the Session resource from `request.sessionId`, so a reconnecting reader reloads only the projections of Sessions it may read. `session/list`, `session/search`, and `session/control` read no resource, so their calls touch the `global` resource and need a `global` role; for a non-owner caller, `session/list` returns only the rows, `session/search` only the hits, and `session/control` only the baseline projections and projection frames, whose Session `ctx.remoteAccess.allows()` admits for the same action, so that action also belongs to a `session` role; owner results are unfiltered. `session/prompt` builds the durable `user-rpc` message source in its Remote method and records `principal` only for a non-owner caller, read through `principalOf(this.ctx.invocation)`; a source without `principal` belongs to the owner. List rows carry `formatStatus` when available, so Clients can identify migration-required Sessions without requesting their projections. diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index b3cb5dccee..b01a99cc1f 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -33,7 +33,7 @@ Client journal 在发布 follow 快照、live entry 或历史页之前验证当 每个 endpoint 都声明自己的激活策略。列表只读取持久化 header 与 projection cache row,绝不调用逐 Session stat 或打开冷 Session body。当前格式 cache identity 可以提供全部列表 hint;生命周期匹配的 predecessor cache 只能提供版本兼容的 title,作为可能过时的展示事实,绝不能作为权威 fold seed。搜索、附件、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。取消要求 live 状态;queue 变更、模型、重命名、prompt 和文件引用操作可以解析或恢复普通 Session。提示词会在解析 Agent 或追加 Session 事件前,拒绝既没有非空白文本也没有附件的 content;queue edit 只接受非空文本 content。prompt 准入从注入的 [`fileUploads`](../../client/file-upload/README.zh.md) Host 服务取得不透明凭证,在把完整有序内容列表交给 `ctx.attachments` 前解析每个属于同一 Agent 的凭证。`requestId` 已进入 queue 或日志时,prompt 重试直接返回原来的接受结果,不会重复插入消息。只有 create 与 fork 会直接创建新 Agent。该服务把同一套感知 preset 的恢复策略和 subagent ownership fence 同时用于自身方法,以及其他 Remote namespace 使用的 Typert Agent 与 Session lookup。Queue 变更只有一个狭窄例外:当前 projection identity 为 continuable 且来自自身非 seed suffix 的在线 child,可以在两个 inbox 目标上使用普通 Edit、Remove 与 QueueDock Steer action。One-shot、缺失、未知、损坏、仅含 seed identity 或冷 child 继续被拒绝,且不会恢复。skill 目录优先使用已有 live Agent 的有效工作目录与限定范围内的注册表。冷态列表使用投影中的当前目录,依次回退到记录的起始目录与配置的默认目录。它保留 preset 常驻 scope,不会激活 Agent。经过鉴权的文件交付路由通过 `workspaceDesktop()` 获取提供服务的 Host 名称和文件管理器行为。`openWorkspacePath({ path, action: "reveal" })` 将文件管理器导航委托给原生适配器;省略 `action` 时按文件类型关联打开,包括 HTML 和 SVG。两种操作都要求当前文件系统将请求的 Host 路径映射到同一个规范进程路径;无法映射的远端路径会在执行原生命令之前被拒绝。 `session.projections` 正常读取准确基线,包括当前格式的冷日志,返回 `kind: sequenced`。只有需要迁移时才返回不带 `asOfSeq` 的 `kind: migration-required` 提示,缺少的键保持缺席。Session 不存在时返回 null。Fork 调用者可设置 `allowMigration: false`,在源历史需要迁移时收到 `session/migration-required`;省略则保持自动迁移。Client 通过 `projectionsBySession` 暴露共享值和显式读取状态,由领域选择自身的 key。Session 列表摘要携带 `agentAvailable`,通过已有摘要与状态事件更新,与持久化 projection 相互独立。有序读取与实时 projection 帧使用相同的序号排序规则;缓存读取只补充提示,不替换有序值。 -每个 Remote 端点都受访问控制:principal 不是 owner 的 Peer 只有在本次调用访问的每个资源上,该 principal kind 在 [`dsh-api-remote-access`](../remote-access/README.zh.md) 中的 policy 都有某个 binding 授予列出该 action 的 role 时,才能调用该端点([Peer 身份属性 Agent Note](../../../.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md))。控制器注入 `remoteAccess`,并经 `typert.resources` 以默认字段 `sessionId` 登记 `session` 资源种类。`session/page` 与 `session/follow` 在 `@Remote({ resources })` 中声明 `session` 读取函数:对普通 Session 地址读 `request.address.sessionId`,对 subagent 地址读 `request.address.childSessionId`;父会话的访问权是否覆盖其子会话由 policy 决定。`session/projections` 从 `request.sessionId` 读取会话资源,因此重连的读者只会重新读取它可读的会话的 projections。`session/list` 与 `session/control` 不读取资源,其调用访问 `global` 资源,需要 `global` role;对非 owner 调用方,`session/list` 只返回其 Session 被 `ctx.remoteAccess.allows()` 按同一 action 允许的行,`session/control` 也只返回这类 Session 的 baseline projection 与 projection 帧,因此该 action 也要列在 `session` role 中;owner 的结果不过滤。`session/prompt` 在其 Remote 方法内构造持久的 `user-rpc` 消息 source,且只为非 owner 调用方记录经 `principalOf(this.ctx.invocation)` 读取的 `principal`;不带 `principal` 的 source 属于 owner。 +每个 Remote 端点都受访问控制:principal 不是 owner 的 Peer 只有在本次调用访问的每个资源上,该 principal kind 在 [`dsh-api-remote-access`](../remote-access/README.zh.md) 中的 policy 都有某个 binding 授予列出该 action 的 role 时,才能调用该端点([Peer 身份属性 Agent Note](../../../.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md))。控制器注入 `remoteAccess`,并经 `typert.resources` 以默认字段 `sessionId` 登记 `session` 资源种类。`session/page` 与 `session/follow` 在 `@Remote({ resources })` 中声明 `session` 读取函数:对普通 Session 地址读 `request.address.sessionId`,对 subagent 地址读 `request.address.childSessionId`;父会话的访问权是否覆盖其子会话由 policy 决定。`session/projections` 从 `request.sessionId` 读取会话资源,因此重连的读者只会重新读取它可读的会话的 projections。`session/list`、`session/search` 与 `session/control` 不读取资源,其调用访问 `global` 资源,需要 `global` role;对非 owner 调用方,`session/list` 只返回其 Session 被 `ctx.remoteAccess.allows()` 按同一 action 允许的行,`session/search` 只返回这类 Session 的命中,`session/control` 也只返回这类 Session 的 baseline projection 与 projection 帧,因此该 action 也要列在 `session` role 中;owner 的结果不过滤。`session/prompt` 在其 Remote 方法内构造持久的 `user-rpc` 消息 source,且只为非 owner 调用方记录经 `principalOf(this.ctx.invocation)` 读取的 `principal`;不带 `principal` 的 source 属于 owner。 列表行在状态可用时携带 `formatStatus`,Client 无需请求其投影即可识别需要迁移的 Session。 diff --git a/packages/api/session-controller/src/index.ts b/packages/api/session-controller/src/index.ts index 81fca45b9b..2362bce21c 100644 --- a/packages/api/session-controller/src/index.ts +++ b/packages/api/session-controller/src/index.ts @@ -279,14 +279,17 @@ export class SessionController extends TypertRemoteService { } /** - * Search visible Session content without resuming an Agent. + * Search visible Session content without resuming an Agent. A non-owner + * receives only the hits whose Session `allows()` admits; `hasMore` + * describes the bounded result before that filtering. * @param request - literal message-content query. * @param signal - cancellation for list and search reads. * @returns authorized bounded Session search results. */ @Remote('search') - search(request: SessionSearchRequest, signal: AbortSignal): Promise { - return this.listState.search(request.query, signal) + async search(request: SessionSearchRequest, signal: AbortSignal): Promise { + const result = await this.listState.search(request.query, signal) + return { ...result, items: result.items.filter(item => this.ctx.remoteAccess.allows({ kind: 'session', id: item.sessionId })) } } /** diff --git a/packages/api/session-controller/tests/remote-access.host.spec.ts b/packages/api/session-controller/tests/remote-access.host.spec.ts index 04fa3b860f..bd39bc13f1 100644 --- a/packages/api/session-controller/tests/remote-access.host.spec.ts +++ b/packages/api/session-controller/tests/remote-access.host.spec.ts @@ -1,8 +1,8 @@ /** * Session Controller under non-owner callers: the Session resource kind it * registers, the Session readers of `session/page` and `session/follow`, - * `session/list` and `session/control` called through a `global` role and - * filtered per Session through `remoteAccess.allows()`, the caller recorded + * `session/list`, `session/search`, and `session/control` called through a + * `global` role and filtered per Session through `remoteAccess.allows()`, the caller recorded * on prompt sources, and `session/projections` admitted per Session through * the Gateway. */ @@ -16,9 +16,10 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' import { remoteMethods, type PeerId, type PeerScope, type Principal, type RemoteInvocation } from '@deepseek-ai/dsh-typert-protocol' import TypertRegistry from '@deepseek-ai/dsh-typert-registry' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import type SessionController from '../src/index.ts' -import type { SessionControlFrame, SessionRequestId } from '../src/types.ts' +import { ApiSessionList } from '../src/list.ts' +import type { SessionControlFrame, SessionRequestId, SessionSearchValue } from '../src/types.ts' import { createSessionTestController } from './test-remote.ts' declare module '@deepseek-ai/dsh-typert-protocol' { @@ -41,6 +42,14 @@ const READER: RemoteAccessRole<'session'> = { } const LIST_CALLS: RemoteAccessBinding = { role: LISTER, resource: { kind: 'global' } } const READS_SHARED: RemoteAccessBinding = { role: READER, resource: { kind: 'session', id: SHARED } } +const SEARCH_CALLS: RemoteAccessBinding = { + role: { name: 'test:session-searcher', resource: 'global', actions: ['session/search'] }, + resource: { kind: 'global' }, +} +const SEARCHES_SHARED: RemoteAccessBinding = { + role: { name: 'test:session-search-reader', resource: 'session', actions: ['session/search'] }, + resource: { kind: 'session', id: SHARED }, +} const owned = new Set() afterEach(async () => { @@ -134,6 +143,22 @@ describe('Session Controller access', () => { expect(owner.items.map(item => item.sessionId).sort()).toEqual([PRIVATE, SHARED].sort()) }) + it('returns a non-owner only the session/search hits whose Session a Session role admits', async () => { + const { ctx, as } = await harness() + await ctx.plugin(TypertGatewayService) + guestPolicy(ctx, SEARCH_CALLS, SEARCHES_SHARED) + const hits: SessionSearchValue = { + items: [{ sessionId: PRIVATE, snippet: 'private hit' }, { sessionId: SHARED, snippet: 'shared hit' }], + hasMore: true, + } + vi.spyOn(ApiSessionList.prototype, 'search').mockResolvedValue(hits) + await expect(guestCall(ctx, 'search', { request: { query: 'hit' } })).resolves.toEqual({ + items: [{ sessionId: SHARED, snippet: 'shared hit' }], + hasMore: true, + }) + await expect(as(OWNER, 'search').sessionController.search({ query: 'hit' }, new AbortController().signal)).resolves.toEqual(hits) + }) + it('filters session/control baselines and projection frames for a non-owner', async () => { const { ctx, shared, hidden, as } = await harness() guestPolicy(ctx, LIST_CALLS, READS_SHARED) diff --git a/packages/api/workspace-files/src/index.ts b/packages/api/workspace-files/src/index.ts index 1c1c652f1e..d6ecbc68c1 100644 --- a/packages/api/workspace-files/src/index.ts +++ b/packages/api/workspace-files/src/index.ts @@ -203,6 +203,7 @@ export class WorkspaceFiles extends TypertRemoteService { scope.typert.lookups.register('workspaceFileScope', { parameter: 'workspaceFileScope', wire: 'workspaceFileScopeId', + resource: 'session', hostTypeSymbol: '@deepseek-ai/dsh-api-workspace-files#WorkspaceFileScope', wireTypeSymbol: '@deepseek-ai/dsh-session/types#SessionId', resolve: async (sessionId) => { diff --git a/packages/api/workspace-files/tests/scope.spec.ts b/packages/api/workspace-files/tests/scope.spec.ts index 2d9cd097b2..d954063bfd 100644 --- a/packages/api/workspace-files/tests/scope.spec.ts +++ b/packages/api/workspace-files/tests/scope.spec.ts @@ -52,6 +52,8 @@ describe('Workspace Files Session scope lookup', () => { expect(lookup).toMatchObject({ parameter: 'workspaceFileScope', wire: 'workspaceFileScopeId', + // A method taking a scope reads the scope's Session as its access-control resource. + resource: 'session', hostTypeSymbol: '@deepseek-ai/dsh-api-workspace-files#WorkspaceFileScope', wireTypeSymbol: '@deepseek-ai/dsh-session/types#SessionId', }) From b632f5033060920f1c586ac7bd145a25c9cf4eac Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 14 Oct 2026 00:02:56 +0800 Subject: [PATCH 25/35] docs: regenerate cordis catalogs --- docs/subsystems/session.md | 6 ++++-- docs/subsystems/session.zh.md | 6 ++++-- packages/extensions/tool-cordis/src/api-catalog.ts | 4 ++-- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md index fc2db4f0de..4933734d57 100644 --- a/docs/subsystems/session.md +++ b/docs/subsystems/session.md @@ -848,12 +848,14 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise /** - * Search visible Session content without resuming an Agent. + * Search visible Session content without resuming an Agent. A non-owner + * receives only the hits whose Session `allows()` admits; `hasMore` + * describes the bounded result before that filtering. * @param request - literal message-content query. * @param signal - cancellation for list and search reads. * @returns authorized bounded Session search results. */ -@Remote('search') search(request: SessionSearchRequest, signal: AbortSignal): Promise +@Remote('search') async search(request: SessionSearchRequest, signal: AbortSignal): Promise /** * Create one ordinary Session for a new conversation, reusing the named blank Session while it is reusable. diff --git a/docs/subsystems/session.zh.md b/docs/subsystems/session.zh.md index bae730ef7d..a3e5446b61 100644 --- a/docs/subsystems/session.zh.md +++ b/docs/subsystems/session.zh.md @@ -852,12 +852,14 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise /** - * Search visible Session content without resuming an Agent. + * Search visible Session content without resuming an Agent. A non-owner + * receives only the hits whose Session `allows()` admits; `hasMore` + * describes the bounded result before that filtering. * @param request - literal message-content query. * @param signal - cancellation for list and search reads. * @returns authorized bounded Session search results. */ -@Remote('search') search(request: SessionSearchRequest, signal: AbortSignal): Promise +@Remote('search') async search(request: SessionSearchRequest, signal: AbortSignal): Promise /** * Create one ordinary Session for a new conversation, reusing the named blank Session while it is reusable. diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 6d643e0099..62787c4b5d 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -2032,8 +2032,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ returns: 'visible Session summaries ordered by activity.', }, { - signature: '@Remote(\'search\') search(request: SessionSearchRequest, signal: AbortSignal): Promise', - description: 'Search visible Session content without resuming an Agent.', + signature: '@Remote(\'search\') async search(request: SessionSearchRequest, signal: AbortSignal): Promise', + description: 'Search visible Session content without resuming an Agent. A non-owner receives only the hits whose Session `allows()` admits; `hasMore` describes the bounded result before that filtering.', parameters: [{ name: 'request', description: 'literal message-content query.' }, { name: 'signal', description: 'cancellation for list and search reads.' }], returns: 'authorized bounded Session search results.', }, From a4426261ab1eac91d924b484fdaa7eeff13beeaf Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 13 Oct 2026 23:34:58 +0800 Subject: [PATCH 26/35] fix(ui-chat): fold next-message work details on one frame clock Folding on the next message reserved the whole closing height as bottom room and kept scrollTop whenever the input had no transcript echo on this page: a message from another tab, a queued message delivered after the previous Turn, or a send from a subagent child in the right Sidebar. A reader below the fold was left on an empty viewport. Every batch of closing rows now runs on the viewport's frame-clock driver. It lands at the tail for this client's own input or a following reader and otherwise keeps the reader's content still, installing only the room the landing lacks. Subagent child sends register a local echo and pass its request id to subagents.prompt, so the child's recorded message retires it. --- ...26-10-13-chat-single-fold-motion.i18n.yaml | 31 + .../2026-10-13-chat-single-fold-motion.md | 91 ++ .../2026-10-13-chat-single-fold-motion.zh.md | 91 ++ apps/web/tests/fold-blank-matrix.e2e.ts | 776 ++++++++++++++++++ apps/web/tsconfig.json | 1 + .../src/client/sessions/session.ts | 2 +- .../tests/session.client.spec.ts | 13 + packages/client/ui-chat/README.i18n.yaml | 4 +- packages/client/ui-chat/README.md | 4 +- packages/client/ui-chat/README.zh.md | 4 +- .../ui-chat/src/client/chat/flow-motion.ts | 234 +++--- .../src/client/chat/use-chat-reading.ts | 21 +- .../src/client/chat/use-chat-scroll.ts | 41 +- .../src/client/chat/use-chat-viewport.ts | 217 +++-- .../conversation-nodes/README.i18n.yaml | 4 +- .../src/client/conversation-nodes/README.md | 2 +- .../client/conversation-nodes/README.zh.md | 2 +- .../ui-chat/tests/chat-apply.client.spec.tsx | 19 +- .../ui-chat/tests/chat-scroll.client.spec.tsx | 6 +- .../tests/flow-motion-scope.client.spec.tsx | 6 +- .../ui-chat/tests/flow-motion.client.spec.tsx | 138 +++- .../ui-chat/tests/fold-follow.client.spec.ts | 125 ++- .../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/service.ts | 7 - .../service-orchestration.client.spec.ts | 9 +- tsconfig.host.json | 1 + 28 files changed, 1552 insertions(+), 305 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.md create mode 100644 .agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.zh.md create mode 100644 apps/web/tests/fold-blank-matrix.e2e.ts diff --git a/.agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.i18n.yaml new file mode 100644 index 0000000000..0f2e789bf6 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.i18n.yaml @@ -0,0 +1,31 @@ +# Bilingual-pair consistency record for 2026-10-13-chat-single-fold-motion.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.md +/agent-note-one-fold-motion-for-collapsing-on-the-next-message: + en: 45594545dabfaa78 + zh: a1eac669647b2bac +/agent-note-one-fold-motion-for-collapsing-on-the-next-message/problem: + en: 84c909627857b56d + zh: 543d20649a519abb +/agent-note-one-fold-motion-for-collapsing-on-the-next-message/decision: + en: 972bbe45fe8ac28e + zh: 593b851c142894e1 +/agent-note-one-fold-motion-for-collapsing-on-the-next-message/decision/batches: + en: 45cb2d17e4d15b09 + zh: e11e75c7bcc6bd8e +/agent-note-one-fold-motion-for-collapsing-on-the-next-message/decision/landings: + en: a052e1eb266c2798 + zh: 1ded76ca8b6285e9 +/agent-note-one-fold-motion-for-collapsing-on-the-next-message/decision/own-input: + en: ca6a5c372bb3cad1 + zh: 1cd5028269b58c65 +/agent-note-one-fold-motion-for-collapsing-on-the-next-message/testing: + en: a304b52774b7676d + zh: e99683e30f0c1173 +/agent-note-one-fold-motion-for-collapsing-on-the-next-message/alternatives-considered: + en: d8e1ea66e293cd93 + zh: 0aaa9e693f441673 +/agent-note-one-fold-motion-for-collapsing-on-the-next-message/consequences: + en: 89148df30a9a1772 + zh: d484cc7c9e0cc3f1 diff --git a/.agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.md b/.agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.md new file mode 100644 index 0000000000..d0b7eeb1cd --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.md @@ -0,0 +1,91 @@ +# Agent Note: One fold motion for collapsing on the next message + +Status: implemented + +English | [中文](2026-10-13-chat-single-fold-motion.zh.md) + +## Problem + +When "When to Collapse Work Details" is "On next message", the previous exchange's process rows close when the next human input arrives. The scroll layer had two fold paths and chose one by `ownInput` ([use-chat-scroll.ts](../../../../packages/client/ui-chat/src/client/chat/use-chat-scroll.ts)): + +- **Input fold path:** when the fold starts, it measures the closing rows, computes the final landing and the required bottom room, and moves the rows and the scrollport on one frame clock to a single landing. +- **Generic path:** scrollTop stays put, the closing height is reserved as bottom room in one block, and following scrolls once more after the fold ends. This holds only when the fold happens below the reader. When the folding region is above the reader, the content the reader is looking at moves up as a whole and the viewport shows only the room. + +`ownInput` holds for three signals only: a local echo in the transcript (`submissionId`), a steer echo (`steeringId`), or a user message as the last list row (`lastIsUser`). In the commit where the Host records the opening input, the Turn's process control is ordered after that input, so `lastIsUser` does not hold. Every human input without a transcript echo on this page therefore took the generic path: + +| Source | Echo on this page | Reader at the bottom | Reader in the middle of the body | +|---|---|---|---| +| Idle send from the main composer | Yes, in the transcript | Correct | Correct | +| Main-Session queued message delivered after the previous Turn | Yes, but in the queue, not consulted | Whole viewport blank | Whole viewport blank | +| Send from another tab | No | Whole viewport blank | Whole viewport blank | +| Send from a subagent child's composer in the right Sidebar | No (the composer skipped `beginSubmission`) | Whole viewport blank | Whole viewport blank | +| Human send after parent messages woke several child Turns | No | Whole viewport blank (about 6000 px of room) | — | + +Notice-woken Turns no longer fold anything ([group-tree positions and exchanges](2026-10-11-chat-group-tree-exchanges.md)); that note lists human input from another tab as a remaining issue, and the table above is that issue's full scope. The data comes from [fold-blank-matrix.e2e.ts](../../../../apps/web/tests/fold-blank-matrix.e2e.ts). + +Subagent children showed no local echo because, when echoes were introduced, `subagents.prompt` carried no request id, so an echo could not be matched with the message the child recorded. Subagent control later moved to Remote, and the Host now records `SubagentPromptRequest.requestId` as the child message's `source.rpcId`; the client's `Session.prompt` still minted a separate id. + +## Decision + +"On next message" keeps a single fold motion: [flow-motion.ts](../../../../packages/client/ui-chat/src/client/chat/flow-motion.ts) hands every batch of closing rows to the `InputFoldDriver` that the viewport supplies, and [use-chat-viewport.ts](../../../../packages/client/ui-chat/src/client/chat/use-chat-viewport.ts) chooses one of two landings when the batch begins. + +### Batches + +- Rows that start closing in one commit, together with rows that start revealing in it, form one batch. The driver's four steps are: + - `begin`: measure the boxes and choose the landing; + - `prepare`: grow the room before box writes so the browser does not clamp an intermediate frame; + - `frame`: write box heights and scrollTop together; + - `finish`: install the final room. + + Content added during the batch updates the remaining movement by starting a new segment. +- A batch with only revealing rows needs no room and keeps CSS transitions. When the viewport has no reading policy attached, closing rows also keep CSS transitions; under reduced motion they finish immediately. +- Closing rows commit their hidden state first, the driver then installs the final geometry, and only then are fold-idle listeners notified (for example, clearing room after a display-mode change). +- The generic path is removed: the `reserve` callback, the `pendingReserve` and `pendingGrowth` reservation arithmetic, `reserveBelow`, and the post-fold `followAfterFold`. `reclaimBelow` (returning room after new content or reader scrolling) and `resetBelow` (clearing room after a display-mode change) remain. + +### Landings + +Let `start` be the scrollTop when the batch begins, `above` the height of closing rows above the viewport top minus revealing rows there, `base = max(0, start − above)`, and `bottom` the floor after the fold. + +| Landing | When | Target scrollTop | After the batch | +|---|---|---|---| +| **Tail** | This client's own input, or the reader was following the tail when the batch began | `max(base, bottom)` | Commit follow ownership | +| **Reader content still** | Otherwise | `base` | Keep the reader's reading state and acknowledge the new position | + +- Both landings install only the exact deficit as room: `max(0, target + viewport height − folded height)`. +- Content never slides back down: content already above the viewport top does not return. When the retained body and the new message cannot fill the viewport, the deficit remains as room until new content fills it. +- Own input during a running batch starts a new segment from the current position toward the tail; it takes over even when a reading gesture cancelled the automatic target. +- A reading gesture cancels the tail landing's automatic target and keeps the reader's position. +- For the reader-content-still landing, the driver takes the first visible top-level row outside the batch as a reference, found by binary search over the ordered top-level rows. Neighbouring spacing changes instantly when the batch begins and when closing rows commit their hidden state; the driver measures the reference's displacement at those two points and offsets scrollTop by it. +- When a tail landing scrolls forward past closing rows that start below the viewport top, those rows stay opaque, scroll with the content, and clip while they shrink; nested closing rows keep their size until the batch ends. Other closing rows fade in place. Scrolling forward therefore never crosses space that is already transparent but not yet closed. + +### Own input + +| Source | Determination | Landing | +|---|---|---| +| Idle send from the main composer | Transcript echo | Tail | +| Idle send from a subagent child's composer | The child also registers a local echo; `subagents.prompt` uses the echo's `requestId` | Tail | +| Queued while running, delivered after the previous Turn | Not own input | Tail when following, otherwise reader content still | +| Send from another tab | Not own input | Same as above | +| Parent Agent messages a child | The notice opens no exchange and folds nothing | — | + +"On completion" is outside the motion scope; its folds apply immediately and are unaffected. + +## Testing + +[fold-blank-matrix.e2e.ts](../../../../apps/web/tests/fold-blank-matrix.e2e.ts) covers 19 main-Session cases and 8 right-Sidebar child cases across reader position (bottom, middle of the body, top of the body, inside the steps, previous Turn), input source (composer, Turn failing before its message is recorded, another tab, queued delivery, steer, parent message, several Turns folding at once), body length, and Standard and "On completion" controls. Blank means the tallest band between the scrollport top and the composer that no visible row covers: at most 120 px once settled, and over that for at most one animation frame, because a closing row fading in place can expose its space for one frame before it finishes shrinking. Tail cases assert the floor landing; reader-content-still cases assert that the reader's anchor row moves at most 2 px on screen. Short-body cases assert only that no room remains after the reply arrives. + +## Alternatives considered + +**Keep the generic path and fix only own-input detection.** The two paths keep diverging geometry rules; for input that is not own input, a reader in the middle is either pulled to the bottom or still sees a blank. + +**Compensate the generic path by reader position but implement it separately from the input path.** The compensation and room geometry would exist twice and could diverge again. + +**Detect own input from the exchange-opening human node and keep children without echoes.** Input from another tab would count as own input and pull a reader in the middle to the bottom; child sends still would not show the sender's message immediately. + +**Let content slide back to fill the deficit a short body leaves.** Content would move up and then down within one animation. + +## Consequences + +- Benefit: the source of whole-viewport blanks (the generic path) no longer exists; the landing depends only on whether the input is own input and whether the reader was following, not on which page sent the message or when it was delivered. Child sends show the sender's message immediately. +- Cost: with a short body, content never sliding back still leaves the deficit until new content fills it. +- When focus is inside a closing row, focus protection keeps that row open. If it is a height-capped process group, its body shrinks to the cap immediately. That shrink happens before the batch begins, so the reader-content-still landing cannot measure it and the reader's content moves once. diff --git a/.agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.zh.md b/.agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.zh.md new file mode 100644 index 0000000000..f7528c01eb --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.zh.md @@ -0,0 +1,91 @@ +# Agent Note: 「下条消息时收起」只用一条折叠动画 + +Status: implemented + +[English](2026-10-13-chat-single-fold-motion.md) | 中文 + +## Problem + +「何时收起工作详情」选「下条消息时收起」时,上一个交互段的过程行在下一条人类输入到来时收起。滚动层原本有两条折叠路径,按 `ownInput`([use-chat-scroll.ts](../../../../packages/client/ui-chat/src/client/chat/use-chat-scroll.ts))二选一: + +- **输入折叠路径**:折叠开始时测量收起行,算出最终落点和所需的底部补白,收起与滚动在同一个帧时钟上推进,一次落定。 +- **通用路径**:scrollTop 不动,把收起的高度整块预留为底部补白,折叠结束后再按跟随状态补滚一次。它只对折叠发生在读者下方的情况成立。折叠区在读者上方时,读者正在看的内容整体上移,视口里只剩补白。 + +`ownInput` 只在三种信号下成立:对话流里的本地回显(`submissionId`)、steer 回显(`steeringId`)、列表最后一行是用户消息(`lastIsUser`)。Host 写回开场输入的那次提交里,该轮的过程控制行排在输入之后,`lastIsUser` 不成立。于是,凡是不经过本页对话流回显的人类输入都走通用路径: + +| 来源 | 本页回显 | 读者在底部 | 读者在正文中段 | +|---|---|---|---| +| 主会话输入框空闲发送 | 有,在对话流 | 正常 | 正常 | +| 主会话排队消息在上一轮结束后自动投递 | 有,但在排队区,不参与判定 | 整屏空白 | 整屏空白 | +| 另一个标签页发送 | 无 | 整屏空白 | 整屏空白 | +| 右侧栏子会话输入框发送 | 无(composer 跳过 `beginSubmission`) | 整屏空白 | 整屏空白 | +| 子会话里父消息唤醒多轮后人类发送 | 无 | 整屏空白(补白约 6000 px) | — | + +通知唤醒的 Turn 已不再触发折叠([分组树位置与交互段](2026-10-11-chat-group-tree-exchanges.zh.md)),那篇记录把另一个标签页的人类输入列为遗留问题;上表是这一遗留问题的完整范围。数据来自 [fold-blank-matrix.e2e.ts](../../../../apps/web/tests/fold-blank-matrix.e2e.ts)。 + +子会话不显示本地回显,是因为引入回显时 `subagents.prompt` 不携带 request id,回显无法与子会话写回的消息对应。此后子 agent 控制迁到 Remote,`SubagentPromptRequest.requestId` 已被 Host 写入子会话用户消息的 `source.rpcId`;客户端 `Session.prompt` 却仍另行生成 id。 + +## Decision + +「下条消息时收起」只保留一条折叠动画:[flow-motion.ts](../../../../packages/client/ui-chat/src/client/chat/flow-motion.ts) 把每一批收起交给 viewport 提供的 `InputFoldDriver`,[use-chat-viewport.ts](../../../../packages/client/ui-chat/src/client/chat/use-chat-viewport.ts) 在批次开始时选择两种落点之一。 + +### 批次 + +- 同一次提交中开始收起的行,连同同批开始展开的行,组成一个批次。驱动器的四步依次为: + - `begin`:测量这些盒子,选定落点; + - `prepare`:在写盒子之前扩大补白,避免中间帧被浏览器截断; + - `frame`:同步写盒子高度和 scrollTop; + - `finish`:安装最终补白。 + + 批次中途新增的内容按分段规则更新剩余运动。 +- 只含展开行的批次不需要补白,仍用 CSS 过渡。viewport 未挂载阅读策略时,收起行也用 CSS 过渡;减少动态效果时立即生效。 +- 收起行先提交 hidden,驱动器再安装最终几何,之后才通知 fold-idle 监听者(例如切换展示模式后清空补白)。 +- 通用路径已删除:`reserve` 回调、`pendingReserve` 与 `pendingGrowth` 的预留计算、`reserveBelow`、折叠结束后再跟随的 `followAfterFold`。保留 `reclaimBelow`(新内容到来或读者滚动后归还补白)与 `resetBelow`(切换展示模式后清空补白)。 + +### 落点 + +记 `start` 为批次开始时的 scrollTop,`above` 为视口顶部以上收起行的高度减去展开行的高度,`base = max(0, start − above)`,`bottom` 为折叠后的底部位置。 + +| 落点 | 何时 | 目标 scrollTop | 结束后 | +|---|---|---|---| +| **到底部** | 本客户端的自己输入,或批次开始时读者正在跟随底部 | `max(base, bottom)` | 提交跟随状态 | +| **读者内容不动** | 其余情况 | `base` | 保持读者的阅读状态,确认新位置 | + +- 两种落点都只安装精确差额作为补白:`max(0, 目标 + 视口高 − 折叠后高度)`。 +- 「内容只上不下」:已经在视口顶部以上的内容不会回滑。保留的正文和新消息不足一屏时留出差额,新内容到来后填满。 +- 批次进行中出现自己输入时,从当前位置开新段,改为到底部;即使阅读手势已取消自动目标,也由新输入接管。 +- 读者的滚动手势会取消「到底部」的自动目标,保留读者位置。 +- 「读者内容不动」时,驱动器对有序的顶层行二分查找,选取视口里第一行不属于本批次的可见顶层行作为参照。批次开始和收起行提交 hidden 时,相邻行的间距会瞬间改变;驱动器在这两处测量参照行的位移,抵消到 scrollTop 上。 +- 「到底部」且需要向前滚过视口顶部以下的收起行时,这些行保持不透明、随内容滚动,在收缩中裁剪显示,嵌套的收起行在批次结束前保持原尺寸。其余收起行原地淡出。这样向前滚动不会穿过一片已经透明、还没收完的空位。 + +### 自己输入的判定 + +| 来源 | 判定 | 落点 | +|---|---|---| +| 主会话输入框空闲发送 | 对话流回显 | 到底部 | +| 子会话输入框空闲发送 | 子会话同样注册本地回显;`subagents.prompt` 使用回显的 `requestId` | 到底部 | +| 运行中排队、上一轮结束后自动投递 | 不是自己输入 | 跟随底部时到底部,否则内容不动 | +| 另一个标签页发送 | 不是自己输入 | 同上 | +| 父 agent 给子会话发消息 | 通知不开启交互段,不折叠 | — | + +「完成时收起」不在动画作用域内,折叠立即生效,不受影响。 + +## Testing + +[fold-blank-matrix.e2e.ts](../../../../apps/web/tests/fold-blank-matrix.e2e.ts) 覆盖主会话 19 例与右侧栏子会话 8 例,维度包括读者位置(底部、正文中段、正文开头、步骤中间、上一轮)、输入来源(输入框、回合在写入消息前失败、另一个标签页、排队投递、steer、父消息、一次折叠多轮)、正文长短,以及 Standard 与「完成时收起」对照。空白指视口顶部到输入框之间没有任何行覆盖的最大连续高度:落定后不超过 120 px;动画中至多 1 帧超过,因为原地淡出的收起行可能在收完之前露出一帧空位。「到底部」的用例断言落到底部;「内容不动」的用例断言读者锚定行的屏幕位置变化不超过 2 px。正文很短的用例只断言回复到达后不再留空。 + +## Alternatives considered + +**保留通用路径,只修自己输入的识别。** 两条路径的几何规则继续分叉;非自己输入时,中段读者要么被拉到底部,要么仍然看到空白。 + +**通用路径改为按读者位置补偿,但与输入路径分开实现。** 补偿与补白的几何代码重复一份,两边容易再次不一致。 + +**用开启交互段的人类输入节点识别自己输入,子会话不补回显。** 另一个标签页的输入会被当成自己输入,把中段读者拉到底部;子会话发送后仍然不能立即看到自己的消息。 + +**允许内容回滑,以填满短正文留下的差额。** 同一次动画里内容先上后下。 + +## Consequences + +- 收益:整屏空白的来源(通用路径)不再存在;落点只看「是不是自己的输入」和「读者是否在跟随」,与消息从哪个页面、何时投递无关。子会话发送立即显示自己的消息。 +- 代价:正文很短时,「内容只上不下」仍会留出差额,直到新内容填满。 +- 焦点停在正在收起的行里时,焦点保护会让该行保持展开。若它是被封顶高度的过程组,正文会立即缩到封顶高度。这次缩小发生在批次开始之前,「读者内容不动」的落点测不到,读者内容会移动一次。 diff --git a/apps/web/tests/fold-blank-matrix.e2e.ts b/apps/web/tests/fold-blank-matrix.e2e.ts new file mode 100644 index 0000000000..bef053a7cb --- /dev/null +++ b/apps/web/tests/fold-blank-matrix.e2e.ts @@ -0,0 +1,776 @@ +/** + * Blank-area matrix for "On next message" folding. A human message folds the + * previous exchange's work details; each case records the largest band between + * the scrollport top and the composer that no visible row covers, after the fold + * settles, after the Turn the message started settles, and on every animation + * frame in between. Cases cover the main Session and a subagent child Session + * hosted in the right Sidebar, across reader positions and input origins. + */ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import type { Browser, Page, Route } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import type { StreamChunk } from '@deepseek-ai/dsh-llm' +import { ToolCallId, createAssistantMessage, createUserMessage } from '@deepseek-ai/dsh-llm' +import type { ReplayEntry } from '@deepseek-ai/dsh-llm-replay' +import { + SESSION_FORMAT_VERSION, SessionId, SessionLogOffset, SessionSeq, type SessionEvent, type SessionHeader, +} from '@deepseek-ai/dsh-session' +import type {} from '@deepseek-ai/dsh-system-prompt' +import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent' +import { createChatScrollFixture, type ChatScrollFixture } from './chat-scroll-fixture.ts' +import { launchWebScaffold, parseSeedFixture, seedSession, watchConsole, webSnapshotMode, type WebScaffold } from './scaffold.ts' +import { newEnglishPage, openSettings } from './support.ts' + +declare global { + interface Window { + foldProbe?: { readonly samples: Geometry[]; stop(): void } + foldMeasure?: (root: string) => Geometry + } +} + +const MODE = webSnapshotMode() +const SEEDED_TURNS = 8 +const SEEDED_STEPS = 6 +const LIVE_STEPS = 6 +const BLANK_LIMIT = 120 +/** + * A closing row fading in place can expose its emptied space for one frame before it finishes + * shrinking; a longer blank is a defect. + */ +const TRANSIENT_FRAMES = 1 +const EVIDENCE = fileURLToPath(new URL('../../../.artifacts/screenshots/fold-blank-matrix/', import.meta.url)) +const MAIN_ROOT = '[data-conversation-scroll]:not([data-sidebar-chat] [data-conversation-scroll])' +const SIDE_ROOT = '[data-sidebar-chat] [data-conversation-scroll]' +const CHILD_LABEL = 'fold matrix child' +const READ_FILE = 'fold-matrix-notes.txt' +const NEXT_INPUT = 'FOLDMX_NEXT_INPUT' + +/** Where the reader stands when the message is sent. */ +type Reader = + | { readonly kind: 'bottom' } + | { readonly kind: 'marker'; readonly marker: string; readonly align: 'center' | 'top' } + +const AT_BOTTOM: Reader = { kind: 'bottom' } +const SEEDED_MID_BODY: Reader = { kind: 'marker', marker: 'FOLDMX_BODY', align: 'center' } +const SEEDED_BODY_TOP: Reader = { kind: 'marker', marker: 'FOLDMX_BODY', align: 'top' } +const SEEDED_STEPS_READER: Reader = { kind: 'marker', marker: 'FOLDMX_STEP_4', align: 'top' } +const PREVIOUS_TURN: Reader = { kind: 'marker', marker: `FOLDMX_PREVIOUS_${SEEDED_TURNS - 1}`, align: 'top' } +const LIVE_MID_BODY: Reader = { kind: 'marker', marker: 'FOLDMX_LIVE_BODY', align: 'center' } + +/** + * How the folding message arrives. + * - `composer`: this page's composer sends while the Session is idle. + * - `error`: as `composer`, but the Turn fails before its user message is recorded. + * - `other-tab`: a second page sends; this page receives it without a local echo. + * - `queued`: this page queues the message while a long live Turn runs; it is delivered after that Turn ends. + * - `steer`: as `queued`, but the queued message is steered into the running Turn. + * - `parent-message`: the live parent Agent messages the child (side chat only). + * - `big-exchange`: two parent messages wake long child Turns before the human message (side chat only). + */ +type Origin = 'composer' | 'error' | 'other-tab' | 'queued' | 'steer' | 'parent-message' | 'big-exchange' + +interface Case { + readonly id: string + readonly surface: 'main' | 'side' + readonly origin: Origin + readonly reader: Reader + readonly bodyParagraphs: number + readonly mode: 'Detailed' | 'Standard' + readonly timing: 'On next message' | 'On completion' + /** + * `tail`: the view ends at the floor. `reader`: the reader's anchor row keeps its screen position. + */ + readonly landing: 'tail' | 'reader' + /** The reader's anchor row is itself folded, so only the blank area is asserted. */ + readonly anchorFolds?: true + /** Content above the viewport never slides back down, so a short body leaves room until newer content fills it. */ + readonly roomUntilReply?: true +} + +const MAIN = { + surface: 'main', origin: 'composer', bodyParagraphs: 40, mode: 'Detailed', timing: 'On next message', landing: 'tail', +} as const +const SIDE = { ...MAIN, surface: 'side' } as const + +const CASES: readonly Case[] = [ + { ...MAIN, id: 'main/composer/bottom', reader: AT_BOTTOM }, + { ...MAIN, id: 'main/composer/mid-body', reader: SEEDED_MID_BODY }, + { ...MAIN, id: 'main/composer/body-top', reader: SEEDED_BODY_TOP }, + { ...MAIN, id: 'main/composer/steps', reader: SEEDED_STEPS_READER }, + { ...MAIN, id: 'main/composer/previous-turn', reader: PREVIOUS_TURN }, + { ...MAIN, id: 'main/composer/short-body/bottom', reader: AT_BOTTOM, bodyParagraphs: 2, roomUntilReply: true }, + { ...MAIN, id: 'main/composer/short-body/steps', reader: SEEDED_STEPS_READER, bodyParagraphs: 2, roomUntilReply: true }, + { ...MAIN, id: 'main/composer/short-body/previous-turn', reader: PREVIOUS_TURN, bodyParagraphs: 2 }, + { ...MAIN, id: 'main/error-before-message/bottom', origin: 'error', reader: AT_BOTTOM }, + { ...MAIN, id: 'main/error-before-message/mid-body', origin: 'error', reader: SEEDED_MID_BODY }, + { ...MAIN, id: 'main/other-tab/bottom', origin: 'other-tab', reader: AT_BOTTOM }, + { ...MAIN, id: 'main/other-tab/mid-body', origin: 'other-tab', reader: SEEDED_MID_BODY, landing: 'reader' }, + { ...MAIN, id: 'main/other-tab/steps', origin: 'other-tab', reader: SEEDED_STEPS_READER, landing: 'reader', anchorFolds: true }, + { ...MAIN, id: 'main/other-tab/previous-turn', origin: 'other-tab', reader: PREVIOUS_TURN, landing: 'reader' }, + { ...MAIN, id: 'main/queued/bottom', origin: 'queued', reader: AT_BOTTOM }, + { ...MAIN, id: 'main/queued/mid-body', origin: 'queued', reader: LIVE_MID_BODY, landing: 'reader' }, + { ...MAIN, id: 'main/steer/bottom', origin: 'steer', reader: AT_BOTTOM }, + { ...MAIN, id: 'main/composer/standard/mid-body', reader: SEEDED_MID_BODY, mode: 'Standard' }, + { ...MAIN, id: 'main/composer/completion/mid-body', reader: SEEDED_MID_BODY, timing: 'On completion' }, + { ...SIDE, id: 'side/composer/bottom', reader: AT_BOTTOM }, + { ...SIDE, id: 'side/composer/mid-body', reader: SEEDED_MID_BODY }, + { ...SIDE, id: 'side/composer/steps', reader: SEEDED_STEPS_READER }, + { ...SIDE, id: 'side/composer/previous-turn', reader: PREVIOUS_TURN }, + { ...SIDE, id: 'side/queued/bottom', origin: 'queued', reader: AT_BOTTOM }, + { ...SIDE, id: 'side/queued/mid-body', origin: 'queued', reader: LIVE_MID_BODY, landing: 'reader' }, + { ...SIDE, id: 'side/parent-message/mid-body', origin: 'parent-message', reader: SEEDED_MID_BODY, landing: 'reader' }, + { ...SIDE, id: 'side/big-exchange/bottom', origin: 'big-exchange', reader: AT_BOTTOM }, +] + +interface Geometry { + readonly top: number + readonly floor: number + readonly spacer: number + /** Largest band between the scrollport top and the composer that no visible row covers. */ + readonly maxGap: number + readonly gapAt: number + /** Empty height between the last visible row and the composer. */ + readonly tailGap: number + readonly following: boolean +} + +/** Installed into each measured page before load; reads one scrollport's blank-area geometry. */ +function installMeasure(): void { + window.foldMeasure = (root) => { + const host = document.querySelector(root) + if (host === null) throw new Error(`scrollport is absent: ${root}`) + const viewport = host.getBoundingClientRect() + const composer = host.querySelector('[data-composer-seat]') + if (composer === null) throw new Error(`composer is absent: ${root}`) + const composerTop = composer.getBoundingClientRect().top + const spacer = host.querySelector('[data-chat-turn-spacer]')?.getBoundingClientRect().height ?? 0 + const intervals = [...host.querySelectorAll('[data-chat-flow-key], [data-submission-echo], [data-pending-steering], [data-streaming], [data-chat-running]')] + .filter(element => element.closest('[hidden]') === null) + .map((element) => { + const rect = element.getBoundingClientRect() + return [Math.max(rect.top, viewport.top), Math.min(rect.bottom, composerTop)] as const + }) + .filter(([top, bottom]) => bottom - top > 0.5) + .sort((left, right) => left[0] - right[0]) + let cursor = viewport.top + let maxGap = 0 + let gapAt = 0 + for (const [top, bottom] of intervals) { + if (top - cursor > maxGap) { + maxGap = top - cursor + gapAt = cursor - viewport.top + } + cursor = Math.max(cursor, bottom) + } + if (composerTop - cursor > maxGap) { + maxGap = composerTop - cursor + gapAt = cursor - viewport.top + } + return { + top: host.scrollTop, floor: Math.max(0, host.scrollHeight - host.clientHeight), spacer, + maxGap, gapAt, tailGap: composerTop - cursor, + following: host.querySelector('[data-chat-following-tail]') !== null, + } + } +} + +function paragraphs(marker: string, count: number, text: string): string { + return `${marker}\n\n${Array.from({ length: count }, (_, line) => `${text} ${line}.`).join('\n\n')}` +} + +/** Main-session log: settled Turns, then a long last Turn of expanded reasoning Steps and a final body. */ +function seededFixture(id: string, bodyParagraphs: number): ChatScrollFixture { + const base = createChatScrollFixture({ markerPrefix: 'FOLDMX', title: `FOLDMX ${id}`, turns: SEEDED_TURNS }) + const parsed = parseSeedFixture(base.log) + const boundary = parsed.events.findIndex(event => event.type === 'turn/start' && event.data.turn === SEEDED_TURNS) + const drafts: (SessionEvent | { type: string; data: object; surfaceOp?: 'append' })[] = parsed.events.slice(0, boundary) + .map((event) => { + if (event.type !== 'assistant/message' || event.data.turn !== SEEDED_TURNS - 1) return event + return { ...event, data: { ...event.data, message: createAssistantMessage({ + content: [{ type: 'text', text: paragraphs(`FOLDMX_PREVIOUS_${SEEDED_TURNS - 1}`, 6, 'Previous Turn answer paragraph') }], + source: { provider: 'fixture', model: 'fixture' }, + }) } } + }) + drafts.push( + { type: 'turn/start', data: { turn: SEEDED_TURNS } }, + { type: 'step/start', data: { turn: SEEDED_TURNS, step: 1 } }, + { type: 'user/message', data: createUserMessage({ + content: [{ type: 'text', text: 'FOLDMX_LAST_USER' }], source: { kind: 'user' }, + }), surfaceOp: 'append' }, + ) + for (let step = 1; step <= SEEDED_STEPS; step++) { + if (step > 1) drafts.push({ type: 'step/start', data: { turn: SEEDED_TURNS, step } }) + drafts.push({ type: 'assistant/message', surfaceOp: 'append', data: { + turn: SEEDED_TURNS, step, stream: [], message: createAssistantMessage({ + content: [ + { type: 'reasoning', text: paragraphs(`FOLDMX_STEP_${step}`, 18, `Reasoning stage ${step} explains an intermediate decision, paragraph`) }, + { type: 'text', text: `FOLDMX_PROGRESS_${step}` }, + ], + source: { provider: 'fixture', model: 'fixture' }, + }), + } }) + drafts.push({ type: 'step/end', data: { turn: SEEDED_TURNS, step } }) + } + drafts.push( + { type: 'step/start', data: { turn: SEEDED_TURNS, step: SEEDED_STEPS + 1 } }, + { type: 'assistant/message', surfaceOp: 'append', data: { + turn: SEEDED_TURNS, step: SEEDED_STEPS + 1, stream: [], message: createAssistantMessage({ + content: [{ type: 'text', text: paragraphs('FOLDMX_BODY', bodyParagraphs, 'Final answer paragraph that remains after the work details fold') }], + source: { provider: 'fixture', model: 'fixture' }, + }), + } }, + { type: 'step/end', data: { turn: SEEDED_TURNS, step: SEEDED_STEPS + 1 } }, + { type: 'turn/end', data: { turn: SEEDED_TURNS, reason: { kind: 'completed' } } }, + ) + return { ...base, log: [parsed.headerLine, + ...drafts.map((event, seq) => JSON.stringify({ ...event, seq, time: seq + 1 })), ''].join('\n') } +} + +function finish(kind: 'stop' | 'tool-calls'): StreamChunk[] { + return [ + { type: 'usage', usage: { inputTokens: 512, outputTokens: 64 } }, + { type: 'finish', reason: { kind } }, + ] +} + +function textChunks(text: string): StreamChunk[] { + return [ + { type: 'block-start', index: 0, blockType: 'text' }, + { type: 'text-delta', index: 0, text }, + { type: 'block-end', index: 0, block: { type: 'text', text } }, + ] +} + +function replyEntry(marker: string): ReplayEntry { + return { kind: 'chunks', chunks: [...textChunks(`${marker} short answer.`), ...finish('stop')] } +} + +/** One live Turn: Steps of narration plus a workspace read, then a long final body. */ +function liveLongTurn(prefix: string): ReplayEntry[] { + const entries: ReplayEntry[] = [] + for (let step = 1; step <= LIVE_STEPS; step++) { + const id = ToolCallId(`${prefix.toLowerCase()}-read-${step}`) + const args = JSON.stringify({ file_path: READ_FILE }) + entries.push({ kind: 'chunks', chunks: [ + ...textChunks(paragraphs(`${prefix}_STEP_${step}`, 10, `${prefix} narration for step ${step}, paragraph`)), + { type: 'block-start', index: 1, blockType: 'tool-call' }, + { type: 'tool-call-delta', index: 1, id, name: 'read', argumentsDelta: args }, + { type: 'block-end', index: 1, block: { type: 'tool-call', id, name: 'read', arguments: args } }, + ...finish('tool-calls'), + ] }) + } + entries.push({ kind: 'chunks', chunks: [ + ...textChunks(paragraphs(prefix === 'FOLDMX_LIVE' ? 'FOLDMX_LIVE_BODY' : `${prefix}_BODY`, 40, `${prefix} final answer paragraph`)), + ...finish('stop'), + ] }) + return entries +} + +function replayFor(scenario: Case): ReplayEntry[] { + switch (scenario.origin) { + case 'composer': + case 'other-tab': + return [replyEntry('FOLDMX_REPLY')] + case 'error': + return [] + case 'queued': + return [...liveLongTurn('FOLDMX_LIVE'), replyEntry('FOLDMX_REPLY')] + case 'steer': + return [...liveLongTurn('FOLDMX_LIVE').slice(0, 3), replyEntry('FOLDMX_STEERED')] + case 'parent-message': + return [replyEntry('FOLDMX_PARENT_REPLY')] + case 'big-exchange': + return [...liveLongTurn('FOLDMX_WAKE_A'), ...liveLongTurn('FOLDMX_WAKE_B'), replyEntry('FOLDMX_REPLY')] + } +} + +async function stableScroll(page: Page, root: string): Promise { + await page.evaluate(async (selector) => { + const host = document.querySelector(selector) + if (host === null) throw new Error(`scrollport is absent: ${selector}`) + let previous = host.scrollTop + let stableSince = performance.now() + await new Promise((resolveStable) => { + const check = (): void => { + const now = performance.now() + const moving = host.querySelector('[data-chat-motion="collapse"], [data-chat-motion="reveal"]') !== null + if (Math.abs(host.scrollTop - previous) > 0.25 || moving) stableSince = now + previous = host.scrollTop + if (now - stableSince >= 300) resolveStable() + else requestAnimationFrame(check) + } + requestAnimationFrame(check) + }) + }, root) +} + +function measure(page: Page, root: string): Promise { + return page.evaluate((selector) => { + if (window.foldMeasure === undefined) throw new Error('fold measurement is not installed') + return window.foldMeasure(selector) + }, root) +} + +async function startTrace(page: Page, root: string): Promise { + await page.evaluate((selector) => { + const read = window.foldMeasure + if (read === undefined) throw new Error('fold measurement is not installed') + const samples: Geometry[] = [] + let frame = 0 + const tick = (): void => { + // A scrollport can remount between frames; that frame contributes no sample. + try { samples.push(read(selector)) } catch (_remounting) { /* skip this frame */ } + frame = requestAnimationFrame(tick) + } + window.foldProbe = { samples, stop: () => { cancelAnimationFrame(frame) } } + frame = requestAnimationFrame(tick) + }, root) +} + +async function stopTrace(page: Page): Promise { + return page.evaluate(() => { + const probe = window.foldProbe + if (probe === undefined) throw new Error('fold trace was not started') + probe.stop() + return probe.samples + }) +} + +/** Screen position of the smallest visible row containing a marker, relative to the scrollport top. */ +function anchorTop(page: Page, root: string, marker: string): Promise { + return page.evaluate(([selector, text]) => { + const host = document.querySelector(selector)! + const row = [...host.querySelectorAll('[data-chat-flow-key], [data-streaming]')] + .filter(candidate => candidate.closest('[hidden]') === null && (candidate.textContent ?? '').includes(text)) + .sort((left, right) => left.getBoundingClientRect().height - right.getBoundingClientRect().height)[0] + return row === undefined ? null : row.getBoundingClientRect().top - host.getBoundingClientRect().top + }, [root, marker] as const) +} + +async function toBottom(page: Page, root: string): Promise { + const button = page.locator(root).getByRole('button', { name: 'Back to bottom', exact: true }) + if (await button.count() > 0) await button.click() + await stableScroll(page, root) + await expect.poll(() => page.locator(`${root} [data-chat-following-tail]`).count()).toBe(1) +} + +async function placeReader(page: Page, root: string, reader: Reader): Promise { + if (reader.kind === 'bottom') { + await toBottom(page, root) + return + } + const delta = await page.evaluate(([selector, marker, align]) => { + const host = document.querySelector(selector)! + const viewport = host.getBoundingClientRect() + const composerTop = host.querySelector('[data-composer-seat]')!.getBoundingClientRect().top + const rows = [...host.querySelectorAll('[data-chat-flow-key], [data-streaming]')] + .filter(row => row.closest('[hidden]') === null && (row.textContent ?? '').includes(marker)) + .sort((left, right) => left.getBoundingClientRect().height - right.getBoundingClientRect().height) + const row = rows[0] + if (row === undefined) throw new Error(`reader anchor is absent: ${marker}`) + const rect = row.getBoundingClientRect() + return align === 'center' + ? rect.top + rect.height / 2 - (viewport.top + composerTop) / 2 + : rect.top - viewport.top - 24 + }, [root, reader.marker, reader.align] as const) + const box = await page.locator(root).boundingBox() + if (box === null) throw new Error('scrollport has no layout box') + await page.mouse.move(box.x + box.width / 2, box.y + Math.min(140, box.height / 3)) + await page.mouse.wheel(0, delta) + await stableScroll(page, root) + // Reader movement is sampled on scrollend or after the sampling interval. + await page.waitForTimeout(700) + expect(await page.locator(`${root} [data-chat-following-tail]`).count()).toBe(0) +} + +async function openSessionBySearch(page: Page, query: string): Promise { + await page.getByText('Ungrouped', { exact: true }).waitFor({ timeout: 30_000 }) + const searchButton = page.getByRole('button', { name: 'Search sessions' }) + if (await searchButton.getAttribute('aria-expanded') !== 'true') await searchButton.click() + await page.getByRole('textbox', { name: 'Search session names', exact: true }).fill(query) + const results = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem') + await expect.poll(() => results.count(), { timeout: 60_000 }).toBe(1) + await results.click() +} + +async function choosePreferences(page: Page, scenario: Case): Promise { + await openSettings(page, 'en') + const dialog = page.getByRole('dialog', { name: 'Settings', exact: true }) + const timing = dialog.getByText('When to Collapse Work Details', { exact: true }).locator('../..') + await timing.getByRole('button').click() + await page.getByRole('menuitem', { name: scenario.timing, exact: true }).click() + const details = dialog.getByText('Work details', { exact: true }).locator('../..') + await details.getByRole('button').click() + await page.getByRole('menuitem', { name: scenario.mode, exact: true }).click() + await dialog.getByRole('button', { name: 'Close', exact: true }).click() +} + +async function expandSeededSteps(page: Page, root: string, scenario: Case): Promise { + if (scenario.mode !== 'Detailed' || scenario.timing !== 'On next message') return + const thoughts = page.locator(`${root} [data-chat-turn="${SEEDED_TURNS}"] [data-variant="think"]`) + await expect.poll(() => thoughts.count()).toBe(SEEDED_STEPS) + for (let index = 0; index < SEEDED_STEPS; index++) { + const button = thoughts.nth(index).getByRole('button').first() + if (await button.getAttribute('aria-expanded') !== 'true') await button.click() + } + // Focus left inside a folding row keeps that row open; a reader who moved on has focused nothing there. + await page.evaluate(() => { if (document.activeElement instanceof HTMLElement) document.activeElement.blur() }) +} + +/** Hold the finish of one future model call; calls are counted from installation. */ +function holdFinishOf(scaffold: WebScaffold, callIndex: number): { readonly reached: Promise; release(): void; dispose(): void } { + const gate = Promise.withResolvers() + const reached = Promise.withResolvers() + let calls = 0 + const dispose = scaffold.ctx.on('llm/stream', async function* (_options, next) { + const index = calls++ + for await (const chunk of next()) { + if (index === callIndex && chunk.type === 'finish') { + reached.resolve(undefined) + await gate.promise + } + yield chunk + } + }) + return { + reached: reached.promise.then(() => undefined), + release: () => { gate.resolve(undefined) }, + dispose: () => { gate.resolve(undefined); dispose() }, + } +} + +interface World { + readonly browser: Browser + readonly scaffold: WebScaffold + readonly page: Page + readonly root: string + /** Session receiving the folding message. */ + readonly target: SessionId + /** Live parent Agent for a side-chat case. */ + readonly parentId?: SessionId + readonly tripwire: ReturnType + /** Session-search text that selects the target's root Session. */ + readonly searchQuery: string +} + +function sessionKey(scenario: Case): string { + return `foldmx-${scenario.id.replaceAll('/', '-')}` +} + +async function createChild(scaffold: WebScaffold, parentId: SessionId, scenario: Case): Promise { + const childId = SessionId(`${sessionKey(scenario)}-child`) + const createdAt = Date.now() - 30_000 + const header: SessionHeader = { + version: SESSION_FORMAT_VERSION, id: childId, createdAt, isSeeded: false, cwd: scaffold.workspaceCwd, + parentSession: parentId, origin: 'subagent', delegationDepth: 1, + } + const parsed = parseSeedFixture(seededFixture(scenario.id, scenario.bodyParagraphs).log) + const firstUser = parsed.events.findIndex(event => event.type === 'user/message') + const ordered = [ + ...parsed.events.slice(0, firstUser + 1), + { type: 'subagent/descriptor', data: snapshotSubagentDescriptor({ + mode: 'continuable', provider: 'spawn', label: CHILD_LABEL, agentProvider: 'deepseek-official', agentModel: 'deepseek-v4-flash', + }) }, + ...parsed.events.slice(firstUser + 1), + ] + const events = ordered.map((event, seq) => ({ ...event, seq: SessionSeq(seq), time: createdAt + seq })) as SessionEvent[] + const handle = await scaffold.ctx.sessionPersistence.create(header) + await handle.append(events) + await handle.close() + scaffold.ctx.sessionProjectionCache.coldSnapshot(header, SessionLogOffset(0), events) + const deadline = Date.now() + 10_000 + while (scaffold.ctx.sessionProjectionCache.cachedSnapshot(header) === undefined) { + if (Date.now() >= deadline) throw new Error('child projection cache row did not land') + await new Promise(resolve => setTimeout(resolve, 10)) + } + const parent = scaffold.ctx.agents.get(parentId) + if (parent === undefined) throw new Error('parent Agent is not live') + parent.session.append('subagent/catalog', { version: 0, childId, childCreatedAt: createdAt, mode: 'continuable', label: CHILD_LABEL }) + return childId +} + +async function openWorld(browser: Browser, scenario: Case, replayDir: string): Promise { + await writeFile(join(replayDir, 'replay.override.json'), JSON.stringify(replayFor(scenario))) + const scaffold = await launchWebScaffold({ + replayFixture: join(replayDir, 'override-only.jsonl'), + replayOverride: join(replayDir, 'replay.override.json'), + paceMs: 16, + replayContextWindow: 10_000_000, + }) + await writeFile(join(scaffold.workspaceCwd, READ_FILE), 'fold matrix notes\n') + const page = await newEnglishPage(browser, 900) + const tripwire = watchConsole(page) + await page.addInitScript(installMeasure) + await page.setViewportSize(scenario.surface === 'main' ? { width: 1280, height: 720 } : { width: 1680, height: 900 }) + await page.emulateMedia({ reducedMotion: 'no-preference' }) + const sessionId = SessionId(sessionKey(scenario)) + if (scenario.surface === 'main') { + const fixture = seededFixture(scenario.id, scenario.bodyParagraphs) + await seedSession(scaffold, fixture.log, sessionId) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await openSessionBySearch(page, fixture.markers.user(1)) + await page.locator(MAIN_ROOT).getByText('FOLDMX_BODY', { exact: false }).last().waitFor({ timeout: 30_000 }) + const resolved = await scaffold.ctx.sessionController.resolveAgent(sessionId) + if ('error' in resolved) throw resolved.error + scaffold.ctx.permissionPresets.set(resolved.agent.session, 'danger-full-access') + await choosePreferences(page, scenario) + return { browser, scaffold, page, root: MAIN_ROOT, target: sessionId, tripwire, searchQuery: fixture.markers.user(1) } + } + const parentFixture = createChatScrollFixture({ markerPrefix: 'FOLDMXP', title: `FOLDMX parent ${scenario.id}`, turns: 2 }) + await seedSession(scaffold, parentFixture.log, sessionId) + const resolved = await scaffold.ctx.sessionController.resolveAgent(sessionId) + if ('error' in resolved) throw resolved.error + scaffold.ctx.permissionPresets.set(resolved.agent.session, 'danger-full-access') + const childId = await createChild(scaffold, sessionId, scenario) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await openSessionBySearch(page, parentFixture.markers.user(1)) + await choosePreferences(page, scenario) + const trigger = page.getByRole('button', { name: '1 subagent', exact: true }) + await trigger.waitFor({ timeout: 15_000 }) + await trigger.hover() + await page.getByRole('treeitem', { name: new RegExp(CHILD_LABEL) }) + .getByRole('button', { name: `Open ${CHILD_LABEL} in sidebar`, exact: true }).click() + await page.locator(SIDE_ROOT).getByText('FOLDMX_BODY', { exact: false }).last().waitFor({ timeout: 30_000 }) + await page.mouse.move(0, 0) + return { + browser, scaffold, page, root: SIDE_ROOT, target: childId, parentId: sessionId, tripwire, + searchQuery: parentFixture.markers.user(1), + } +} + +function composerOf(world: World): ReturnType { + return world.page.locator(`${world.root} [data-composer-input][contenteditable="true"]`).last() +} + +async function send(world: World, text: string): Promise { + await composerOf(world).fill(text) + await world.page.locator(world.root).getByRole('button', { name: 'Send message', exact: true }).click() +} + +/** Wait until a Session's Agent ends a Turn. */ +function turnEnded(scaffold: WebScaffold, sessionId: SessionId, count = 1, timeoutMs = 60_000): Promise { + return new Promise((resolveEnded, reject) => { + let remaining = count + const timer = setTimeout(() => { off(); reject(new Error(`no turn/end for ${sessionId} within ${timeoutMs}ms`)) }, timeoutMs) + const off = scaffold.ctx.on('session/event', (session: { id: SessionId }, event: SessionEvent) => { + if (session.id !== sessionId || event.type !== 'turn/end') return + remaining -= 1 + if (remaining > 0) return + clearTimeout(timer) + off() + resolveEnded() + }) + }) +} + +async function settle(world: World): Promise { + await expect.poll(() => world.page.locator(`${world.root} [data-streaming="true"]`).count(), { timeout: 30_000 }).toBe(0) + await stableScroll(world.page, world.root) + await world.page.waitForTimeout(900) + await stableScroll(world.page, world.root) +} + +/** Drive one case from its prepared reader position through the folding message and its Turn. */ +interface RunResult { + readonly afterFold: Geometry + readonly afterTurn: Geometry + readonly samples: Geometry[] + /** Reader anchor row positions when sending, after the fold, and after the Turn. */ + readonly anchors: { before: number | null; fold: number | null; turn: number | null } +} + +async function run(world: World, scenario: Case): Promise { + const { page, root, scaffold, target } = world + const anchors: RunResult['anchors'] = { before: null, fold: null, turn: null } + const readAnchor = (): Promise => scenario.reader.kind === 'marker' + ? anchorTop(page, root, scenario.reader.marker) : Promise.resolve(null) + const begin = async (): Promise => { + anchors.before = await readAnchor() + await startTrace(page, root) + } + const folded = async (): Promise => { + await stableScroll(page, root) + anchors.fold = await readAnchor() + return measure(page, root) + } + let afterFold: Geometry + switch (scenario.origin) { + case 'composer': + case 'error': { + const disposeFailure = scenario.origin === 'error' + ? scaffold.ctx.on('system-prompt/assemble', async () => { throw new Error('FOLDMX_TURN_FAILED') }) + : () => undefined + const promptGate = Promise.withResolvers() + const holdPrompt = async (route: Route): Promise => { await promptGate.promise; await route.continue() } + await page.route('**/api/session/prompt', holdPrompt) + const ended = turnEnded(scaffold, target) + try { + await expandSeededSteps(page, root, scenario) + await placeReader(page, root, scenario.reader) + await begin() + await send(world, NEXT_INPUT) + await page.locator(`${root} [data-submission-echo]`).waitFor() + afterFold = await folded() + promptGate.resolve(undefined) + await ended + } finally { + promptGate.resolve(undefined) + disposeFailure() + await page.unroute('**/api/session/prompt', holdPrompt) + } + break + } + case 'other-tab': { + await expandSeededSteps(page, root, scenario) + const other = await newEnglishPage(world.browser, 720) + await other.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await openSessionBySearch(other, world.searchQuery) + await other.locator(MAIN_ROOT).getByText('FOLDMX_BODY', { exact: false }).last().waitFor({ timeout: 30_000 }) + await placeReader(page, root, scenario.reader) + await begin() + const ended = turnEnded(scaffold, target) + await other.locator(`${MAIN_ROOT} [data-composer-input][contenteditable="true"]`).last().fill(NEXT_INPUT) + await other.locator(MAIN_ROOT).getByRole('button', { name: 'Send message', exact: true }).click() + await page.locator(`${root} [data-chat-flow-kind="user"]`, { hasText: NEXT_INPUT }).waitFor() + afterFold = await folded() + await ended + await other.context().close() + break + } + case 'queued': + case 'steer': { + const steps = scenario.origin === 'queued' ? LIVE_STEPS : 2 + const gate = holdFinishOf(scaffold, steps) + try { + const first = turnEnded(scaffold, target, scenario.origin === 'queued' ? 2 : 1) + await toBottom(page, root) + await send(world, 'FOLDMX_START_LIVE_TURN') + await gate.reached + await stableScroll(page, root) + if (scenario.origin === 'queued' && scenario.reader.kind === 'marker') { + await page.locator(root).getByText('FOLDMX_LIVE_BODY', { exact: false }).last().waitFor() + } + await placeReader(page, root, scenario.reader) + await composerOf(world).fill(NEXT_INPUT) + await composerOf(world).press('Enter') + const steerButton = page.getByRole('button', { name: 'Steer queued message' }) + await steerButton.waitFor({ timeout: 15_000 }) + if (scenario.origin === 'steer') { + await expect.poll(() => steerButton.isEnabled(), { timeout: 10_000 }).toBe(true) + await steerButton.click() + } + await begin() + gate.release() + await page.locator(`${root} [data-chat-flow-kind="user"], ${root} [data-chat-flow-kind="steering"]`, { hasText: NEXT_INPUT }) + .first().waitFor({ timeout: 30_000 }) + afterFold = await folded() + await first + } finally { + gate.dispose() + } + break + } + case 'parent-message': { + await expandSeededSteps(page, root, scenario) + await placeReader(page, root, scenario.reader) + await begin() + const parent = scaffold.ctx.agents.get(world.parentId!) + if (parent === undefined) throw new Error('parent Agent is not live') + const ended = turnEnded(scaffold, target) + await scaffold.ctx.subagents.sendMessage(parent, target, [{ type: 'text', text: 'FOLDMX_PARENT_NOTE' }], { signal: new AbortController().signal }) + await page.locator(`${root} [data-chat-turn="${SEEDED_TURNS + 1}"]`).first().waitFor({ timeout: 30_000 }) + afterFold = await folded() + await ended + break + } + case 'big-exchange': { + const parent = scaffold.ctx.agents.get(world.parentId!) + if (parent === undefined) throw new Error('parent Agent is not live') + for (const note of ['FOLDMX_WAKE_A_NOTE', 'FOLDMX_WAKE_B_NOTE']) { + const woken = turnEnded(scaffold, target) + await scaffold.ctx.subagents.sendMessage(parent, target, [{ type: 'text', text: note }], { signal: new AbortController().signal }) + await woken + } + await settle(world) + await placeReader(page, root, scenario.reader) + await begin() + const ended = turnEnded(scaffold, target) + await send(world, NEXT_INPUT) + await page.locator(`${root} [data-chat-flow-kind="user"]`, { hasText: NEXT_INPUT }).waitFor({ timeout: 30_000 }) + afterFold = await folded() + await ended + break + } + } + await settle(world) + anchors.turn = await readAnchor() + const afterTurn = await measure(page, root) + const samples = await stopTrace(page) + return { afterFold, afterTurn, samples, anchors } +} + +function rounded(geometry: Geometry): Record { + return { + top: Math.round(geometry.top), floor: Math.round(geometry.floor), spacer: Math.round(geometry.spacer), + maxGap: Math.round(geometry.maxGap), gapAt: Math.round(geometry.gapAt), following: geometry.following, + } +} + +describe.skipIf(MODE === 'record')('web e2e: blank area after next-message folding', () => { + let browser: Browser + + beforeAll(async () => { + browser = await chromium.launch() + await mkdir(EVIDENCE, { recursive: true }) + }) + + afterAll(async () => { + await browser?.close() + }) + + for (const scenario of CASES) { + it(scenario.id, async () => { + const replayDir = await mkdtemp(join(tmpdir(), 'dsh-fold-matrix-')) + let world: World | undefined + try { + world = await openWorld(browser, scenario, replayDir) + const { afterFold, afterTurn, samples, anchors } = await run(world, scenario) + const transient = samples.filter(sample => sample.maxGap > BLANK_LIMIT) + const summary = { + id: scenario.id, landing: scenario.landing, before: samples[0] === undefined ? null : rounded(samples[0]), + afterFold: rounded(afterFold), afterTurn: rounded(afterTurn), anchors, + peakGap: Math.round(Math.max(0, ...samples.map(sample => sample.maxGap))), + framesOverLimit: transient.length, frames: samples.length, + peakSpacer: Math.round(Math.max(0, ...samples.map(sample => sample.spacer))), + } + await writeFile(join(EVIDENCE, `${sessionKey(scenario)}.json`), JSON.stringify({ scenario, ...summary, samples }, null, 2)) + console.log(`FOLDMX ${JSON.stringify(summary)}`) + if (scenario.roomUntilReply !== true) { + expect.soft(afterFold.maxGap, 'blank after the fold settles').toBeLessThanOrEqual(BLANK_LIMIT) + expect.soft(transient.length, 'animation frames showing a blank').toBeLessThanOrEqual(TRANSIENT_FRAMES) + } + expect.soft(afterTurn.maxGap, 'blank after the Turn settles').toBeLessThanOrEqual(BLANK_LIMIT) + if (scenario.landing === 'tail') { + expect.soft(afterFold.floor - afterFold.top, 'distance from the floor after the fold').toBeLessThanOrEqual(1) + expect.soft(afterTurn.floor - afterTurn.top, 'distance from the floor after the Turn').toBeLessThanOrEqual(1) + expect.soft(afterTurn.following, 'following after the Turn').toBe(true) + } else if (scenario.anchorFolds !== true) { + expect.soft(anchors.before).not.toBeNull() + expect.soft(Math.abs((anchors.fold ?? Infinity) - (anchors.before ?? 0)), 'anchor movement after the fold').toBeLessThanOrEqual(2) + expect.soft(Math.abs((anchors.turn ?? Infinity) - (anchors.before ?? 0)), 'anchor movement after the Turn').toBeLessThanOrEqual(2) + } + expect.soft(world.tripwire.pageErrors).toEqual([]) + } finally { + await world?.page.context().close().catch(() => undefined) + await world?.scaffold.close().catch((error: unknown) => { console.log(`FOLDMX close ${scenario.id}: ${String(error)}`) }) + await rm(replayDir, { recursive: true, force: true }) + } + }, 240_000) + } +}) + +export {} diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index b529ba1697..9103d289a5 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -24,6 +24,7 @@ "exclude": [ "tests/collapse-timing.e2e.ts", "tests/notice-wake-tail.e2e.ts", + "tests/fold-blank-matrix.e2e.ts", "tests/chat-flow-layout.e2e.ts", "tests/layout-bottom.e2e.ts", "tests/shortcuts-desktop.e2e.ts", diff --git a/packages/api/session-controller/src/client/sessions/session.ts b/packages/api/session-controller/src/client/sessions/session.ts index 284abc5656..09494e01ab 100644 --- a/packages/api/session-controller/src/client/sessions/session.ts +++ b/packages/api/session-controller/src/client/sessions/session.ts @@ -289,7 +289,7 @@ export class Session implements SessionFace { // wire type is used; this array is not filtered or reordered. const routedContent = content as Exclude[] const routed = await this.remote.subagents.prompt({ - requestId: randomUUID() as SessionRequestId, + requestId: requestId ?? randomUUID() as SessionRequestId, parentSessionId: this.address.parentSessionId, childSessionId: this.address.childSessionId, mode: 'continuable', diff --git a/packages/api/session-controller/tests/session.client.spec.ts b/packages/api/session-controller/tests/session.client.spec.ts index ccba6d3a6a..8d1838e40f 100644 --- a/packages/api/session-controller/tests/session.client.spec.ts +++ b/packages/api/session-controller/tests/session.client.spec.ts @@ -513,6 +513,19 @@ describe('prompt and cancel errors', () => { expect(session.getSnapshot().subagent).toEqual({ address: CHILD, parentAvailable: true }) }) + it('sends an addressed child continuation under its echo request id and retires a refused echo', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID, { address: CHILD, parentAvailable: true }) + await session.open() + const accepted = session.beginSubmission({ mode: 'queue', text: '继续', attachments: [] }) + await session.prompt([{ type: 'text', text: '继续' }], 'queue', undefined, accepted.requestId) + expect(mock.log.requests('subagents/prompt')).toMatchObject([{ requestId: accepted.requestId, ...CHILD }]) + expect(session.getSnapshot().pendingSubmissions.map(echo => echo.requestId)).toEqual([accepted.requestId]) + + const refused = session.beginSubmission({ mode: 'queue', text: '附件', attachments: [] }) + await session.prompt([{ type: 'file', receiptId: 'receipt' as never }], 'queue', undefined, refused.requestId) + expect(session.getSnapshot().pendingSubmissions.map(echo => echo.requestId)).toEqual([accepted.requestId]) + }) + it('forwards continuation image parts to the subagent prompt Remote unstripped', async ({ mock, start }) => { const session = await sessionBench(mock, start, SID, { address: CHILD, parentAvailable: true }) await session.open() diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index dd9033ae18..e930ea7167 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/packages/client/ui-chat/README.i18n.yaml @@ -36,8 +36,8 @@ en: 81d7ca7595c34d39 zh: 29eb8248c75e9ea6 /deepseek-ai-dsh-client-ui-chat/scroll-ownership: - en: 3f4e6c161b4583bb - zh: e4265bb2bce8eb1c + en: 82c23ea0545c2f60 + zh: 42fdd5a16a0ca301 /deepseek-ai-dsh-client-ui-chat/model-experience: en: c17b4dd43a61c569 zh: f7414309810c3a08 diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index f04957566b..1b35267bd5 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -138,11 +138,11 @@ The transcript root uses `overflow-x: visible; overflow-y: clip`: vertical overf Outer transcript following and each open capped group's following are independent. Native animation progress retains follow intent; only that scrollport's own `scrollend` settles its animation. Browsers without `scrollend` may retain the native follow target until a later bottom-aligned update or reader gesture. A reader gesture interrupts the animation, and actual movement determines whether following remains enabled. Scroll chaining can move the outer transcript, which then applies its own distance threshold. The back-to-bottom button restores only outer following. -Next-message folding combines row closure and tail following in one cancellable task, with browser scroll anchoring disabled during the fold. If the removed height exceeds the new content's height, the difference remains as bottom space rather than reducing the scroll offset to chase the unpadded content floor. This applies even when the conversation already exceeds the viewport. New content fills the retained space before following advances again; a smaller fold leaves only the remaining forward distance to scroll. Reader gestures cancel the task and active native following; resize cannot revive a cancelled task in the same fold, but new submitted input can. Off-bottom readers retain their position, and acknowledged scrolling releases only space that can be removed without clamping them. Unloading Chat cancels waiting callbacks and row transitions. Reduced-motion users receive immediate visibility and positioning changes. +Next-message folding moves every batch of closing rows and the scrollport on one frame clock, with browser scroll anchoring disabled during the fold; there is no separate reserve-then-follow path. The batch lands at the tail when this client's own input caused it (including a subagent child's continuation) or when the reader was following; otherwise the reader's visible content stays still, whoever sent the message. Content above the reading line never slides back down: when the retained answer and the new content cannot fill the viewport, only that deficit remains as bottom space, and new content fills it before following advances again. Reader gestures cancel the automatic target and active native following; resize cannot revive it in the same fold, but new submitted input can. Acknowledged scrolling releases only space that can be removed without clamping the reader. Unloading Chat cancels waiting callbacks and row transitions. Reduced-motion users receive immediate visibility and positioning changes. Changing Work details clears reserved bottom space after active folds finish and reconciles the scroll range with the selected mode. Readers already following the tail stay at the new bottom; other readers are not forced to follow. -Fold coordination is viewport-local: reservations and idle callbacks never broadcast to other Chat views. ChatView supplies its motion capability once to `conversation.chat.flow` through the render's `hookContext`. Flow binds the group and header behavior hooks, owns node dispatch, supplies Factory rendering for local echoes, and shares the existing Chat store and Session node sources. The image entry Factory owns the replaceable image Slot. Layout nodes and inline reasoning receive the bound hooks without receiving the motion controller or changing content parents. Generic searchable hiding applies before paint and owns focus protection and beforematch handling; pending-input reconciliation remains in ChatView. +Fold coordination is viewport-local: fold batches, bottom space, and idle callbacks never broadcast to other Chat views. ChatView supplies its motion capability once to `conversation.chat.flow` through the render's `hookContext`. Flow binds the group and header behavior hooks, owns node dispatch, supplies Factory rendering for local echoes, and shares the existing Chat store and Session node sources. The image entry Factory owns the replaceable image Slot. Layout nodes and inline reasoning receive the bound hooks without receiving the motion controller or changing content parents. Generic searchable hiding applies before paint and owns focus protection and beforematch handling; pending-input reconciliation remains in ChatView. | Outer follows | Open group follows | Back-to-bottom button | New content | |---|---|---|---| diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index 064607a1a3..c26efa2e8b 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -138,11 +138,11 @@ Chat 会在历史前插与 renderer 重新挂载时恢复语义锚点,并且 外层文本记录与每个已展开限高组的跟随状态彼此独立。原生动画的中间滚动保留跟随意图;只有该滚动容器自身的 `scrollend` 才结束其动画。不支持 `scrollend` 的浏览器可能保留原生跟随目标,直到后续贴底更新或读者手势将其结束。读者手势中断动画,是否继续跟随由实际位移决定。滚动传递可以带动外层,外层再按自身离底距离判断。回到底部按钮只恢复外层跟随。 -下次消息触发的折叠与跟尾属于同一个可取消任务,折叠期间禁用浏览器滚动锚定。如果移除的高度大于新增内容的高度,差值保留为底部空间,不通过减小滚动偏移追到未含预留空间的内容底部;对话总高度已经超过视口时也遵循此规则。后续新增内容先填充保留区域,填满后才继续跟尾;折叠高度较小时,仅滚动剩余的向前距离。读者手势取消该任务及进行中的原生跟随;同一次折叠中的尺寸变化不能恢复已取消的任务,但新提交的输入可以。离底读者保留位置,已确认的滚动只释放不会造成位置钳制的多余空间。卸载 Chat 时取消等待回调和行过渡。启用减少动态效果偏好时,可见性和定位立即变化。 +下次消息触发的折叠中,每一批收起行与滚动容器在同一个帧时钟上推进,折叠期间禁用浏览器滚动锚定;不存在「先预留、再跟随」的另一条路径。由本客户端自己的输入(包括子代理会话的 continuation)触发、或读者原本在跟尾时,这一批落到底部;其余情况下,无论消息由谁发出,读者正在看的内容保持不动。阅读线以上的内容不会回滑:保留的正文和新内容填不满视口时,只把差额保留为底部空间,后续新增内容先填满它,再继续跟尾。读者手势取消自动目标及进行中的原生跟随;同一次折叠中的尺寸变化不能恢复它,但新提交的输入可以。已确认的滚动只释放不会造成位置钳制的多余空间。卸载 Chat 时取消等待回调和行过渡。启用减少动态效果偏好时,可见性和定位立即变化。 切换工作步骤展示档位时,当前折叠结束后清空底部预留空间,并按所选档位调整滚动范围。原本跟随底部的读者保持在新的底部;其他读者不会被强制拉回底部。 -折叠协调状态由各自的 viewport 持有,空间预留和空闲回调不会广播到其他 Chat 视图。ChatView 通过本次渲染的 `hookContext`,向 `conversation.chat.flow` 交接一次动画能力。Flow 绑定组内容与组头的行为 hook,负责节点分发,为本地回显提供 Factory renderer,并共享既有 Chat store 与 Session 节点来源。图片入口 Factory 拥有可替换的图片 Slot。布局节点和行内思考接收绑定后的 hook,不接收动画控制器,也不改变内容的父级。通用的可搜索隐藏在绘制前生效,并负责焦点保护及 beforematch;pending 输入对账仍留在 ChatView。 +折叠协调状态由各自的 viewport 持有,折叠批次、底部空间和空闲回调不会广播到其他 Chat 视图。ChatView 通过本次渲染的 `hookContext`,向 `conversation.chat.flow` 交接一次动画能力。Flow 绑定组内容与组头的行为 hook,负责节点分发,为本地回显提供 Factory renderer,并共享既有 Chat store 与 Session 节点来源。图片入口 Factory 拥有可替换的图片 Slot。布局节点和行内思考接收绑定后的 hook,不接收动画控制器,也不改变内容的父级。通用的可搜索隐藏在绘制前生效,并负责焦点保护及 beforematch;pending 输入对账仍留在 ChatView。 | 外层跟随 | 已展开小组跟随 | 回到底部按钮 | 新增内容 | |---|---|---|---| diff --git a/packages/client/ui-chat/src/client/chat/flow-motion.ts b/packages/client/ui-chat/src/client/chat/flow-motion.ts index 9705f1f22a..49e670acff 100644 --- a/packages/client/ui-chat/src/client/chat/flow-motion.ts +++ b/packages/client/ui-chat/src/client/chat/flow-motion.ts @@ -1,7 +1,7 @@ /** - * Fold transitions owned by one Chat viewport. Input folds coordinate box sizes and scrolling on - * one frame clock; other folds use CSS transitions. Motion is opt-in under `[data-chat-motion]`, - * respects reduced motion, and clears temporary styles. + * Fold transitions owned by one Chat viewport. Every batch containing a closing row moves its boxes and + * the scrollport on one frame clock; a batch that only reveals rows uses CSS transitions. Motion is + * opt-in under `[data-chat-motion]`, respects reduced motion, and clears temporary styles. */ import { useCallback, useLayoutEffect, useRef, type RefObject } from 'react' import { useSearchableHidden, type HiddenTransition } from './searchable-hidden.ts' @@ -24,7 +24,7 @@ export interface FlowMotionRows { cancelMotion(element: HTMLElement): void } -/** Geometry captured before one input-owned fold changes its boxes. */ +/** Geometry captured before one fold batch changes its boxes. */ export interface InputFoldBox { readonly element: HTMLElement readonly height: number @@ -32,29 +32,31 @@ export interface InputFoldBox { readonly closing: boolean } -/** The viewport follows the same progress that changes the folded boxes. */ +/** The viewport moves on the same progress that changes the folded boxes. */ export interface InputFoldDriver { /** - * Capture geometry before the frame clock changes the boxes. + * Capture geometry and choose the landing before the frame clock changes the boxes. * @param boxes - non-nested boxes whose layout changes on this clock. + * @returns closing boxes that scroll with the content and stay opaque; the rest fade in place. */ - begin(boxes: readonly InputFoldBox[]): void + begin(boxes: readonly InputFoldBox[]): ReadonlySet /** - * Reserve scroll range before box writes; omit when the driver needs no pre-write compensation. + * Reserve scroll range before box writes. * @param progress - shared eased progress from zero to one. - * @param cancelled - automatic following has stopped; reader-position compensation still applies. + * @param cancelled - a reading gesture stopped the automatic target. */ - prepare?(progress: number, cancelled: boolean): void + prepare(progress: number, cancelled: boolean): void /** - * Apply viewport geometry after box writes, even when automatic following is cancelled. + * Apply viewport geometry after box writes. * @param progress - shared eased progress from zero to one. - * @param cancelled - preserve the reader's position rather than applying the automatic target. + * @param cancelled - a reading gesture stopped the automatic target. * @returns actual scrollTop displacement from the batch start, for closing-content visual compensation. */ frame(progress: number, cancelled: boolean): number /** - * Release temporary geometry after the last frame or cancellation. - * @param cancelled - clean up without reacquiring follow ownership or committing an obsolete landing. + * Install the final geometry after the last frame or cancellation. + * @param cancelled - a reading gesture stopped the automatic target, every closing row reopened, or the + * viewport detached; the driver commits no obsolete landing. */ finish(cancelled: boolean): void } @@ -65,11 +67,11 @@ export interface FlowMotion extends FlowMotionRows { foldActive(): boolean /** @param listener - callback after the last fold. @returns cancellation for a pending callback. */ onFoldIdle(listener: () => void): () => void - /** @param driver - one input-owned viewport motion. @returns whether an active fold was adopted. */ - followInput(driver: InputFoldDriver): boolean - /** Stop input-owned scrolling while letting its closing rows finish. */ + /** Stop the running batch's automatic target while letting its boxes finish. */ cancelInput(): void - /** @returns whether a coordinated fold currently owns layout and scrolling. */ + /** Restore the running batch's automatic target after a newer request takes it over. */ + resumeInput(): void + /** @returns whether a fold batch currently owns layout and scrolling. */ inputActive(): boolean /** Cancel owned animations and pending work when the viewport detaches. */ clear(): void @@ -96,111 +98,113 @@ export function hiddenOrCollapsing(element: HTMLElement): boolean { /** * Create animation coordination without cross-viewport broadcasts. - * @param reserve - reserve the deduplicated removed height in this viewport. + * @param driver - the viewport's driver for the next batch; null while detached. * @param foldingChanged - disable or restore this viewport's native scroll anchoring. * @returns stable callbacks and cleanup for this viewport's animations. */ -export function createFlowMotion(reserve: (px: number) => void, foldingChanged: (active: boolean) => void): FlowMotion { +export function createFlowMotion(driver: () => InputFoldDriver | null, foldingChanged: (active: boolean) => void): FlowMotion { const running = new Map void>() let folding = 0 const foldIdleListeners = new Set<() => void>() - const pendingReserve = new Set() - let pendingGrowth = 0 - let reserveScheduled = false + let batchScheduled = false const boxes = new Map void; finish: () => void }>() - let inputDriver: InputFoldDriver | null = null - let inputFrame: number | null = null - let inputBatch = false + let frameRequest: number | null = null + let batch = false + let cancelled = false let clearEpoch = 0 - let inputCleanup: (() => void) | null = null - let activeDriver: InputFoldDriver | null = null + let batchCleanup: (() => void) | null = null let cancelEmptyBatch: (() => void) | null = null + /** The batch is committing hidden rows and its final geometry; idle listeners wait until both land. */ + let settling = false + let anchoringDisabled = false - function cancelInput(): void { inputDriver = null } - - function followInput(driver: InputFoldDriver): boolean { - if (folding === 0) return false - if (inputBatch) { - inputDriver = activeDriver - return true - } - inputDriver = driver - scheduleReserve() - return true + function cancelInput(): void { + if (batch) cancelled = true } - function runInputBatch(): void { - const driver = inputDriver - if (driver === null || inputBatch) return + function resumeInput(): void { + cancelled = false + } + + function runBatch(current: InputFoldDriver): void { const owned = [...boxes.values()] - const batch = owned.filter(box => box.element.parentElement?.closest('[data-chat-motion="collapse"]') === null) + const members = owned.filter(box => box.element.parentElement?.closest('[data-chat-motion="collapse"]') === null) .sort((left, right) => left.element.compareDocumentPosition(right.element) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1) - if (!batch.some(box => box.closing)) return - inputBatch = true - activeDriver = driver - driver.begin(batch) - inputCleanup = () => { driver.finish(true) } + if (!members.some(box => box.closing)) return + batch = true + cancelled = false + const travelling = current.begin(members) + batchCleanup = () => { current.finish(true) } for (const box of owned) box.take() - for (const box of batch) { + for (const box of members) { box.element.style.transition = 'none' box.element.style.height = `${box.closing ? box.height : 0}px` box.element.style.marginTop = `${box.closing ? box.marginTop : 0}px` box.element.style.opacity = box.closing ? '1' : '0' } + // A travelling box clips its content as it shrinks, so nested closing rows keep their size until it ends. + for (const box of owned) { + if (members.includes(box) || ![...travelling].some(element => element.contains(box.element))) continue + box.element.style.transition = 'none' + box.element.style.height = `${box.height}px` + box.element.style.marginTop = `${box.marginTop}px` + box.element.style.opacity = '1' + } const reduced = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches let started: number | undefined let displacement = 0 - cancelEmptyBatch = () => { - if (batch.some(box => box.closing && boxes.get(box.element) === box)) return - if (inputFrame !== null) cancelAnimationFrame(inputFrame) - inputFrame = null - inputBatch = false - activeDriver = null - inputDriver = null - inputCleanup = null + const settle = (finishCancelled: boolean): void => { + batch = false + batchCleanup = null cancelEmptyBatch = null - for (const box of owned) if (boxes.get(box.element) === box) box.finish() - driver.finish(true) + settling = true + try { + for (const box of owned) if (boxes.get(box.element) === box) box.finish() + current.finish(finishCancelled) + } finally { + settling = false + } + if (folding === 0) notifyIdle() + } + cancelEmptyBatch = () => { + if (members.some(box => box.closing && boxes.get(box.element) === box)) return + if (frameRequest !== null) cancelAnimationFrame(frameRequest) + frameRequest = null + settle(true) } const tick = (now: number): void => { - inputFrame = null + frameRequest = null started ??= now const elapsed = reduced ? CLOSE_DELAY_MS + CLOSE_MS : now - started const linear = Math.min(1, Math.max(0, (elapsed - CLOSE_DELAY_MS) / CLOSE_MS)) const progress = linear * linear * (3 - 2 * linear) - driver.prepare?.(progress, inputDriver !== driver) - for (const box of batch) { + current.prepare(progress, cancelled) + for (const box of members) { if (boxes.get(box.element) !== box) continue const remaining = box.closing ? 1 - progress : progress box.element.style.height = `${box.height * remaining}px` box.element.style.marginTop = `${box.marginTop * remaining}px` - box.element.style.opacity = box.closing ? String(Math.max(0, 1 - elapsed / FADE_OUT_MS)) : String(progress) + box.element.style.opacity = !box.closing ? String(progress) + : travelling.has(box.element) ? '1' : String(Math.max(0, 1 - elapsed / FADE_OUT_MS)) } - displacement = driver.frame(progress, inputDriver !== driver) + displacement = current.frame(progress, cancelled) let removedBefore = 0 - for (const box of batch) { + for (const box of members) { if (boxes.get(box.element) !== box) continue if (box.closing) { // Transforming the closing box changes its pixels, not its flow contribution. - box.element.style.translate = `0 ${displacement + removedBefore}px` + if (!travelling.has(box.element)) box.element.style.translate = `0 ${displacement + removedBefore}px` removedBefore += (box.height + box.marginTop) * progress } else removedBefore -= (box.height + box.marginTop) * progress } if (linear < 1) { - inputFrame = requestAnimationFrame(tick) + frameRequest = requestAnimationFrame(tick) return } - for (const box of owned) box.finish() - inputBatch = false - activeDriver = null - inputCleanup = null - cancelEmptyBatch = null - const cancelled = inputDriver !== driver - if (!cancelled) inputDriver = null - driver.finish(cancelled) + settle(cancelled) } if (reduced) tick(0) - else inputFrame = requestAnimationFrame(tick) + else frameRequest = requestAnimationFrame(tick) } function foldActive(): boolean { return folding > 0 } @@ -211,48 +215,42 @@ export function createFlowMotion(reserve: (px: number) => void, foldingChanged: return () => { foldIdleListeners.delete(listener) } } - function flushReserve(): void { - reserveScheduled = false - if (inputDriver !== null && !inputBatch) { - pendingReserve.clear() - pendingGrowth = 0 - runInputBatch() - return - } - if (typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches) { - pendingReserve.clear() - pendingGrowth = 0 + function flushBatch(): void { + batchScheduled = false + // Rows that start closing during a batch keep their CSS transitions; the batch follows their size changes. + if (batch || ![...boxes.values()].some(box => box.closing)) return + const current = driver() + if (current !== null) runBatch(current) + else if (typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches) { for (const box of [...boxes.values()]) box.finish() - return } - let total = -pendingGrowth - pendingGrowth = 0 - for (const element of pendingReserve) { - // A member inside a closing group is already part of the group's own box. - if (element.parentElement?.closest('[data-chat-motion="collapse"]') !== null) continue - total += element.offsetHeight + (Number.parseFloat(getComputedStyle(element).marginTop) || 0) - } - pendingReserve.clear() - if (total > 0) reserve(total) } - function scheduleReserve(): void { - if (reserveScheduled) return - reserveScheduled = true + function scheduleBatch(): void { + if (batchScheduled) return + batchScheduled = true // After the whole commit's collapses and reveals have started, before any box changes size. const epoch = clearEpoch - queueMicrotask(() => { if (epoch === clearEpoch) flushReserve() }) + queueMicrotask(() => { if (epoch === clearEpoch) flushBatch() }) } function foldStarted(): void { folding += 1 - if (folding === 1) foldingChanged(true) + if (folding !== 1 || anchoringDisabled) return + anchoringDisabled = true + foldingChanged(true) } function foldEnded(): void { folding = Math.max(0, folding - 1) - if (folding > 0) return - foldingChanged(false) + if (folding === 0 && !settling) notifyIdle() + } + + function notifyIdle(): void { + if (anchoringDisabled) { + anchoringDisabled = false + foldingChanged(false) + } const listeners = [...foldIdleListeners] foldIdleListeners.clear() for (const listener of listeners) listener() @@ -274,7 +272,6 @@ export function createFlowMotion(reserve: (px: number) => void, foldingChanged: } function cancelMotion(element: HTMLElement): void { - pendingReserve.delete(element) running.get(element)?.() cancelEmptyBatch?.() } @@ -290,8 +287,7 @@ export function createFlowMotion(reserve: (px: number) => void, foldingChanged: const { style } = element element.dataset.chatMotion = 'collapse' foldStarted() - pendingReserve.add(element) - scheduleReserve() + scheduleBatch() style.transition = 'none' style.overflow = 'hidden' style.height = `${height}px` @@ -329,8 +325,7 @@ export function createFlowMotion(reserve: (px: number) => void, foldingChanged: const height = element.offsetHeight if (height === 0) return const marginTop = Number.parseFloat(getComputedStyle(element).marginTop) || 0 - pendingGrowth += height + marginTop - scheduleReserve() + scheduleBatch() const { style } = element element.dataset.chatMotion = 'reveal' style.transition = 'none' @@ -360,22 +355,21 @@ export function createFlowMotion(reserve: (px: number) => void, foldingChanged: function clear(): void { clearEpoch++ - reserveScheduled = false - inputDriver = null - activeDriver = null - inputBatch = false - if (inputFrame !== null) cancelAnimationFrame(inputFrame) - inputFrame = null - inputCleanup?.() - inputCleanup = null + batchScheduled = false + batch = false + cancelled = false + if (frameRequest !== null) cancelAnimationFrame(frameRequest) + frameRequest = null + batchCleanup?.() + batchCleanup = null cancelEmptyBatch = null foldIdleListeners.clear() - pendingReserve.clear() - pendingGrowth = 0 for (const cancel of [...running.values()]) cancel() } - return { collapse, reveal, cancelMotion, foldActive, onFoldIdle, followInput, cancelInput, inputActive: () => inputBatch, clear } + return { + collapse, reveal, cancelMotion, foldActive, onFoldIdle, cancelInput, resumeInput, inputActive: () => batch, clear, + } } function useFlowTransition(motion: FlowMotionRows | undefined, grow: boolean): HiddenTransition | undefined { diff --git a/packages/client/ui-chat/src/client/chat/use-chat-reading.ts b/packages/client/ui-chat/src/client/chat/use-chat-reading.ts index 3d3c6ae048..4d1f9f98d5 100644 --- a/packages/client/ui-chat/src/client/chat/use-chat-reading.ts +++ b/packages/client/ui-chat/src/client/chat/use-chat-reading.ts @@ -88,13 +88,25 @@ export class ChatReading { } /** - * Follow submitted input on the active fold's animation clock. - * @returns whether this input joined a closing batch instead of starting another scroll animation. + * Follow the tail on the active fold's animation clock. + * @returns whether a fold batch owns the tail movement instead of a separate scroll animation. */ followInputFold(): boolean { this.cancelPending() - return this.viewport.followInputFold(this.follow, (landing) => { - this.commit(landing, true, this.viewport.latestTurn) + return this.viewport.followInputFold(this.follow) + } + + /** + * Let fold batches read follow intent and commit their tail landings. + * @returns a disposer that disconnects this reading policy from the viewport. + */ + bindFolds(): () => void { + return this.viewport.bindReader({ + follow: this.follow, + landedAtTail: (landing) => { + this.cancelPending() + this.commit(landing, true, this.viewport.latestTurn) + }, }) } @@ -263,6 +275,7 @@ export function useChatReading( const follow = useScrollFollow(state.followingTail, FOLLOW_THRESHOLD + 1) const [reading] = useState(() => new ChatReading(viewport, store, state, setState, follow)) useLayoutEffect(() => { reading.setStore(store) }, [reading, store]) + useLayoutEffect(() => reading.bindFolds(), [reading]) useLayoutEffect(() => () => { reading.dispose() }, [reading]) return { reading, state } } diff --git a/packages/client/ui-chat/src/client/chat/use-chat-scroll.ts b/packages/client/ui-chat/src/client/chat/use-chat-scroll.ts index 8f867de148..0fd20082e3 100644 --- a/packages/client/ui-chat/src/client/chat/use-chat-scroll.ts +++ b/packages/client/ui-chat/src/client/chat/use-chat-scroll.ts @@ -56,30 +56,9 @@ export function useChatScroll(input: ChatScrollInput): ChatScrollState { const content = useRef<{ input: ChatScrollInput; applied: ChatScrollInput | null; opened: boolean }>({ input, applied: null, opened: false, }) - // A fold larger than the new content leaves bottom room rather than reversing the scrollport. - // Growth consumes that room before automatic following advances toward newer content. - const cancelFollow = useRef<(() => void) | null>(null) - const clearPendingFollow = useCallback(() => { - cancelFollow.current?.() - cancelFollow.current = null - }, []) - const cancelPendingFollow = useCallback(() => { - viewport.motion.cancelInput() - clearPendingFollow() - // Retain a cancelled wait until this fold ends, so resize cannot reacquire follow ownership. - if (viewport.motion.foldActive()) { - cancelFollow.current = viewport.motion.onFoldIdle(() => { cancelFollow.current = null }) - } - }, [clearPendingFollow, viewport]) - const followAfterFold = useCallback(() => { - if (cancelFollow.current !== null || viewport.motion.inputActive()) return - reading.interruptFollow() - cancelFollow.current = viewport.motion.onFoldIdle(() => { - cancelFollow.current = null - viewport.reclaimBelow() - if (content.current.input.deferCompletedTurns) reading.followTail('smooth') - }) - }, [reading, viewport]) + // A fold batch owns layout and scrolling until it lands: at the tail for own input or a following + // reader, otherwise with the reader's content still. Growth consumes any room the landing installed. + const cancelPendingFollow = useCallback(() => { viewport.motion.cancelInput() }, [viewport]) const processContent = useCallback(() => { const current = content.current.input @@ -99,7 +78,6 @@ export function useChatScroll(input: ChatScrollInput): ChatScrollState { return } if (ownInput) { - clearPendingFollow() navigation.cancel() if (current.deferCompletedTurns && reading.followInputFold()) return reading.followTail(current.deferCompletedTurns ? 'smooth' : 'instant') @@ -115,10 +93,10 @@ export function useChatScroll(input: ChatScrollInput): ChatScrollState { || current.steeringId !== previous.steeringId || current.submissionId !== previous.submissionId if (tipChanged && reading.followingTail) { navigation.cancel() - if (current.deferCompletedTurns && viewport.motion.foldActive()) followAfterFold() - else reading.followTail() + if (current.deferCompletedTurns && reading.followInputFold()) return + reading.followTail() } else navigation.reconcile() - }, [viewport, reading, navigation, followAfterFold, clearPendingFollow]) + }, [reading, navigation]) useLayoutEffect(() => { const disconnectViewport = viewport.connect({ @@ -139,9 +117,7 @@ export function useChatScroll(input: ChatScrollInput): ChatScrollState { navigation.reconcile() return } - if (content.current.input.deferCompletedTurns && viewport.motion.foldActive()) { - if (!reading.pending && reading.followingTail) followAfterFold() - } else reading.onResize() + if (!content.current.input.deferCompletedTurns || !viewport.motion.foldActive()) reading.onResize() navigation.reconcile() }, }) @@ -150,13 +126,12 @@ export function useChatScroll(input: ChatScrollInput): ChatScrollState { processContent() }) return () => { - clearPendingFollow() disconnectViewport() disconnectReading() content.current.opened = false content.current.applied = null } - }, [viewport, reading, navigation, processContent, followAfterFold, cancelPendingFollow, clearPendingFollow]) + }, [viewport, reading, navigation, processContent, cancelPendingFollow]) useLayoutEffect(() => { const previous = content.current.input diff --git a/packages/client/ui-chat/src/client/chat/use-chat-viewport.ts b/packages/client/ui-chat/src/client/chat/use-chat-viewport.ts index ad6de91901..4c57b8e486 100644 --- a/packages/client/ui-chat/src/client/chat/use-chat-viewport.ts +++ b/packages/client/ui-chat/src/client/chat/use-chat-viewport.ts @@ -2,7 +2,7 @@ import { useLayoutEffect, useRef, useState, type RefObject } from 'react' import type { ChatScrollPosition } from '../contract/slots.ts' import type { ChatSnapshot } from '../contract/snapshot.ts' -import { createFlowMotion } from './flow-motion.ts' +import { createFlowMotion, type InputFoldDriver } from './flow-motion.ts' import { scrollMetrics, ScrollFollow, type ViewportMetrics } from './use-scroll-follow.ts' export type { ViewportMetrics } from './use-scroll-follow.ts' @@ -37,6 +37,14 @@ interface ViewportElements { readonly spacer: HTMLElement | null } +/** Reading policy that fold batches consult and report tail landings to. */ +export interface FoldReader { + /** Follow intent at batch start selects the tail landing; tail landings commit it. */ + readonly follow: ScrollFollow + /** @param landing - the actual geometry after a batch landed at the tail. */ + readonly landedAtTail: (landing: ViewportLanding) => void +} + interface PagingPosition { readonly row: HTMLElement readonly position: ChatScrollPosition @@ -54,9 +62,14 @@ export class ChatViewport { private turns: ReturnType = [] private observation: { top: number; landing: ViewportLanding | null } = { top: 0, landing: null } private paging: PagingPosition | null = null + private reader: FoldReader | null = null + /** The pending or running fold batch lands at the tail. */ + private tailRequested = false + /** Switches the running batch to a tail landing; null while no batch runs. */ + private retarget: (() => void) | null = null /** Transition state and row callbacks owned by this viewport's attachment. */ readonly motion = createFlowMotion( - (px) => { this.reserveBelow(px) }, + () => this.foldDriver(), (active) => { if (this.elements !== null) this.elements.scroller.style.overflowAnchor = active ? 'none' : '' }, @@ -151,114 +164,210 @@ export class ChatViewport { } /** - * Add room below the flow before rows above the reader close, so the scrollport never clamps: - * the closing gap pulls the content below it upward while everything above stays put. - * @param px - height about to leave the flow. + * Connect the reading policy that fold batches consult. + * @param reader - follow intent and the tail-landing commit. + * @returns a disposer that disconnects only this reader. */ - reserveBelow(px: number): void { - if (px <= 0) return - this.setSpacer(this.spacerHeight() + px) + bindReader(reader: FoldReader): () => void { + this.reader = reader + return () => { if (this.reader === reader) this.reader = null } } /** - * Join an input-caused fold and its tail movement on the fold's frame clock. - * @param follow - the existing reader's follow controller. - * @param settled - commits follow ownership after the final geometry is installed. - * @returns whether a closing batch owns this input. + * Land the pending or running fold batch at the tail instead of starting a separate scroll. + * @param follow - follow controller whose native animation yields to the batch. + * @returns whether a fold owns the tail movement. */ - followInputFold(follow: ScrollFollow, settled: (landing: ViewportLanding) => void): boolean { - const elements = this.elements - if (elements === null) return false + followInputFold(follow: ScrollFollow): boolean { + if (this.elements === null || !this.motion.foldActive()) return false this.interruptFollow(follow) + this.tailRequested = true + this.retarget?.() + return true + } + + /** + * Drive one fold batch on its frame clock. A tail landing ends at the floor, never above the reader's + * compensated position; a reader landing keeps the visible content still. Both install only the room + * the landing lacks. + * @returns the batch driver, or null while detached or without a reading policy. + */ + private foldDriver(): InputFoldDriver | null { + const elements = this.elements + const reader = this.reader + if (elements === null || reader === null) return null + const { follow } = reader + let tail = false let start = 0 + let base = 0 let target = 0 let removed = 0 - let initialHeight = 0 - let originalRoom = 0 - let finalRoom = 0 let growth = 0 - let base = 0 + let initialHeight = 0 + let finalRoom = 0 let previousColumnHeight = 0 let previousProgress = 0 let segmentProgress = 0 let segmentTop = 0 - return this.motion.followInput({ + // A visible row outside the batch measures spacing that snaps when the batch starts and ends. + let reference: HTMLElement | null = null + let referenceTop = 0 + let viewportTop = 0 + const screenTop = (element: HTMLElement): number => element.getBoundingClientRect().top - viewportTop + const aim = (): void => { + const finalHeight = initialHeight - removed + growth + const height = elements.scroller.clientHeight + target = tail ? Math.max(base, finalHeight - height, 0) : base + finalRoom = target === 0 ? 0 : Math.max(0, target + height - finalHeight) + } + const restart = (): void => { + segmentTop = elements.scroller.scrollTop + segmentProgress = previousProgress + } + const automatic = (cancelled: boolean): boolean => !(cancelled && tail) + const position = (progress: number, cancelled: boolean): number => { + if (!automatic(cancelled)) return elements.scroller.scrollTop + const remaining = segmentProgress === 1 ? 1 : (progress - segmentProgress) / (1 - segmentProgress) + return segmentTop + (target - segmentTop) * remaining + } + const room = (top: number, progress: number, cancelled: boolean): number => Math.max( + automatic(cancelled) ? finalRoom : 0, + top + elements.scroller.clientHeight - (initialHeight + (growth - removed) * progress), + ) + return { begin: (boxes) => { - const viewport = elements.scroller.getBoundingClientRect() - const metrics = scrollMetrics(elements.scroller) - start = metrics.top - originalRoom = this.spacerHeight() + tail = this.tailRequested || follow.active + this.tailRequested = false + viewportTop = elements.scroller.getBoundingClientRect().top + start = elements.scroller.scrollTop + const originalRoom = this.spacerHeight() initialHeight = Math.max(elements.column.offsetHeight, elements.scroller.scrollHeight - originalRoom) let above = 0 + const below: HTMLElement[] = [] for (const box of boxes) { const amount = box.height + box.marginTop + const top = box.element.getBoundingClientRect().top - box.marginTop if (box.closing) { removed += amount - const top = box.element.getBoundingClientRect().top - box.marginTop - above += Math.max(0, Math.min(amount, viewport.top - top)) - } else growth += amount + above += Math.max(0, Math.min(amount, viewportTop - top)) + if (top >= viewportTop) below.push(box.element) + } else { + growth += amount + if (top < viewportTop) above -= amount + } + } + if (!tail) reference = this.readingReference() + const referenceBefore = reference === null ? 0 : screenTop(reference) + elements.column.dataset.inputFold = '' + if (reference !== null) { + referenceTop = screenTop(reference) + above -= referenceTop - referenceBefore } - const finalHeight = initialHeight - removed + growth base = Math.max(0, start - above) segmentTop = start previousColumnHeight = elements.column.offsetHeight - target = Math.max(base, Math.max(0, finalHeight - metrics.height)) - finalRoom = target === 0 ? 0 : Math.max(0, target + metrics.height - finalHeight) - elements.column.dataset.inputFold = '' + aim() // This reserve only prevents an intermediate clamp; the final frame installs the exact deficit. this.setSpacer(Math.max(originalRoom, finalRoom, removed)) - follow.setFollowing(true) + if (tail) follow.setFollowing(true) + // A newer tail request also takes over a batch whose target a reading gesture cancelled. + this.retarget = () => { + tail = true + follow.setFollowing(true) + this.motion.resumeInput() + aim() + restart() + } + // Scrolling forward past closing rows shows them shrinking; faded rows would leave their space empty. + return new Set(tail && target > start ? below : []) }, prepare: (progress, cancelled) => { if (this.elements !== elements) return const added = elements.column.offsetHeight - previousColumnHeight if (added !== 0) { initialHeight += added - const finalHeight = initialHeight - removed + growth - target = Math.max(base, finalHeight - elements.scroller.clientHeight, 0) - finalRoom = target === 0 ? 0 : Math.max(0, target + elements.scroller.clientHeight - finalHeight) - segmentTop = elements.scroller.scrollTop - segmentProgress = previousProgress + aim() + restart() } - const remaining = segmentProgress === 1 ? 1 : (progress - segmentProgress) / (1 - segmentProgress) - const top = cancelled ? elements.scroller.scrollTop : segmentTop + (target - segmentTop) * remaining - const currentHeight = initialHeight + (growth - removed) * progress - const room = Math.max(cancelled ? 0 : finalRoom, top + elements.scroller.clientHeight - currentHeight) + const top = position(progress, cancelled) // Growing the room before shrinking boxes avoids a native clamp between writes. - this.setSpacer(Math.max(this.spacerHeight(), room)) + this.setSpacer(Math.max(this.spacerHeight(), room(top, progress, cancelled))) }, frame: (progress, cancelled) => { if (this.elements !== elements) return 0 - const remaining = segmentProgress === 1 ? 1 : (progress - segmentProgress) / (1 - segmentProgress) - const top = cancelled ? elements.scroller.scrollTop : segmentTop + (target - segmentTop) * remaining - const currentHeight = initialHeight + (growth - removed) * progress - const room = Math.max(cancelled ? 0 : finalRoom, top + elements.scroller.clientHeight - currentHeight) + const top = position(progress, cancelled) elements.scroller.scrollTop = top - this.setSpacer(room) - if (!cancelled) this.observation = { top: elements.scroller.scrollTop, landing: null } + this.setSpacer(room(top, progress, cancelled)) + const written = elements.scroller.scrollTop + // Earlier frames keep the reference still; only its final position is compared at finish. + if (reference !== null && progress === 1) referenceTop = screenTop(reference) + if (automatic(cancelled)) this.observation = { top: written, landing: null } previousColumnHeight = elements.column.offsetHeight previousProgress = progress - return elements.scroller.scrollTop - start + return written - start }, finish: (cancelled) => { + this.retarget = null + this.tailRequested = false if (this.elements !== elements) return - // Commit the final sibling margins while their input-fold transition override still applies. + // Commit the final sibling margins while their fold transition override still applies. void elements.column.offsetHeight delete elements.column.dataset.inputFold - if (cancelled) { + if (!tail || cancelled) { + if (!tail && reference?.isConnected === true && reference.closest('[hidden]') === null) { + // Committing the hidden rows settles their neighbours' spacing; keep the reference still. + const shift = screenTop(reference) - referenceTop + if (Math.abs(shift) > 0.5) { + const top = elements.scroller.scrollTop + shift + const content = elements.scroller.scrollHeight - this.spacerHeight() + this.setSpacer(Math.max(this.spacerHeight(), top + elements.scroller.clientHeight - content)) + elements.scroller.scrollTop = top + } + } const metrics = scrollMetrics(elements.scroller) const height = elements.scroller.scrollHeight - this.spacerHeight() this.setSpacer(metrics.top === 0 ? 0 : Math.max(0, metrics.top + metrics.height - height)) - this.invalidate() + if (tail) this.invalidate() + else this.observation = { top: elements.scroller.scrollTop, landing: null } return } this.setSpacer(finalRoom) const metrics = scrollMetrics(elements.scroller) const landing = { metrics, position: null, turn: this.latestTurn } this.observation = { top: metrics.top, landing } - settled(landing) + reader.landedAtTail(landing) }, - }) + } + } + + /** + * Find the first visible top-level row at or below the scrollport top that holds no batch box. + * Binary search over the ordered top-level rows reads O(log n) boxes. + * @returns the reference row, or null when every visible row folds. + */ + private readingReference(): HTMLElement | null { + const elements = this.elements + if (elements === null) return null + const viewport = elements.scroller.getBoundingClientRect() + // Slot anchors use display:contents: their children are the top-level rows. + const rows = (elements.column.querySelector(':scope > [data-slot="conversation.chat.flow"]') ?? elements.column).children + let low = 0 + let high = rows.length + while (low < high) { + const middle = (low + high) >>> 1 + if ((rows[middle] as Element).getBoundingClientRect().bottom > viewport.top) high = middle + else low = middle + 1 + } + for (let index = low; index < rows.length; index++) { + const row = rows[index] as HTMLElement + if (row.hasAttribute('hidden')) continue + const rect = row.getBoundingClientRect() + if (rect.top >= viewport.bottom) return null + if (rect.height === 0 || rect.bottom <= viewport.top) continue + if (row.hasAttribute('data-chat-motion') || row.querySelector('[data-chat-motion]') !== null) continue + return row + } + return null } /** Clear fold reservations after a display-mode change, allowing the scroll range to shrink. */ diff --git a/packages/client/ui-chat/src/client/conversation-nodes/README.i18n.yaml b/packages/client/ui-chat/src/client/conversation-nodes/README.i18n.yaml index ce5ab755d1..d40f82715f 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/README.i18n.yaml +++ b/packages/client/ui-chat/src/client/conversation-nodes/README.i18n.yaml @@ -33,8 +33,8 @@ en: b25bf00ce3a6a119 zh: 0ab47a4014ea5e1e /chat-conversation-node-rules/display-modes/collapse-timing: - en: 03d744dd1c92bdd3 - zh: 19804eaf3234632a + en: ad74c1a610fc50d2 + zh: 13de05ffedf63f0c /chat-conversation-node-rules/display-modes/group-title-rules: en: 2392290662bc69e8 zh: 8872d651c07a8f4c diff --git a/packages/client/ui-chat/src/client/conversation-nodes/README.md b/packages/client/ui-chat/src/client/conversation-nodes/README.md index bda19aba52..2efd2681af 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/README.md +++ b/packages/client/ui-chat/src/client/conversation-nodes/README.md @@ -140,7 +140,7 @@ Settings → General → Collapse work details appears directly below Work detai With On next message, the latest exchange keeps its running presentation after completion: Compact and Standard retain their process headers, and Detailed retains its flat process rows. An exchange starts with the Turn whose first Step admits human input and includes every later Turn until the next such Turn, so a Turn woken by a notice (a settled subagent, goal round, schedule, or job) leaves the preceding Turns of its exchange expanded. A pending ordinary transcript submission or a Turn that opens a newer exchange ends this exemption without clearing the reader's manual whole-Turn choice; a bare Turn start does not, because the host claims the waking input before it logs Step 1. Inner disclosures reset only when their outer process actually hides. Queued input and steering alone do not end it. The latest exchange of a reopened Session also receives the exemption, starting at the first loaded Turn when the loaded window holds no opening Turn; manual whole-Turn choices still take precedence. Switching back to On completion ends the exemption immediately. -When a new message triggers a fold, process details close and the transcript follows the new message in one upward movement, without a second scroll after the fold. Off-screen steps do not add bottom space; only the space needed to preserve the visible movement remains when the retained answer and new message cannot fill it. Content added during the fold updates its remaining movement. Reopening every closing row cancels that movement and releases obsolete bottom space. A reading gesture interrupts automatic following; finishing the fold does not resume it. +When a new message triggers a fold, process details close in one movement with no second scroll after the fold. If this client sent the message, or the reader was following the tail, the transcript lands on the new message; otherwise, such as a message from another tab or a queued message delivered after the previous Turn, the content the reader is looking at stays still. Off-screen steps do not add bottom space; only the space needed to preserve the visible movement remains when the retained answer and new message cannot fill it. Content added during the fold updates its remaining movement. Reopening every closing row cancels that movement and releases obsolete bottom space. A reading gesture interrupts automatic following; finishing the fold does not resume it. Verbose retains its flat process rows under either timing. Failed, stopped, and interleaved-input Turns retain their existing whole-Turn eligibility rules; delayed folding can still postpone Detailed's conversion from flat rows to historical groups. diff --git a/packages/client/ui-chat/src/client/conversation-nodes/README.zh.md b/packages/client/ui-chat/src/client/conversation-nodes/README.zh.md index 3eedbc8c9a..e121f62439 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/README.zh.md +++ b/packages/client/ui-chat/src/client/conversation-nodes/README.zh.md @@ -140,7 +140,7 @@ description: "Chat 过程分组与活动摘要的明细业务规则。" 选择「下次发送时收起」后,最新交互段完成时保留运行中的呈现:简洁和标准保留过程组头,详细保留平铺过程行。交互段从首个 Step 接收真人输入的轮次开始,包含之后的所有轮次,直到下一个这样的轮次,因此由通知(子 agent 完成、Goal Round、定时任务或后台作业)唤醒的轮次不会收起同一交互段里更早的轮次。普通 transcript 输入进入待发送状态,或出现开启更新交互段的轮次时,取消这项例外,但保留用户对整轮的手动开合选择;仅出现新轮次的开始不会取消,因为 Host 先认领唤醒输入,再记录 Step 1。只有外层过程实际隐藏时,才重置内部开合。排队输入和单独的 steering 不会取消例外。重新打开 Session 时,其最新交互段也享有例外,已加载窗口中没有开启轮次时从第一个已加载轮次算起;整轮的手动开合选择仍优先。切回「完成时收起」会立即取消例外。 -新消息触发折叠时,过程收起与跟随新消息合为一次向上运动,折叠结束后不再追加滚动。屏幕外的步骤不增加底部补白;只有保留的正文和新消息无法填满可见内容移动所需空间时,才保留必要差额。折叠期间新增的内容会更新剩余运动;重新展开全部正在收起的行时,取消该运动并释放失效的底部补白。阅读手势会中断自动跟随,折叠结束不会重新接管。 +新消息触发折叠时,过程收起为一次运动,折叠结束后不再追加滚动。消息由本客户端发出、或读者原本在跟尾时,对话落到新消息;其余情况(例如另一个标签页发来的消息,或上一轮结束后才投递的排队消息)下,读者正在看的内容保持不动。屏幕外的步骤不增加底部补白;只有保留的正文和新消息无法填满可见内容移动所需空间时,才保留必要差额。折叠期间新增的内容会更新剩余运动;重新展开全部正在收起的行时,取消该运动并释放失效的底部补白。阅读手势会中断自动跟随,折叠结束不会重新接管。 完全展开模式在两种时机下都保留平铺过程行。失败、停止和含插入输入的轮次沿用原有整轮折叠资格;延后折叠仍可推迟详细模式从平铺行转换为历史分组的时机。 diff --git a/packages/client/ui-chat/tests/chat-apply.client.spec.tsx b/packages/client/ui-chat/tests/chat-apply.client.spec.tsx index c449d30d89..6eced3135c 100644 --- a/packages/client/ui-chat/tests/chat-apply.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-apply.client.spec.tsx @@ -28,7 +28,7 @@ import type { QuotaNoticeInjected } from '@deepseek-ai/dsh-client-ui-chat/client import type { PerformanceUsageRowInjected } from '../src/client/settings/PerformanceUsageRow.tsx' import { CHAT_SETTINGS_NAMESPACE, type ChatSettings } from '../src/chat-settings.ts' import { ActivityPill, UsagePill } from '../src/client/chat/StatsPills.tsx' -import { createFlowMotion } from '../src/client/chat/flow-motion.ts' +import { createFlowMotion, type InputFoldDriver } from '../src/client/chat/flow-motion.ts' import { CHAT_FLOW_INJECT, CHAT_NODE_INJECT } from '../src/client/apply.ts' import { UserContent } from '../src/client/chat/UserContent.tsx' @@ -376,10 +376,11 @@ describe('Chat apply wiring', () => { type UseGroupAction = ReturnType type UseGroupHeaderAction = ReturnType const standard = { sessionId: SID } as Parameters[0] - const reserveFirst = vi.fn<(px: number) => void>() - const reserveSecond = vi.fn<(px: number) => void>() - const first = createFlowMotion(reserveFirst, () => {}) - const second = createFlowMotion(reserveSecond, () => {}) + // Each viewport asks only its own driver for a batch; null keeps the rows on CSS transitions. + const driverFirst = vi.fn<() => InputFoldDriver | null>(() => null) + const driverSecond = vi.fn<() => InputFoldDriver | null>(() => null) + const first = createFlowMotion(driverFirst, () => {}) + const second = createFlowMotion(driverSecond, () => {}) const useFirst = spec.inject.hooks.groupAction(standard, { motion: first }) const useSecond = spec.inject.hooks.groupAction(standard, { motion: second }) const useFirstHeader = spec.inject.hooks.groupHeaderAction(standard, { motion: first }) @@ -422,13 +423,13 @@ describe('Chat apply wiring', () => { expect(first.foldActive()).toBe(true) expect(second.foldActive()).toBe(false) await act(async () => { await Promise.resolve() }) - expect(reserveFirst).toHaveBeenCalledExactlyOnceWith(80) - expect(reserveSecond).not.toHaveBeenCalled() + expect(driverFirst).toHaveBeenCalledTimes(1) + expect(driverSecond).not.toHaveBeenCalled() view.rerender(rows(true, true)) expect(second.foldActive()).toBe(true) await act(async () => { await Promise.resolve() }) - expect(reserveFirst).toHaveBeenCalledExactlyOnceWith(80) - expect(reserveSecond).toHaveBeenCalledExactlyOnceWith(90) + expect(driverFirst).toHaveBeenCalledTimes(1) + expect(driverSecond).toHaveBeenCalledTimes(1) const finished = new Event('transitionend', { bubbles: true }) Object.defineProperty(finished, 'propertyName', { value: 'height' }) act(() => { firstRow.dispatchEvent(finished) }) diff --git a/packages/client/ui-chat/tests/chat-scroll.client.spec.tsx b/packages/client/ui-chat/tests/chat-scroll.client.spec.tsx index b212f91940..6f58eefd3c 100644 --- a/packages/client/ui-chat/tests/chat-scroll.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-scroll.client.spec.tsx @@ -92,10 +92,14 @@ function mountScroll(deferCompletedTurns = true, initial: Partial { motion.collapse(row, () => { row.hidden = true }) }) }, + /** Run the fold's frame clock to its end (a row outside a batch finishes on its transition). */ finishFold: () => { const event = new Event('transitionend', { bubbles: true }) Object.defineProperty(event, 'propertyName', { value: 'height' }) - act(() => { row.dispatchEvent(event) }) + act(() => { + row.dispatchEvent(event) + vi.advanceTimersByTime(200) + }) }, } } diff --git a/packages/client/ui-chat/tests/flow-motion-scope.client.spec.tsx b/packages/client/ui-chat/tests/flow-motion-scope.client.spec.tsx index 4d8cc20f45..258d2683d1 100644 --- a/packages/client/ui-chat/tests/flow-motion-scope.client.spec.tsx +++ b/packages/client/ui-chat/tests/flow-motion-scope.client.spec.tsx @@ -3,6 +3,7 @@ import { act, cleanup, render } from '@testing-library/react' import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { ChatViewport } from '../src/client/chat/use-chat-viewport.ts' +import { ScrollFollow } from '../src/client/chat/use-scroll-follow.ts' import { useSearchableHidden } from '../src/client/chat/searchable-hidden.ts' beforeEach(() => { vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) }) @@ -20,10 +21,11 @@ function viewport() { document.body.append(list) const owner = new ChatViewport() owner.attach(list, column) - return { list, row, spacer, owner, dispose: () => { owner.detach(); list.remove() } } + const unbind = owner.bindReader({ follow: new ScrollFollow(false, 25), landedAtTail: vi.fn() }) + return { list, row, spacer, owner, dispose: () => { unbind(); owner.detach(); list.remove() } } } -it('isolates fold anchoring and reservation between viewports', async () => { +it('isolates fold anchoring and batch room between viewports', async () => { const first = viewport() const second = viewport() try { diff --git a/packages/client/ui-chat/tests/flow-motion.client.spec.tsx b/packages/client/ui-chat/tests/flow-motion.client.spec.tsx index 5eb8c2f853..6e8b2ba544 100644 --- a/packages/client/ui-chat/tests/flow-motion.client.spec.tsx +++ b/packages/client/ui-chat/tests/flow-motion.client.spec.tsx @@ -5,21 +5,33 @@ import { StrictMode, useRef } from 'react' import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' import { createFlowMotion, hiddenOrCollapsing, motionEnabled, useFlowHidden, useMotionHidden, - type FlowMotion, type FlowMotionRows, + type FlowMotion, type FlowMotionRows, type InputFoldBox, type InputFoldDriver, } from '../src/client/chat/flow-motion.ts' const ownedRows = new Set() const subscriptions = new Set<() => void>() let motion: FlowMotion -let reserve: Mock<(px: number) => void> +let driver: Mock<() => InputFoldDriver | null> let changed: Mock<(active: boolean) => void> beforeEach(() => { vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) - reserve = vi.fn<(px: number) => void>() + // Without a driver, rows keep their CSS transitions; batch tests supply one. + driver = vi.fn<() => InputFoldDriver | null>(() => null) changed = vi.fn<(active: boolean) => void>() - motion = createFlowMotion(reserve, changed) + motion = createFlowMotion(driver, changed) }) + +function batchDriver(travelling: readonly HTMLElement[] = []) { + const calls = { + begin: vi.fn<(boxes: readonly InputFoldBox[]) => ReadonlySet>(() => new Set(travelling)), + prepare: vi.fn<(progress: number, cancelled: boolean) => void>(), + frame: vi.fn<(progress: number, cancelled: boolean) => number>(() => -20), + finish: vi.fn<(cancelled: boolean) => void>(), + } + driver.mockReturnValue(calls) + return calls +} afterEach(async () => { try { for (const unsubscribe of subscriptions) unsubscribe() @@ -104,7 +116,7 @@ describe('flow motion', () => { expect(motion.foldActive()).toBe(false) expect(vi.getTimerCount()).toBe(0) await Promise.resolve() - expect(reserve).not.toHaveBeenCalled() + expect(driver).not.toHaveBeenCalled() expectCleared(row) }) @@ -121,7 +133,7 @@ describe('flow motion', () => { expect(row.style.opacity).toBe('0') expect(row.style.height).toBe('0px') await Promise.resolve() - expect(reserve).toHaveBeenCalledExactlyOnceWith(80) + expect(driver).toHaveBeenCalledTimes(1) transitionEnd(row, 'opacity') transitionEnd(child, 'height') expect(done).not.toHaveBeenCalled() @@ -180,7 +192,7 @@ describe('flow motion', () => { expect(idle).toHaveBeenCalledTimes(1) }) - it('cancels before reserve delivery without hiding the row or retaining callbacks', async () => { + it('cancels before the batch starts without hiding the row or retaining callbacks', async () => { const row = rowWithHeight(80) const done = vi.fn() motion.collapse(row, done) @@ -190,14 +202,15 @@ describe('flow motion', () => { expect(hiddenOrCollapsing(row)).toBe(false) expectCleared(row) await Promise.resolve() - expect(reserve).not.toHaveBeenCalled() + expect(driver).not.toHaveBeenCalled() transitionEnd(row, 'height') vi.runAllTimers() expect(done).not.toHaveBeenCalled() expect(row.hasAttribute('hidden')).toBe(false) }) - it('reserves nested closing content once and subtracts newly revealed height', async () => { + it('drives non-nested closing and revealing boxes in document order on one batch', async () => { + const calls = batchDriver() const parent = rowWithHeight(180) const child = rowWithHeight(80, parent) const sibling = rowWithHeight(50) @@ -207,25 +220,120 @@ describe('flow motion', () => { motion.collapse(sibling, vi.fn()) motion.reveal(opening) await Promise.resolve() - expect(reserve).toHaveBeenCalledExactlyOnceWith(200) + expect(calls.begin).toHaveBeenCalledTimes(1) + expect(calls.begin.mock.calls[0]?.[0].map(box => [box.element, box.height, box.closing])).toEqual([ + [parent, 180, true], [sibling, 50, true], [opening, 30, false], + ]) + expect(motion.inputActive()).toBe(true) }) - it('does not rewrite a reopened row after its input-fold ownership is cancelled', async () => { + it('leaves a reveal-only commit to its CSS transition', async () => { + batchDriver() + const row = rowWithHeight(30) + motion.reveal(row) + await Promise.resolve() + expect(driver).not.toHaveBeenCalled() + expect(row.dataset.chatMotion).toBe('reveal') + vi.runAllTimers() + }) + + it('lands a batch on its frame clock and notifies idle after the final geometry', async () => { vi.useFakeTimers() + const calls = batchDriver() + const row = rowWithHeight(180) + const order: string[] = [] + const done = vi.fn(() => { order.push('hidden'); row.setAttribute('hidden', '') }) + calls.finish.mockImplementation(() => { order.push('finish') }) + motion.collapse(row, done) + subscriptions.add(motion.onFoldIdle(() => { order.push('idle') })) + await Promise.resolve() + vi.advanceTimersByTime(96) + expect(row.style.translate).toBe('0 -20px') + expect(Number.parseFloat(row.style.height)).toBeLessThan(180) + vi.advanceTimersByTime(400) + expect(order).toEqual(['hidden', 'finish', 'idle']) + expect(calls.finish).toHaveBeenCalledExactlyOnceWith(false) + expect(changed.mock.calls).toEqual([[true], [false]]) + expect(motion.inputActive()).toBe(false) + expectCleared(row) + }) + + it('keeps a travelling box opaque and in the flow while it shrinks', async () => { + vi.useFakeTimers() + const row = rowWithHeight(180) + const nested = rowWithHeight(60, row) + batchDriver([row]) + motion.collapse(nested, vi.fn()) + motion.collapse(row, vi.fn()) + await Promise.resolve() + expect(nested.style.height).toBe('60px') + expect(nested.style.transition).toBe('none') + vi.advanceTimersByTime(96) + expect(row.style.opacity).toBe('1') + expect(row.style.translate).toBe('') + expect(nested.style.opacity).toBe('1') + vi.advanceTimersByTime(400) + }) + + it('reports reader cancellation to the driver and lets a newer request resume the target', async () => { + vi.useFakeTimers() + const calls = batchDriver() + const row = rowWithHeight(180) + motion.cancelInput() + motion.collapse(row, vi.fn()) + await Promise.resolve() + motion.cancelInput() + vi.advanceTimersByTime(96) + expect(calls.frame.mock.calls.at(-1)?.[1]).toBe(true) + motion.resumeInput() + vi.advanceTimersByTime(16) + expect(calls.frame.mock.calls.at(-1)?.[1]).toBe(false) + motion.cancelInput() + vi.advanceTimersByTime(400) + expect(calls.finish).toHaveBeenCalledExactlyOnceWith(true) + }) + + it('finishes a batch synchronously under reduced motion', async () => { + vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: true }))) + const calls = batchDriver() + const row = rowWithHeight(180) + const done = vi.fn() + motion.collapse(row, done) + await Promise.resolve() + expect(done).toHaveBeenCalledTimes(1) + expect(calls.finish).toHaveBeenCalledExactlyOnceWith(false) + }) + + it('does not rewrite a reopened row after its batch is cancelled', async () => { + vi.useFakeTimers() + const calls = batchDriver() const row = rowWithHeight(180) const done = vi.fn() motion.collapse(row, done) - motion.followInput({ begin: vi.fn(), frame: () => -20, finish: vi.fn() }) await Promise.resolve() vi.advanceTimersByTime(96) motion.cancelMotion(row) expectCleared(row) + expect(calls.finish).toHaveBeenCalledExactlyOnceWith(true) vi.advanceTimersByTime(400) expectCleared(row) expect(row.style.translate).toBe('') expect(done).not.toHaveBeenCalled() }) + it('cancels a running batch with its owner', async () => { + vi.useFakeTimers() + const calls = batchDriver() + const row = rowWithHeight(180) + motion.collapse(row, vi.fn()) + await Promise.resolve() + vi.advanceTimersByTime(32) + motion.clear() + expect(calls.finish).toHaveBeenCalledExactlyOnceWith(true) + expect(motion.inputActive()).toBe(false) + expectCleared(row) + }) + it('clears pending reservations and idle work with its owner', async () => { const row = rowWithHeight(80) const idle = vi.fn() @@ -233,7 +341,7 @@ describe('flow motion', () => { motion.onFoldIdle(idle) motion.clear() await Promise.resolve() - expect(reserve).not.toHaveBeenCalled() + expect(driver).not.toHaveBeenCalled() expect(idle).not.toHaveBeenCalled() expectCleared(row) }) @@ -292,7 +400,7 @@ describe('flow motion', () => { act(() => { vi.advanceTimersByTime(100) }) view.rerender(

) expect(changed.mock.calls).toEqual([[true]]) - expect(reserve).toHaveBeenCalledExactlyOnceWith(80) + expect(driver).toHaveBeenCalledTimes(1) act(() => { vi.advanceTimersByTime(140) }) expect(row.getAttribute('hidden')).toBe('until-found') expect(changed.mock.calls).toEqual([[true], [false]]) @@ -347,7 +455,7 @@ describe.each([ expect(motion.foldActive()).toBe(false) expectCleared(row) await Promise.resolve() - expect(reserve).not.toHaveBeenCalled() + expect(driver).not.toHaveBeenCalled() act(() => { vi.runAllTimers() }) transitionEnd(row, 'height') expect(row.hasAttribute('hidden')).toBe(false) diff --git a/packages/client/ui-chat/tests/fold-follow.client.spec.ts b/packages/client/ui-chat/tests/fold-follow.client.spec.ts index 08eab35fcd..a39bc06716 100644 --- a/packages/client/ui-chat/tests/fold-follow.client.spec.ts +++ b/packages/client/ui-chat/tests/fold-follow.client.spec.ts @@ -20,7 +20,31 @@ afterEach(async () => { } }) -function fixture() { +const frames = new Map() +let frameClock = 0 +let nextFrame = 0 + +/** Run queued animation frames until the batch stops requesting more. */ +function runFrames(): void { + for (let guard = 0; frames.size > 0 && guard < 100; guard++) { + const pending = [...frames.values()] + frames.clear() + frameClock += 16 + for (const callback of pending) callback(frameClock) + } +} + +/** + * Earlier content, one closing row, and later content in a 400px scrollport. + * @param layout - heights above and below the row and the starting scroll position. + */ +function fixture(layout: { earlier: number; later: number; top: number; following: boolean }) { + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + const id = ++nextFrame + frames.set(id, callback) + return id + }) + vi.stubGlobal('cancelAnimationFrame', (id: number) => { frames.delete(id) }) const list = document.createElement('div') const column = document.createElement('div') const row = document.createElement('div') @@ -29,8 +53,8 @@ function fixture() { column.append(row) list.append(column, spacer) document.body.append(list) - let contentHeight = 2_400 - let top = 2_000 + let contentHeight = layout.earlier + 1_800 + layout.later + let top = layout.top const room = () => Number.parseFloat(spacer.style.height) || 0 const floor = () => Math.max(0, contentHeight + room() - 400) const scrollTo = vi.fn((options: ScrollToOptions) => { @@ -45,14 +69,20 @@ function fixture() { set: (value: number) => { top = Math.max(0, Math.min(value, floor())) }, }, scrollTo: { value: scrollTo }, + getBoundingClientRect: { value: () => ({ top: 0, bottom: 400, height: 400 }) }, + }) + Object.defineProperty(column, 'offsetHeight', { get: () => contentHeight }) + Object.defineProperties(row, { + offsetHeight: { value: 1_800 }, + getBoundingClientRect: { value: () => ({ top: layout.earlier - top, bottom: layout.earlier - top + 1_800, height: 1_800 }) }, }) - Object.defineProperty(row, 'offsetHeight', { value: 1_800 }) const viewport = new ChatViewport() viewport.attach(list, column) - const follow = new ScrollFollow(true, 25) + const follow = new ScrollFollow(layout.following, 25) const reading = new ChatReading(viewport, { read: () => null, save: vi.fn() }, { - initialized: true, followingTail: true, activeTurn: 1, + initialized: true, followingTail: layout.following, activeTurn: 1, }, vi.fn(), follow) + const unbind = reading.bindFolds() viewport.connect({ scroll: reading.onScroll, scrollEnd: reading.onScrollEnd, @@ -61,21 +91,22 @@ function fixture() { intent: () => { reading.interruptFollow() }, }) disposers.add(() => { + unbind() reading.dispose() viewport.detach() list.remove() + frames.clear() }) return { list, spacer, row, viewport, follow, reading, scrollTo, grow: (height: number) => { contentHeight = height }, close: async () => { - viewport.motion.collapse(row, () => { row.hidden = true }) + viewport.motion.collapse(row, () => { + row.hidden = true + contentHeight -= 1_800 + }) await Promise.resolve() - contentHeight -= 1_800 - expect(list.scrollTop).toBe(2_000) - const event = new Event('transitionend') - Object.defineProperty(event, 'propertyName', { value: 'height' }) - row.dispatchEvent(event) + runFrames() }, scroll: (value: number) => { list.scrollTop = value @@ -84,59 +115,69 @@ function fixture() { } } -it('retains fold room and lets content growth consume it without reversing the scrollport', async () => { - const h = fixture() +/** The row is partly on screen: content above the reading line never slides back, so room remains. */ +const VISIBLE_ROW = { earlier: 600, later: 0, top: 2_000, following: true } + +it('lands a following reader at the tail and lets content growth consume the room it keeps', async () => { + const h = fixture(VISIBLE_ROW) await h.close() - h.viewport.reclaimBelow() - h.reading.followTail('smooth') - expect(h.list.scrollTop).toBe(2_000) - expect(h.spacer.style.height).toBe('1800px') + expect(h.list.scrollTop).toBe(600) + expect(h.spacer.style.height).toBe('400px') + expect(h.reading.followingTail).toBe(true) expect(h.scrollTo).not.toHaveBeenCalled() + h.grow(800) + h.viewport.reclaimBelow() + h.reading.onResize() + expect(h.list.scrollTop).toBe(600) + expect(h.spacer.style.height).toBe('200px') h.grow(1_000) h.viewport.reclaimBelow() h.reading.onResize() - expect(h.list.scrollTop).toBe(2_000) - expect(h.spacer.style.height).toBe('1400px') - h.grow(2_400) - h.viewport.reclaimBelow() - h.reading.onResize() - expect(h.list.scrollTop).toBe(2_000) + expect(h.list.scrollTop).toBe(600) expect(h.spacer.style.height).toBe('') - h.grow(2_520) + h.grow(1_120) h.reading.onResize() - expect(h.list.scrollTop).toBe(2_120) + expect(h.list.scrollTop).toBe(720) expect(h.follow.animating).toBe(false) }) +it('keeps a reader below folding rows on the same content without room or follow', async () => { + const h = fixture({ earlier: 100, later: 500, top: 2_000, following: false }) + await h.close() + expect(h.list.scrollTop).toBe(200) + expect(h.spacer.style.height).toBe('') + expect(h.reading.followingTail).toBe(false) + expect(h.reading.pending).toBe(false) +}) + it('keeps manual off-bottom ownership and reclaims only space below the reader', async () => { - const h = fixture() + const h = fixture(VISIBLE_ROW) await h.close() - h.reading.followTail('smooth') - h.scroll(1_000) h.list.dispatchEvent(new Event('wheel')) - h.scroll(900) + h.scroll(300) vi.advanceTimersByTime(500) expect(h.reading.followingTail).toBe(false) - expect(h.spacer.style.height).toBe('700px') + expect(h.spacer.style.height).toBe('100px') h.viewport.reclaimBelow() h.reading.onResize() - expect(h.list.scrollTop).toBe(900) - expect(h.spacer.style.height).toBe('700px') + runFrames() + expect(h.list.scrollTop).toBe(300) + expect(h.spacer.style.height).toBe('100px') expect(h.follow.animating).toBe(false) }) -it('retains existing fold room when reduced-motion following has no forward distance', async () => { - const h = fixture() - await h.close() +it('lands immediately under reduced motion', async () => { vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: true }))) - h.reading.followTail('smooth') - expect(h.list.scrollTop).toBe(2_000) - expect(h.spacer.style.height).toBe('1800px') + const h = fixture(VISIBLE_ROW) + h.viewport.motion.collapse(h.row, () => { h.row.hidden = true; h.grow(600) }) + await Promise.resolve() + expect(h.list.scrollTop).toBe(600) + expect(h.spacer.style.height).toBe('400px') expect(h.follow.animating).toBe(false) }) it.each([1_999.5, 2_000, 2_000.5])('restores follow immediately at a rounded floor sample (%s)', (top) => { - const h = fixture() + const h = fixture(VISIBLE_ROW) h.reading.pauseFollowing() h.reading.onScroll({ metrics: { top, floor: 2_000, height: 400 }, movedByReader: true }) expect(h.reading.followingTail).toBe(true) @@ -144,7 +185,7 @@ it.each([1_999.5, 2_000, 2_000.5])('restores follow immediately at a rounded flo }) it.each([1_999, 2_001])('keeps movement at least one pixel from the floor pending (%s)', (top) => { - const h = fixture() + const h = fixture(VISIBLE_ROW) h.reading.pauseFollowing() h.reading.onScroll({ metrics: { top, floor: 2_000, height: 400 }, movedByReader: true }) expect(h.reading.followingTail).toBe(false) @@ -152,7 +193,7 @@ it.each([1_999, 2_001])('keeps movement at least one pixel from the floor pendin }) it('does not measure geometry to interrupt an idle follow controller', () => { - const h = fixture() + const h = fixture(VISIBLE_ROW) const reads = (['clientHeight', 'scrollHeight', 'scrollTop'] as const).map(property => vi.spyOn(h.list, property, 'get')) h.viewport.interruptFollow(h.follow) diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 7713ce057f..de1c5e1f59 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -21,8 +21,8 @@ en: 9912b286384c41d7 zh: 3e994887c1948677 /deepseek-ai-dsh-client-ui-conversation/shell-and-standard-props: - en: ddabf2f192114e62 - zh: 31370ed6d5149409 + en: aab84874b0a00ea5 + zh: 642bacaf76385e83 /deepseek-ai-dsh-client-ui-conversation/temporary-composer-entries: en: bb1244310f5c0382 zh: 824b84077432b559 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 11c7490a7f..627a1872ae 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -102,7 +102,7 @@ Clicking unused space inside an editable composer preserves its current caret or Each composer attempt carries its original occurrence time, gesture, delivery intent, and Session model/run-state snapshot through asynchronous command arbitration. The ordinary-message notification fires once before reference serialization, including attachment-only submissions; handled or claimed commands do not notify. Snapshot and notification failures cannot block sending. -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`; Session retires the echo once at its display handoff, correlated by that `rpcId`. 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. Where the browser shell exposes `__DSH_HOST_PATHS__` (the Desktop application), dropped or pasted folders and dropped, picked, or pasted non-image files with a real path become `@path` chips; images keep uploading. Drag-and-drop and paste identify directories through the browser entry API; when that API is absent or returns no entry, pasted items retain ordinary file handling. The file picker cannot select directories. References require the `ui-reference` plugin and the original paths to remain readable by the model's file tools. Paths inside the workspace are relative; others remain absolute. The whole batch is validated before insertion, retains source order and selected text, and uses whitespace-separated, closed-quote mentions. A browser without the bridge refuses dropped or pasted folders; a Desktop folder without a reported path is rejected separately. Continuable subagents accept pasted and dropped PNG, JPEG, WebP, and GIF images with the same projected limits; a batch containing a generic file, unsupported image format, or directory is refused before intake. They skip local echoes because their transport does not preserve the browser request id. +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`; Session retires the echo once at its display handoff, correlated by that `rpcId`. 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. Where the browser shell exposes `__DSH_HOST_PATHS__` (the Desktop application), dropped or pasted folders and dropped, picked, or pasted non-image files with a real path become `@path` chips; images keep uploading. Drag-and-drop and paste identify directories through the browser entry API; when that API is absent or returns no entry, pasted items retain ordinary file handling. The file picker cannot select directories. References require the `ui-reference` plugin and the original paths to remain readable by the model's file tools. Paths inside the workspace are relative; others remain absolute. The whole batch is validated before insertion, retains source order and selected text, and uses whitespace-separated, closed-quote mentions. A browser without the bridge refuses dropped or pasted folders; a Desktop folder without a reported path is rejected separately. Continuable subagents accept pasted and dropped PNG, JPEG, WebP, and GIF images with the same projected limits; a batch containing a generic file, unsupported image format, or directory is refused before intake. Their sends register the same local echo; the continuation request carries the echo's `requestId`, which the Host records as the child message's `rpcId`. Queued submission echoes show “Sending…” beside disabled edit, remove, and steer buttons; a collapsed dock keeps the sending status in its header. A matching Host queue row replaces the echo and enables each action according to its normal text-content and running-state requirements. Prompt acknowledgement alone does not enable queue actions. A failed submission removes its echo and displays an error; the composer restores the failed draft when it is empty or still contains the previous automatic restoration, preserving subsequently typed text. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index cf6a73df3b..66e5a2865b 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -102,7 +102,7 @@ Session 首次绑定或缓存的 Session 成为 current 时,shell 会在渲染 每次输入框提交都携带原始发生时间、操作来源、投递意图以及会话模型和运行状态快照,贯穿异步命令裁决。普通消息通知在引用序列化前发出一次,纯附件提交也遵循这一规则;已处理或认领的命令不发出通知。快照读取与通知消费异常不能阻断发送。 -默认发送采用乐观提交:Enter 在同一事务里清空草稿、occurrence 表和撤销历史,composer 保持 `plain`,发送作为 detached attempt 运行,发送期间可以继续输入和提交。`sendSession` 在序列化之前用投递模式注册 Session 提交回显(`session.beginSubmission`),并在 `pendingSubmissions` 中保留图片与文件的选择顺序;Session 根据该模式与当前运行状态推导位置,因此空闲发送进入 transcript(文本记录),繁忙时 Queue 进入 QueueDock,繁忙时 Steer 进入 pending-steering 区域。随后让出一帧,图片经浏览器原生 `FileReader` data-URL 路径编码,文件则引用已暂存凭证。命令提交也用同一凭证表示通用文件,因此发送 `/goal` 或 `/plan` 时不会再次读取这些浏览器文件。提示词复用提交 `requestId`;Session 按同一 `rpcId` 关联展示接管,并仅退休回显一次。多个并发发送失败时,在用户编辑还原内容之前按提交顺序合并还原;命令提交保持冻结的 `submitting` 阶段。Detached attempt 持有附件 id,直到 admission 完成或 Session scope 销毁。回显以 observed 退休时,durable 图片缓存立即公开每个预览 URL,读取 admitted 附件后用规范化 URL 替换预览,并在各 URL 停止使用后撤销,同时释放文件卡。选中的通用文件进入同一个先进先出的后台上传队列;`maxConcurrentFileUploads` 默认允许两个 Worker transport 同时运行,Conversation 服务在切换 Session 时继续持有排队和运行中的传输操作及字节进度,移除草稿会跳过排队中的传输或中止正在运行的传输。浏览器 shell 暴露 `__DSH_HOST_PATHS__` 时(桌面应用),拖入或粘贴的文件夹以及拖入、选择或粘贴的带真实路径的非图片文件会成为 `@路径` chip;图片仍然上传。拖放和粘贴通过浏览器 entry API 识别目录;该 API 不可用或没有返回 entry 时,粘贴项沿用普通文件处理。文件选择器不能选择目录。引用需要启用 `ui-reference` 插件,原路径也必须仍可由模型的文件工具读取。工作区内的路径使用相对形式,其他路径保留绝对形式。整批文件先校验再插入,保留来源顺序和已选中的文字,引用之间有空白分隔,含空格的路径使用闭合引号。没有该桥的浏览器会拒绝拖入或粘贴的文件夹,桌面端无法获取文件夹路径时单独报错。continuable 子代理允许粘贴和拖入 PNG、JPEG、WebP、GIF 图片,并沿用相同的投影限制;包含通用文件、不支持的图片格式或目录的批次会在进入草稿前整批拒绝。它们不创建本地回显,因为其 transport 不保留浏览器 request id。 +默认发送采用乐观提交:Enter 在同一事务里清空草稿、occurrence 表和撤销历史,composer 保持 `plain`,发送作为 detached attempt 运行,发送期间可以继续输入和提交。`sendSession` 在序列化之前用投递模式注册 Session 提交回显(`session.beginSubmission`),并在 `pendingSubmissions` 中保留图片与文件的选择顺序;Session 根据该模式与当前运行状态推导位置,因此空闲发送进入 transcript(文本记录),繁忙时 Queue 进入 QueueDock,繁忙时 Steer 进入 pending-steering 区域。随后让出一帧,图片经浏览器原生 `FileReader` data-URL 路径编码,文件则引用已暂存凭证。命令提交也用同一凭证表示通用文件,因此发送 `/goal` 或 `/plan` 时不会再次读取这些浏览器文件。提示词复用提交 `requestId`;Session 按同一 `rpcId` 关联展示接管,并仅退休回显一次。多个并发发送失败时,在用户编辑还原内容之前按提交顺序合并还原;命令提交保持冻结的 `submitting` 阶段。Detached attempt 持有附件 id,直到 admission 完成或 Session scope 销毁。回显以 observed 退休时,durable 图片缓存立即公开每个预览 URL,读取 admitted 附件后用规范化 URL 替换预览,并在各 URL 停止使用后撤销,同时释放文件卡。选中的通用文件进入同一个先进先出的后台上传队列;`maxConcurrentFileUploads` 默认允许两个 Worker transport 同时运行,Conversation 服务在切换 Session 时继续持有排队和运行中的传输操作及字节进度,移除草稿会跳过排队中的传输或中止正在运行的传输。浏览器 shell 暴露 `__DSH_HOST_PATHS__` 时(桌面应用),拖入或粘贴的文件夹以及拖入、选择或粘贴的带真实路径的非图片文件会成为 `@路径` chip;图片仍然上传。拖放和粘贴通过浏览器 entry API 识别目录;该 API 不可用或没有返回 entry 时,粘贴项沿用普通文件处理。文件选择器不能选择目录。引用需要启用 `ui-reference` 插件,原路径也必须仍可由模型的文件工具读取。工作区内的路径使用相对形式,其他路径保留绝对形式。整批文件先校验再插入,保留来源顺序和已选中的文字,引用之间有空白分隔,含空格的路径使用闭合引号。没有该桥的浏览器会拒绝拖入或粘贴的文件夹,桌面端无法获取文件夹路径时单独报错。continuable 子代理允许粘贴和拖入 PNG、JPEG、WebP、GIF 图片,并沿用相同的投影限制;包含通用文件、不支持的图片格式或目录的批次会在进入草稿前整批拒绝。它们的发送同样注册本地回显;continuation 请求携带回显的 `requestId`,Host 把它记录为子会话消息的 `rpcId`。 排队提交的本地回显在禁用的编辑、删除、插话按钮旁显示“发送中…”;折叠后的队列在标题栏保留发送状态。匹配的 Host 队列行替换回显后,各操作按原有的纯文本内容和运行状态要求启用。仅收到提示词确认不会启用队列操作。提交失败会移除回显并显示错误;输入框为空或仍保留上一次自动恢复的内容时,composer 恢复失败草稿,保留用户随后输入的文字。 diff --git a/packages/client/ui-conversation/src/client/service.ts b/packages/client/ui-conversation/src/client/service.ts index 2ef2309ccf..fc76701be6 100644 --- a/packages/client/ui-conversation/src/client/service.ts +++ b/packages/client/ui-conversation/src/client/service.ts @@ -261,13 +261,6 @@ export class ConversationController extends Service implements IConversation { ? { type: 'image' as const, ...await this.encodeImage(attachment.file) } : { type: 'file' as const, receiptId: uploadFor(attachment).receiptId }), ) - const snapshot = session.getSnapshot() - if (snapshot.subagent !== null) { - const uploaded = await serializeAttachments() - const content = [...uploaded, ...(text === '' ? [] : [{ type: 'text' as const, text }])] - const result = await session.prompt(content, mode, signal) - return result.ok ? { kind: 'success' } : { kind: 'error' } - } let finishRetirement: ((retirement: PendingSubmissionRetirement) => void) | undefined const retirement = attachments.length === 0 ? undefined diff --git a/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts b/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts index 6665284ae4..d784e37b90 100644 --- a/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts +++ b/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts @@ -755,7 +755,7 @@ describe('sendSession submission echo', () => { await b.runtime.dispose() }) - it('sends a subagent continuation without registering an unobservable echo', async () => { + it('echoes a subagent continuation and sends it under the echo request id', async () => { const b = await bench() const session = b.runtime.sessions.binding('s1')!.session const snapshot = session.getSnapshot() @@ -768,8 +768,11 @@ describe('sendSession submission echo', () => { }) const prompt = vi.spyOn(session, 'prompt').mockResolvedValue({ ok: true, value: { accepted: true } }) await expect(b.root.sendSession(session, '继续', [], 'queue')).resolves.toEqual({ kind: 'success' }) - expect(beginSubmission).not.toHaveBeenCalled() - expect(prompt).toHaveBeenCalledWith([{ type: 'text', text: '继续' }], 'queue', undefined) + expect(beginSubmission).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ mode: 'queue', text: '继续' })) + const echo = beginSubmission.mock.results[0] + const requestId = echo?.type === 'return' ? echo.value.requestId : undefined + expect(requestId).toEqual(expect.any(String)) + expect(prompt).toHaveBeenCalledWith([{ type: 'text', text: '继续' }], 'queue', undefined, requestId) await b.runtime.dispose() }) }) diff --git a/tsconfig.host.json b/tsconfig.host.json index ec1bcc2316..cd77f78880 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -10,6 +10,7 @@ "include": [ "apps/web/tests/collapse-timing.e2e.ts", "apps/web/tests/notice-wake-tail.e2e.ts", + "apps/web/tests/fold-blank-matrix.e2e.ts", "apps/web/tests/chat-flow-layout.e2e.ts", "apps/web/product-isolation.ts", "apps/web/katex-fonts.ts", From 25a69605adfb5a2c77024fbcece3299cbe753a40 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 14 Oct 2026 00:30:28 +0800 Subject: [PATCH 27/35] fix(ui-chat): address review on single fold motion - A tail request counts only while a fold batch runs or is scheduled, so rows still closing on CSS transitions cannot strand own input. - A reader-content-still landing adds the reader's own scrolling between frames to the position it keeps still. - A newer tail request that takes over a running batch recomputes which closing rows travel with the content. - Symmetric cancel and resume guards; Set-based travelling bookkeeping. - The blank-area matrix holds the subagent continuation route for child sends, writes per-frame evidence only on failure, and labels the parent message case as a no-fold control. - The group-tree note now links the single fold motion for input without an echo on this page. --- ...-10-11-chat-group-tree-exchanges.i18n.yaml | 8 ++-- .../2026-10-11-chat-group-tree-exchanges.md | 4 +- ...2026-10-11-chat-group-tree-exchanges.zh.md | 4 +- ...26-10-13-chat-single-fold-motion.i18n.yaml | 8 ++-- .../2026-10-13-chat-single-fold-motion.md | 8 ++-- .../2026-10-13-chat-single-fold-motion.zh.md | 8 ++-- apps/web/tests/fold-blank-matrix.e2e.ts | 36 ++++++++++++---- .../ui-chat/src/client/chat/flow-motion.ts | 43 +++++++++++++------ .../src/client/chat/use-chat-viewport.ts | 30 +++++++++++-- .../ui-chat/tests/flow-motion.client.spec.tsx | 38 ++++++++++++++++ .../ui-chat/tests/fold-follow.client.spec.ts | 21 +++++++++ 11 files changed, 162 insertions(+), 46 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-10-11-chat-group-tree-exchanges.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-11-chat-group-tree-exchanges.i18n.yaml index 97834be19a..b34903faef 100644 --- a/.agents/notes/implemented/architecture/2026-10-11-chat-group-tree-exchanges.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-10-11-chat-group-tree-exchanges.i18n.yaml @@ -15,11 +15,11 @@ en: db8d77cd773ad646 zh: ab2f03b8fc58e952 /agent-note-chat-group-tree-positions-and-exchanges/alternatives-considered: - en: a2b48738666a7d5f - zh: 53377f4f1109af26 + en: fd9152c3f5edb035 + zh: 259337cdcc5969a5 /agent-note-chat-group-tree-positions-and-exchanges/consequences: - en: f51678f9f30bad59 - zh: b525e9ed10b9928c + en: 2a98bb21bb1315d1 + zh: be4a320e9b427a79 /agent-note-chat-group-tree-positions-and-exchanges/testing: en: e2e3982718e16ef4 zh: 4f3802946e51c55c diff --git a/.agents/notes/implemented/architecture/2026-10-11-chat-group-tree-exchanges.md b/.agents/notes/implemented/architecture/2026-10-11-chat-group-tree-exchanges.md index c30483a404..0fffb84e71 100644 --- a/.agents/notes/implemented/architecture/2026-10-11-chat-group-tree-exchanges.md +++ b/.agents/notes/implemented/architecture/2026-10-11-chat-group-tree-exchanges.md @@ -33,7 +33,7 @@ The host claims the waking input right after `turn/start`, assembles the system **Declare a business role (`input`, `notice`) on Nodes.** That places business semantics in the grouping mechanism; group-tree positions describe structure only, and each business Definition chooses its position. -**Correct the scroll room for folds not caused by own input.** Notices should not fold at all, and correcting the room would not remove that unnecessary fold; it remains a residual issue for human input from another tab. +**Correct the scroll room for folds not caused by own input.** Notices should not fold at all, and correcting the room would not remove that unnecessary fold. Folds caused by human input without an echo on this page are handled by [one fold motion for collapsing on the next message](2026-10-13-chat-single-fold-motion.md). **Read Turn inputs at `turn`.** Terminal errors, max-token notices, and the completed-turn footer also stand outside the process disclosure, so reading `turn` would treat them as opening input; Turn inputs need their own position. @@ -45,7 +45,7 @@ The host claims the waking input right after `turn/start`, assembles the system - External Definitions place their Chat rows through `groupTree`. Apart from the two `assistant-step` rules, ui-chat names no kind when it groups, folds, or detects Turn inputs, and built-in kinds keep the placement their former kind lists gave them. - Under On next message, a long run of notice-woken Turns (up to 31 goal-mode Turns in sampled Sessions) stays expanded until the human types again. - When the loaded window holds no opening Turn, every loaded Turn belongs to the latest exchange. -- Human input from another tab folds when its `user/message` arrives, about 200 ms later than the former `turn/start` fold; that fold still uses generic room and is unchanged here. +- Human input from another tab folds when its `user/message` arrives, about 200 ms later than the former `turn/start` fold; its landing follows [one fold motion for collapsing on the next message](2026-10-13-chat-single-fold-motion.md). ## Testing diff --git a/.agents/notes/implemented/architecture/2026-10-11-chat-group-tree-exchanges.zh.md b/.agents/notes/implemented/architecture/2026-10-11-chat-group-tree-exchanges.zh.md index bd3375f5dc..395faed58b 100644 --- a/.agents/notes/implemented/architecture/2026-10-11-chat-group-tree-exchanges.zh.md +++ b/.agents/notes/implemented/architecture/2026-10-11-chat-group-tree-exchanges.zh.md @@ -33,7 +33,7 @@ Host 在 `turn/start` 之后立即认领唤醒输入,然后组装系统提示 **节点声明业务角色(`input`、`notice`)。** 这会把业务语义放进分组机制;分组树位置只描述结构,业务 Definition 自行决定位置。 -**修正非自身输入折叠时的滚动补白。** 通知本来就不应触发折叠,修补白不能消除这次不必要的折叠;它仍是另一个标签页里真人输入的残留问题。 +**修正非自身输入折叠时的滚动补白。** 通知本来就不应触发折叠,修补白不能消除这次不必要的折叠。本页没有回显的真人输入引起的折叠,由[「下条消息时收起」只用一条折叠动画](2026-10-13-chat-single-fold-motion.zh.md)处理。 **在 `turn` 位置上判断轮次输入。** 终止错误、最大 token 提示和已完成轮次页脚同样在整轮折叠之外,读 `turn` 会把它们误当作开场输入;轮次输入需要自己的位置。 @@ -45,7 +45,7 @@ Host 在 `turn/start` 之后立即认领唤醒输入,然后组装系统提示 - 外部 Definition 通过 `groupTree` 放置自己的 Chat 行。除 `assistant-step` 的两条规则外,ui-chat 在分组、折叠和判断轮次输入时不点名任何 kind,内置 kind 保持原 kind 名单给出的放置。 - 「下次发送时收起」下,一长串通知唤醒的 Turn(抽样会话中 Goal 模式最长 31 轮)会一直展开,直到真人再次输入。 - 已加载窗口里没有开启 Turn 时,所有已加载 Turn 都属于最新交互段。 -- 另一个标签页里的真人输入在它的 `user/message` 到达时才折叠,比原来在 `turn/start` 时晚约 200 ms;这类折叠仍使用通用补白,本次没有修改。 +- 另一个标签页里的真人输入在它的 `user/message` 到达时才折叠,比原来在 `turn/start` 时晚约 200 ms;其落点按[「下条消息时收起」只用一条折叠动画](2026-10-13-chat-single-fold-motion.zh.md)决定。 ## Testing diff --git a/.agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.i18n.yaml index 0f2e789bf6..fede5ed837 100644 --- a/.agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.i18n.yaml @@ -6,8 +6,8 @@ en: 45594545dabfaa78 zh: a1eac669647b2bac /agent-note-one-fold-motion-for-collapsing-on-the-next-message/problem: - en: 84c909627857b56d - zh: 543d20649a519abb + en: 88faaf0c589e12cb + zh: dda7efc5eaed2788 /agent-note-one-fold-motion-for-collapsing-on-the-next-message/decision: en: 972bbe45fe8ac28e zh: 593b851c142894e1 @@ -15,8 +15,8 @@ en: 45cb2d17e4d15b09 zh: e11e75c7bcc6bd8e /agent-note-one-fold-motion-for-collapsing-on-the-next-message/decision/landings: - en: a052e1eb266c2798 - zh: 1ded76ca8b6285e9 + en: f048652e38757699 + zh: fe02896384df467a /agent-note-one-fold-motion-for-collapsing-on-the-next-message/decision/own-input: en: ca6a5c372bb3cad1 zh: 1cd5028269b58c65 diff --git a/.agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.md b/.agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.md index d0b7eeb1cd..b0199720c6 100644 --- a/.agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.md +++ b/.agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.md @@ -21,7 +21,7 @@ When "When to Collapse Work Details" is "On next message", the previous exchange | Send from a subagent child's composer in the right Sidebar | No (the composer skipped `beginSubmission`) | Whole viewport blank | Whole viewport blank | | Human send after parent messages woke several child Turns | No | Whole viewport blank (about 6000 px of room) | — | -Notice-woken Turns no longer fold anything ([group-tree positions and exchanges](2026-10-11-chat-group-tree-exchanges.md)); that note lists human input from another tab as a remaining issue, and the table above is that issue's full scope. The data comes from [fold-blank-matrix.e2e.ts](../../../../apps/web/tests/fold-blank-matrix.e2e.ts). +Notice-woken Turns no longer fold anything ([group-tree positions and exchanges](2026-10-11-chat-group-tree-exchanges.md)); the table above covers the human input that still folded through the generic path. The data comes from [fold-blank-matrix.e2e.ts](../../../../apps/web/tests/fold-blank-matrix.e2e.ts). Subagent children showed no local echo because, when echoes were introduced, `subagents.prompt` carried no request id, so an echo could not be matched with the message the child recorded. Subagent control later moved to Remote, and the Host now records `SubagentPromptRequest.requestId` as the child message's `source.rpcId`; the client's `Session.prompt` still minted a separate id. @@ -53,10 +53,10 @@ Let `start` be the scrollTop when the batch begins, `above` the height of closin - Both landings install only the exact deficit as room: `max(0, target + viewport height − folded height)`. - Content never slides back down: content already above the viewport top does not return. When the retained body and the new message cannot fill the viewport, the deficit remains as room until new content fills it. -- Own input during a running batch starts a new segment from the current position toward the tail; it takes over even when a reading gesture cancelled the automatic target. -- A reading gesture cancels the tail landing's automatic target and keeps the reader's position. +- Own input during a running batch starts a new segment from the current position toward the tail; it takes over even when a reading gesture cancelled the automatic target. A tail request counts only while a batch runs or is scheduled; rows still closing on CSS transitions after a batch leave own input to ordinary following. +- A reading gesture cancels a tail landing's automatic target and keeps the reader's position. During a reader-content-still landing, the reader's own scrolling between frames moves the position the batch keeps still. - For the reader-content-still landing, the driver takes the first visible top-level row outside the batch as a reference, found by binary search over the ordered top-level rows. Neighbouring spacing changes instantly when the batch begins and when closing rows commit their hidden state; the driver measures the reference's displacement at those two points and offsets scrollTop by it. -- When a tail landing scrolls forward past closing rows that start below the viewport top, those rows stay opaque, scroll with the content, and clip while they shrink; nested closing rows keep their size until the batch ends. Other closing rows fade in place. Scrolling forward therefore never crosses space that is already transparent but not yet closed. +- When a tail landing scrolls forward past closing rows that start below the viewport top, those rows stay opaque, scroll with the content, and clip while they shrink; nested closing rows keep their size until the batch ends. Other closing rows fade in place. A newer tail request that takes over a running batch adds the closing rows still below the viewport top. Scrolling forward therefore never crosses space that is already transparent but not yet closed. ### Own input diff --git a/.agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.zh.md b/.agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.zh.md index f7528c01eb..ebb163560c 100644 --- a/.agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.zh.md +++ b/.agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.zh.md @@ -21,7 +21,7 @@ Status: implemented | 右侧栏子会话输入框发送 | 无(composer 跳过 `beginSubmission`) | 整屏空白 | 整屏空白 | | 子会话里父消息唤醒多轮后人类发送 | 无 | 整屏空白(补白约 6000 px) | — | -通知唤醒的 Turn 已不再触发折叠([分组树位置与交互段](2026-10-11-chat-group-tree-exchanges.zh.md)),那篇记录把另一个标签页的人类输入列为遗留问题;上表是这一遗留问题的完整范围。数据来自 [fold-blank-matrix.e2e.ts](../../../../apps/web/tests/fold-blank-matrix.e2e.ts)。 +通知唤醒的 Turn 已不再触发折叠([分组树位置与交互段](2026-10-11-chat-group-tree-exchanges.zh.md));上表是仍经由通用路径折叠的人类输入。数据来自 [fold-blank-matrix.e2e.ts](../../../../apps/web/tests/fold-blank-matrix.e2e.ts)。 子会话不显示本地回显,是因为引入回显时 `subagents.prompt` 不携带 request id,回显无法与子会话写回的消息对应。此后子 agent 控制迁到 Remote,`SubagentPromptRequest.requestId` 已被 Host 写入子会话用户消息的 `source.rpcId`;客户端 `Session.prompt` 却仍另行生成 id。 @@ -53,10 +53,10 @@ Status: implemented - 两种落点都只安装精确差额作为补白:`max(0, 目标 + 视口高 − 折叠后高度)`。 - 「内容只上不下」:已经在视口顶部以上的内容不会回滑。保留的正文和新消息不足一屏时留出差额,新内容到来后填满。 -- 批次进行中出现自己输入时,从当前位置开新段,改为到底部;即使阅读手势已取消自动目标,也由新输入接管。 -- 读者的滚动手势会取消「到底部」的自动目标,保留读者位置。 +- 批次进行中出现自己输入时,从当前位置开新段,改为到底部;即使阅读手势已取消自动目标,也由新输入接管。只有批次正在运行或已排定时才接受到底部请求;批次结束后仍在用 CSS 过渡收起的行,不接管自己的输入,交给普通跟随。 +- 读者的滚动手势会取消「到底部」的自动目标,保留读者位置。「读者内容不动」时,帧间读者自己的滚动会并入批次保持的位置。 - 「读者内容不动」时,驱动器对有序的顶层行二分查找,选取视口里第一行不属于本批次的可见顶层行作为参照。批次开始和收起行提交 hidden 时,相邻行的间距会瞬间改变;驱动器在这两处测量参照行的位移,抵消到 scrollTop 上。 -- 「到底部」且需要向前滚过视口顶部以下的收起行时,这些行保持不透明、随内容滚动,在收缩中裁剪显示,嵌套的收起行在批次结束前保持原尺寸。其余收起行原地淡出。这样向前滚动不会穿过一片已经透明、还没收完的空位。 +- 「到底部」且需要向前滚过视口顶部以下的收起行时,这些行保持不透明、随内容滚动,在收缩中裁剪显示,嵌套的收起行在批次结束前保持原尺寸。其余收起行原地淡出。新的到底部请求接管运行中的批次时,会把仍在视口顶部以下的收起行加入随行。这样向前滚动不会穿过一片已经透明、还没收完的空位。 ### 自己输入的判定 diff --git a/apps/web/tests/fold-blank-matrix.e2e.ts b/apps/web/tests/fold-blank-matrix.e2e.ts index bef053a7cb..29b0e5830d 100644 --- a/apps/web/tests/fold-blank-matrix.e2e.ts +++ b/apps/web/tests/fold-blank-matrix.e2e.ts @@ -12,7 +12,7 @@ import { join } from 'node:path' import { fileURLToPath } from 'node:url' import type { Browser, Page, Route } from 'playwright' import { chromium } from 'playwright' -import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import type { StreamChunk } from '@deepseek-ai/dsh-llm' import { ToolCallId, createAssistantMessage, createUserMessage } from '@deepseek-ai/dsh-llm' import type { ReplayEntry } from '@deepseek-ai/dsh-llm-replay' @@ -122,7 +122,8 @@ const CASES: readonly Case[] = [ { ...SIDE, id: 'side/composer/previous-turn', reader: PREVIOUS_TURN }, { ...SIDE, id: 'side/queued/bottom', origin: 'queued', reader: AT_BOTTOM }, { ...SIDE, id: 'side/queued/mid-body', origin: 'queued', reader: LIVE_MID_BODY, landing: 'reader' }, - { ...SIDE, id: 'side/parent-message/mid-body', origin: 'parent-message', reader: SEEDED_MID_BODY, landing: 'reader' }, + // Control: a parent message is a notice, which opens no exchange and folds nothing. + { ...SIDE, id: 'side/parent-message/no-fold-control', origin: 'parent-message', reader: SEEDED_MID_BODY, landing: 'reader' }, { ...SIDE, id: 'side/big-exchange/bottom', origin: 'big-exchange', reader: AT_BOTTOM }, ] @@ -136,6 +137,8 @@ interface Geometry { /** Empty height between the last visible row and the composer. */ readonly tailGap: number readonly following: boolean + /** A row was closing on this sample. */ + readonly collapsing: boolean } /** Installed into each measured page before load; reads one scrollport's blank-area geometry. */ @@ -174,6 +177,7 @@ function installMeasure(): void { top: host.scrollTop, floor: Math.max(0, host.scrollHeight - host.clientHeight), spacer, maxGap, gapAt, tailGap: composerTop - cursor, following: host.querySelector('[data-chat-following-tail]') !== null, + collapsing: host.querySelector('[data-chat-motion="collapse"]') !== null, } } } @@ -604,15 +608,23 @@ async function run(world: World, scenario: Case): Promise { const disposeFailure = scenario.origin === 'error' ? scaffold.ctx.on('system-prompt/assemble', async () => { throw new Error('FOLDMX_TURN_FAILED') }) : () => undefined + // A subagent child's composer sends through the subagent continuation route. + const promptRoute = scenario.surface === 'side' ? '**/api/subagents/prompt' : '**/api/session/prompt' const promptGate = Promise.withResolvers() - const holdPrompt = async (route: Route): Promise => { await promptGate.promise; await route.continue() } - await page.route('**/api/session/prompt', holdPrompt) + const promptHeld = Promise.withResolvers() + const holdPrompt = async (route: Route): Promise => { + promptHeld.resolve(undefined) + await promptGate.promise + await route.continue() + } + await page.route(promptRoute, holdPrompt) const ended = turnEnded(scaffold, target) try { await expandSeededSteps(page, root, scenario) await placeReader(page, root, scenario.reader) await begin() await send(world, NEXT_INPUT) + await promptHeld.promise await page.locator(`${root} [data-submission-echo]`).waitFor() afterFold = await folded() promptGate.resolve(undefined) @@ -620,7 +632,7 @@ async function run(world: World, scenario: Case): Promise { } finally { promptGate.resolve(undefined) disposeFailure() - await page.unroute('**/api/session/prompt', holdPrompt) + await page.unroute(promptRoute, holdPrompt) } break } @@ -725,7 +737,6 @@ describe.skipIf(MODE === 'record')('web e2e: blank area after next-message foldi beforeAll(async () => { browser = await chromium.launch() - await mkdir(EVIDENCE, { recursive: true }) }) afterAll(async () => { @@ -747,8 +758,17 @@ describe.skipIf(MODE === 'record')('web e2e: blank area after next-message foldi framesOverLimit: transient.length, frames: samples.length, peakSpacer: Math.round(Math.max(0, ...samples.map(sample => sample.spacer))), } - await writeFile(join(EVIDENCE, `${sessionKey(scenario)}.json`), JSON.stringify({ scenario, ...summary, samples }, null, 2)) - console.log(`FOLDMX ${JSON.stringify(summary)}`) + const evidence = async (): Promise => { + await mkdir(EVIDENCE, { recursive: true }) + await writeFile(join(EVIDENCE, `${sessionKey(scenario)}.json`), JSON.stringify({ scenario, ...summary, samples }, null, 2)) + console.log(`FOLDMX ${JSON.stringify(summary)}`) + } + // Per-frame evidence is written for failures, or for every case with FOLDMX_EVIDENCE=1. + if (process.env.FOLDMX_EVIDENCE === '1') await evidence() + else onTestFailed(evidence) + if (scenario.origin === 'parent-message') { + expect.soft(samples.some(sample => sample.collapsing), 'a notice folds nothing').toBe(false) + } if (scenario.roomUntilReply !== true) { expect.soft(afterFold.maxGap, 'blank after the fold settles').toBeLessThanOrEqual(BLANK_LIMIT) expect.soft(transient.length, 'animation frames showing a blank').toBeLessThanOrEqual(TRANSIENT_FRAMES) diff --git a/packages/client/ui-chat/src/client/chat/flow-motion.ts b/packages/client/ui-chat/src/client/chat/flow-motion.ts index 49e670acff..91c712c0df 100644 --- a/packages/client/ui-chat/src/client/chat/flow-motion.ts +++ b/packages/client/ui-chat/src/client/chat/flow-motion.ts @@ -1,7 +1,8 @@ /** * Fold transitions owned by one Chat viewport. Every batch containing a closing row moves its boxes and - * the scrollport on one frame clock; a batch that only reveals rows uses CSS transitions. Motion is - * opt-in under `[data-chat-motion]`, respects reduced motion, and clears temporary styles. + * the scrollport on one frame clock; a batch that only reveals rows, or a viewport without a reading + * policy, uses CSS transitions. Motion is opt-in under `[data-chat-motion]`, respects reduced motion, + * and clears temporary styles. */ import { useCallback, useLayoutEffect, useRef, type RefObject } from 'react' import { useSearchableHidden, type HiddenTransition } from './searchable-hidden.ts' @@ -37,19 +38,21 @@ export interface InputFoldDriver { /** * Capture geometry and choose the landing before the frame clock changes the boxes. * @param boxes - non-nested boxes whose layout changes on this clock. - * @returns closing boxes that scroll with the content and stay opaque; the rest fade in place. + * @returns closing boxes that scroll with the content and stay opaque; the rest fade in place. The driver + * may add boxes while the batch runs, when a newer tail request takes it over. */ begin(boxes: readonly InputFoldBox[]): ReadonlySet /** * Reserve scroll range before box writes. * @param progress - shared eased progress from zero to one. - * @param cancelled - a reading gesture stopped the automatic target. + * @param cancelled - a reading gesture stopped a tail landing's automatic target; a landing that keeps + * the reader's content still continues and includes the reader's own movement. */ prepare(progress: number, cancelled: boolean): void /** * Apply viewport geometry after box writes. * @param progress - shared eased progress from zero to one. - * @param cancelled - a reading gesture stopped the automatic target. + * @param cancelled - a reading gesture stopped a tail landing's automatic target. * @returns actual scrollTop displacement from the batch start, for closing-content visual compensation. */ frame(progress: number, cancelled: boolean): number @@ -71,7 +74,7 @@ export interface FlowMotion extends FlowMotionRows { cancelInput(): void /** Restore the running batch's automatic target after a newer request takes it over. */ resumeInput(): void - /** @returns whether a fold batch currently owns layout and scrolling. */ + /** @returns whether a fold batch runs, or one is scheduled for this commit's closing rows. */ inputActive(): boolean /** Cancel owned animations and pending work when the viewport detaches. */ clear(): void @@ -123,7 +126,7 @@ export function createFlowMotion(driver: () => InputFoldDriver | null, foldingCh } function resumeInput(): void { - cancelled = false + if (batch) cancelled = false } function runBatch(current: InputFoldDriver): void { @@ -142,14 +145,24 @@ export function createFlowMotion(driver: () => InputFoldDriver | null, foldingCh box.element.style.marginTop = `${box.closing ? box.marginTop : 0}px` box.element.style.opacity = box.closing ? '1' : '0' } + const memberSet = new Set(members) + const settledTravel = new Set() // A travelling box clips its content as it shrinks, so nested closing rows keep their size until it ends. - for (const box of owned) { - if (members.includes(box) || ![...travelling].some(element => element.contains(box.element))) continue - box.element.style.transition = 'none' - box.element.style.height = `${box.height}px` - box.element.style.marginTop = `${box.marginTop}px` - box.element.style.opacity = '1' + const freezeTravelling = (): void => { + for (const element of travelling) { + if (settledTravel.has(element)) continue + settledTravel.add(element) + element.style.translate = '' + for (const box of owned) { + if (memberSet.has(box) || !element.contains(box.element)) continue + box.element.style.transition = 'none' + box.element.style.height = `${box.height}px` + box.element.style.marginTop = `${box.marginTop}px` + box.element.style.opacity = '1' + } + } } + freezeTravelling() const reduced = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches let started: number | undefined let displacement = 0 @@ -179,6 +192,7 @@ export function createFlowMotion(driver: () => InputFoldDriver | null, foldingCh const linear = Math.min(1, Math.max(0, (elapsed - CLOSE_DELAY_MS) / CLOSE_MS)) const progress = linear * linear * (3 - 2 * linear) current.prepare(progress, cancelled) + freezeTravelling() for (const box of members) { if (boxes.get(box.element) !== box) continue const remaining = box.closing ? 1 - progress : progress @@ -368,7 +382,8 @@ export function createFlowMotion(driver: () => InputFoldDriver | null, foldingCh } return { - collapse, reveal, cancelMotion, foldActive, onFoldIdle, cancelInput, resumeInput, inputActive: () => batch, clear, + collapse, reveal, cancelMotion, foldActive, onFoldIdle, cancelInput, resumeInput, clear, + inputActive: () => batch || (batchScheduled && [...boxes.values()].some(box => box.closing)), } } diff --git a/packages/client/ui-chat/src/client/chat/use-chat-viewport.ts b/packages/client/ui-chat/src/client/chat/use-chat-viewport.ts index 4c57b8e486..976ab2b811 100644 --- a/packages/client/ui-chat/src/client/chat/use-chat-viewport.ts +++ b/packages/client/ui-chat/src/client/chat/use-chat-viewport.ts @@ -2,7 +2,7 @@ import { useLayoutEffect, useRef, useState, type RefObject } from 'react' import type { ChatScrollPosition } from '../contract/slots.ts' import type { ChatSnapshot } from '../contract/snapshot.ts' -import { createFlowMotion, type InputFoldDriver } from './flow-motion.ts' +import { createFlowMotion, type InputFoldBox, type InputFoldDriver } from './flow-motion.ts' import { scrollMetrics, ScrollFollow, type ViewportMetrics } from './use-scroll-follow.ts' export type { ViewportMetrics } from './use-scroll-follow.ts' @@ -179,7 +179,8 @@ export class ChatViewport { * @returns whether a fold owns the tail movement. */ followInputFold(follow: ScrollFollow): boolean { - if (this.elements === null || !this.motion.foldActive()) return false + // Rows still closing on CSS transitions after a batch have no batch to consume the request. + if (this.elements === null || this.reader === null || !this.motion.inputActive()) return false this.interruptFollow(follow) this.tailRequested = true this.retarget?.() @@ -209,6 +210,9 @@ export class ChatViewport { let previousProgress = 0 let segmentProgress = 0 let segmentTop = 0 + let written = 0 + const closing: InputFoldBox[] = [] + const travelling = new Set() // A visible row outside the batch measures spacing that snaps when the batch starts and ends. let reference: HTMLElement | null = null let referenceTop = 0 @@ -251,6 +255,7 @@ export class ChatViewport { removed += amount above += Math.max(0, Math.min(amount, viewportTop - top)) if (top >= viewportTop) below.push(box.element) + closing.push(box) } else { growth += amount if (top < viewportTop) above -= amount @@ -265,6 +270,7 @@ export class ChatViewport { } base = Math.max(0, start - above) segmentTop = start + written = start previousColumnHeight = elements.column.offsetHeight aim() // This reserve only prevents an intermediate clamp; the final frame installs the exact deficit. @@ -277,12 +283,28 @@ export class ChatViewport { this.motion.resumeInput() aim() restart() + if (target <= segmentTop) return + for (const box of closing) { + // A box fading in place carries a visual translate; its layout position excludes it. + const shift = Number.parseFloat(box.element.style.translate.split(' ')[1] ?? '') || 0 + if (box.element.getBoundingClientRect().top - shift - box.marginTop >= viewportTop) travelling.add(box.element) + } } // Scrolling forward past closing rows shows them shrinking; faded rows would leave their space empty. - return new Set(tail && target > start ? below : []) + if (tail && target > start) for (const element of below) travelling.add(element) + return travelling }, prepare: (progress, cancelled) => { if (this.elements !== elements) return + if (!tail) { + // Reader scrolling between frames moves the reading position the batch keeps still. + const drift = elements.scroller.scrollTop - written + if (Math.abs(drift) > 0.5) { + base = Math.max(0, base + drift) + segmentTop += drift + aim() + } + } const added = elements.column.offsetHeight - previousColumnHeight if (added !== 0) { initialHeight += added @@ -298,7 +320,7 @@ export class ChatViewport { const top = position(progress, cancelled) elements.scroller.scrollTop = top this.setSpacer(room(top, progress, cancelled)) - const written = elements.scroller.scrollTop + written = elements.scroller.scrollTop // Earlier frames keep the reference still; only its final position is compared at finish. if (reference !== null && progress === 1) referenceTop = screenTop(reference) if (automatic(cancelled)) this.observation = { top: written, landing: null } diff --git a/packages/client/ui-chat/tests/flow-motion.client.spec.tsx b/packages/client/ui-chat/tests/flow-motion.client.spec.tsx index 6e8b2ba544..8bca075841 100644 --- a/packages/client/ui-chat/tests/flow-motion.client.spec.tsx +++ b/packages/client/ui-chat/tests/flow-motion.client.spec.tsx @@ -275,6 +275,44 @@ describe('flow motion', () => { vi.advanceTimersByTime(400) }) + it('freezes a box that starts travelling after the batch began', async () => { + vi.useFakeTimers() + const travelling = new Set() + const calls = batchDriver() + calls.begin.mockReturnValue(travelling) + const row = rowWithHeight(180) + const nested = rowWithHeight(60, row) + motion.collapse(nested, vi.fn()) + motion.collapse(row, vi.fn()) + await Promise.resolve() + vi.advanceTimersByTime(96) + expect(row.style.translate).toBe('0 -20px') + travelling.add(row) + vi.advanceTimersByTime(16) + expect(row.style.opacity).toBe('1') + expect(row.style.translate).toBe('') + expect(nested.style.height).toBe('60px') + expect(nested.style.transition).toBe('none') + vi.advanceTimersByTime(400) + }) + + it('reports a batch as input-owned while it is scheduled or running, not for reveals alone', async () => { + vi.useFakeTimers() + batchDriver() + const opening = rowWithHeight(30) + motion.reveal(opening) + expect(motion.inputActive()).toBe(false) + const row = rowWithHeight(180) + motion.collapse(row, vi.fn()) + expect(motion.inputActive()).toBe(true) + await Promise.resolve() + expect(motion.inputActive()).toBe(true) + vi.advanceTimersByTime(400) + expect(motion.inputActive()).toBe(false) + motion.resumeInput() + expect(motion.inputActive()).toBe(false) + }) + it('reports reader cancellation to the driver and lets a newer request resume the target', async () => { vi.useFakeTimers() const calls = batchDriver() diff --git a/packages/client/ui-chat/tests/fold-follow.client.spec.ts b/packages/client/ui-chat/tests/fold-follow.client.spec.ts index a39bc06716..8226ada2e9 100644 --- a/packages/client/ui-chat/tests/fold-follow.client.spec.ts +++ b/packages/client/ui-chat/tests/fold-follow.client.spec.ts @@ -25,6 +25,16 @@ let frameClock = 0 let nextFrame = 0 /** Run queued animation frames until the batch stops requesting more. */ +/** Run a fixed number of queued animation frames. */ +function runFrameCount(count: number): void { + for (let index = 0; index < count && frames.size > 0; index++) { + const pending = [...frames.values()] + frames.clear() + frameClock += 16 + for (const callback of pending) callback(frameClock) + } +} + function runFrames(): void { for (let guard = 0; frames.size > 0 && guard < 100; guard++) { const pending = [...frames.values()] @@ -141,6 +151,17 @@ it('lands a following reader at the tail and lets content growth consume the roo expect(h.follow.animating).toBe(false) }) +it('adds a reader\'s own scrolling during the fold to the position it keeps still', async () => { + const h = fixture({ earlier: 100, later: 500, top: 2_000, following: false }) + h.viewport.motion.collapse(h.row, () => { h.row.hidden = true; h.grow(600) }) + await Promise.resolve() + runFrameCount(6) + h.list.scrollTop -= 50 + runFrames() + expect(h.list.scrollTop).toBe(150) + expect(h.reading.followingTail).toBe(false) +}) + it('keeps a reader below folding rows on the same content without room or follow', async () => { const h = fixture({ earlier: 100, later: 500, top: 2_000, following: false }) await h.close() From 06829b67b675cc13eb8777fec208b90f96a01e31 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 14 Oct 2026 00:51:05 +0800 Subject: [PATCH 28/35] test(web): bound the fold matrix's live-Turn waits The queued and steer cases held the model call at a global call index and waited without bounds, so any shifted or missing call stalled the case until the 240 s test timeout. The hold now targets the first call in the case's Session that streams the case's marker text, every wait has an explicit timeout whose error lists the observed calls and the Session's latest events, scroll settling is bounded, and the cases use the web lane's default timeout. --- apps/web/tests/fold-blank-matrix.e2e.ts | 84 +++++++++++++++++++------ 1 file changed, 64 insertions(+), 20 deletions(-) diff --git a/apps/web/tests/fold-blank-matrix.e2e.ts b/apps/web/tests/fold-blank-matrix.e2e.ts index 29b0e5830d..750817d3ae 100644 --- a/apps/web/tests/fold-blank-matrix.e2e.ts +++ b/apps/web/tests/fold-blank-matrix.e2e.ts @@ -36,6 +36,8 @@ const MODE = webSnapshotMode() const SEEDED_TURNS = 8 const SEEDED_STEPS = 6 const LIVE_STEPS = 6 +/** Live Steps the steered Turn replays before the steering message is delivered. */ +const STEER_STEPS = 3 const BLANK_LIMIT = 120 /** * A closing row fading in place can expose its emptied space for one frame before it finishes @@ -284,7 +286,7 @@ function replayFor(scenario: Case): ReplayEntry[] { case 'queued': return [...liveLongTurn('FOLDMX_LIVE'), replyEntry('FOLDMX_REPLY')] case 'steer': - return [...liveLongTurn('FOLDMX_LIVE').slice(0, 3), replyEntry('FOLDMX_STEERED')] + return [...liveLongTurn('FOLDMX_LIVE').slice(0, STEER_STEPS), replyEntry('FOLDMX_STEERED')] case 'parent-message': return [replyEntry('FOLDMX_PARENT_REPLY')] case 'big-exchange': @@ -292,24 +294,26 @@ function replayFor(scenario: Case): ReplayEntry[] { } } -async function stableScroll(page: Page, root: string): Promise { - await page.evaluate(async (selector) => { +async function stableScroll(page: Page, root: string, timeoutMs = 20_000): Promise { + await page.evaluate(async ([selector, limit]) => { const host = document.querySelector(selector) if (host === null) throw new Error(`scrollport is absent: ${selector}`) let previous = host.scrollTop - let stableSince = performance.now() - await new Promise((resolveStable) => { + const started = performance.now() + let stableSince = started + await new Promise((resolveStable, reject) => { const check = (): void => { const now = performance.now() const moving = host.querySelector('[data-chat-motion="collapse"], [data-chat-motion="reveal"]') !== null if (Math.abs(host.scrollTop - previous) > 0.25 || moving) stableSince = now previous = host.scrollTop if (now - stableSince >= 300) resolveStable() + else if (now - started >= limit) reject(new Error(`scroll did not settle within ${String(limit)}ms: top ${String(host.scrollTop)}, motion ${String(moving)}`)) else requestAnimationFrame(check) } requestAnimationFrame(check) }) - }, root) + }, [root, timeoutMs] as const) } function measure(page: Page, root: string): Promise { @@ -425,25 +429,61 @@ async function expandSeededSteps(page: Page, root: string, scenario: Case): Prom await page.evaluate(() => { if (document.activeElement instanceof HTMLElement) document.activeElement.blur() }) } -/** Hold the finish of one future model call; calls are counted from installation. */ -function holdFinishOf(scaffold: WebScaffold, callIndex: number): { readonly reached: Promise; release(): void; dispose(): void } { +/** Model-call barrier for one live Turn. */ +interface FinishHold { + /** Resolves when the held call streams its finish; rejects after the timeout. */ + reached(timeoutMs: number): Promise + release(): void + dispose(): void + /** Model calls observed since installation and the target Session's latest event types. */ + describe(): string +} + +/** + * Hold the finish of the first model call for `session` whose streamed text contains `marker`. + * The call is identified by its replayed content, so auxiliary or reordered calls cannot shift the barrier. + */ +function holdFinishOf(scaffold: WebScaffold, session: SessionId, marker: string): FinishHold { const gate = Promise.withResolvers() const reached = Promise.withResolvers() - let calls = 0 - const dispose = scaffold.ctx.on('llm/stream', async function* (_options, next) { - const index = calls++ + const calls: string[] = [] + let held = false + const dispose = scaffold.ctx.on('llm/stream', async function* (options, next) { + const index = calls.length + calls.push(`#${String(index)} ${options.sessionId ?? 'no-session'}/${options.purpose ?? 'turn'}`) + let matched = false for await (const chunk of next()) { - if (index === callIndex && chunk.type === 'finish') { - reached.resolve(undefined) - await gate.promise + if (chunk.type === 'block-end' && chunk.block.type === 'text' && chunk.block.text.includes(marker)) matched = true + if (chunk.type === 'finish') { + calls[index] = `${calls[index] ?? ''} finish=${chunk.reason.kind}${matched ? ` ${marker}` : ''}` + if (!held && matched && options.sessionId === session) { + held = true + reached.resolve(undefined) + await gate.promise + } } yield chunk } }) + const describe = (): string => { + const events = scaffold.ctx.agents.get(session)?.session.snapshotEvents() ?? [] + return `model calls: [${calls.join('; ')}]; ${session} event tail: ${events.slice(-12).map(event => event.type).join(', ')}` + } return { - reached: reached.promise.then(() => undefined), + reached: async (timeoutMs) => { + let timer: ReturnType | undefined + const expired = new Promise((_resolve, reject) => { + timer = setTimeout(() => { reject(new Error(`no model call streamed ${marker} within ${String(timeoutMs)}ms`)) }, timeoutMs) + }) + try { + await Promise.race([reached.promise, expired]) + } finally { + clearTimeout(timer) + } + }, release: () => { gate.resolve(undefined) }, dispose: () => { gate.resolve(undefined); dispose() }, + describe, } } @@ -655,13 +695,15 @@ async function run(world: World, scenario: Case): Promise { } case 'queued': case 'steer': { - const steps = scenario.origin === 'queued' ? LIVE_STEPS : 2 - const gate = holdFinishOf(scaffold, steps) + // Queued: hold the long final body. Steer: hold the last replayed Step so the steering message lands in this Turn. + const gate = holdFinishOf(scaffold, target, scenario.origin === 'queued' ? 'FOLDMX_LIVE_BODY' : `FOLDMX_LIVE_STEP_${String(STEER_STEPS)}`) + const first = turnEnded(scaffold, target, scenario.origin === 'queued' ? 2 : 1) + // A failed earlier step reports itself; the success path still awaits and rethrows this rejection. + first.catch(() => undefined) try { - const first = turnEnded(scaffold, target, scenario.origin === 'queued' ? 2 : 1) await toBottom(page, root) await send(world, 'FOLDMX_START_LIVE_TURN') - await gate.reached + await gate.reached(60_000) await stableScroll(page, root) if (scenario.origin === 'queued' && scenario.reader.kind === 'marker') { await page.locator(root).getByText('FOLDMX_LIVE_BODY', { exact: false }).last().waitFor() @@ -681,6 +723,8 @@ async function run(world: World, scenario: Case): Promise { .first().waitFor({ timeout: 30_000 }) afterFold = await folded() await first + } catch (error) { + throw new Error(`${scenario.id}: ${error instanceof Error ? error.message : String(error)}\n${gate.describe()}`, { cause: error }) } finally { gate.dispose() } @@ -789,7 +833,7 @@ describe.skipIf(MODE === 'record')('web e2e: blank area after next-message foldi await world?.scaffold.close().catch((error: unknown) => { console.log(`FOLDMX close ${scenario.id}: ${String(error)}`) }) await rm(replayDir, { recursive: true, force: true }) } - }, 240_000) + }) } }) From 6aa157a05a150b7ee3c3236c89d0f26b737a0f66 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 14 Oct 2026 00:11:12 +0800 Subject: [PATCH 29/35] perf(session-persistence-jsonl): bound listing reads with a shared concurrency limit list(), the root encoding check, and historical corpus fingerprints read project directories, Session directories, headers, and stats one at a time. On a network filesystem every listing paid the summed round trips. These reads now run through one p-limit limiter per backend (listConcurrency, default 3), shared by concurrent listings; results and the first reported failure keep the sequential order. --- THIRD_PARTY_NOTICES.md | 1 + docs/config-catalog.md | 10 +- docs/config-catalog.zh.md | 10 +- .../README.i18n.yaml | 4 +- .../session-persistence-jsonl/README.md | 1 + .../session-persistence-jsonl/README.zh.md | 1 + .../session-persistence-jsonl/package.json | 3 +- .../session-persistence-jsonl/src/index.ts | 167 +++++++++++++----- .../tests/jsonl.spec.ts | 101 +++++++++++ pnpm-lock.yaml | 17 ++ 10 files changed, 266 insertions(+), 49 deletions(-) diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index d20c5c2acb..6a2fbda5eb 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -114,6 +114,7 @@ External packages installed for runtime use or distributed inside the prebuilt b | [`node-addon-require-builtin`](https://www.npmjs.com/package/node-addon-require-builtin) | MIT | | [`node-pty`](https://github.com/microsoft/node-pty) | MIT | | [`open`](https://github.com/sindresorhus/open) | MIT | +| [`p-limit`](https://github.com/sindresorhus/p-limit) | MIT | | [`papaparse`](https://github.com/mholt/PapaParse) | MIT | | [`pdfjs-dist`](https://github.com/mozilla/pdf.js) | Apache-2.0 | | [`picomatch`](https://github.com/micromatch/picomatch) | MIT | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 514968564b..8bfae862ef 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2769,10 +2769,10 @@ export type SessionLogCompressionLevel = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 ## `@deepseek-ai/dsh-session-persistence-jsonl` -- `source`: [`packages/session/session-persistence-jsonl/src/index.ts:90`](../packages/session/session-persistence-jsonl/src/index.ts) +- `source`: [`packages/session/session-persistence-jsonl/src/index.ts:94`](../packages/session/session-persistence-jsonl/src/index.ts) ```ts config-catalog -/** Plugin config for the JSONL backend's root and physical encoding. */ +/** Plugin config for the JSONL backend's root, physical encoding, and listing concurrency. */ export interface Config { /** * Root directory for all session files. Required (no default): a default of @@ -2784,6 +2784,12 @@ export interface Config { root: string /** Physical encoding; defaults to checksummed Zstandard frames. */ compression?: JsonlCompression + /** + * Maximum directory, header, and stat reads in flight across all concurrent + * listings of this backend (`list()`, the root encoding check, and historical + * fingerprints). Defaults to 3. + */ + listConcurrency?: number } /** Physical encoding selected for JSONL session artifacts. */ diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 005ea98599..68dfbf1852 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -2771,10 +2771,10 @@ export type SessionLogCompressionLevel = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 ## `@deepseek-ai/dsh-session-persistence-jsonl` -- `source`: [`packages/session/session-persistence-jsonl/src/index.ts:90`](../packages/session/session-persistence-jsonl/src/index.ts) +- `source`: [`packages/session/session-persistence-jsonl/src/index.ts:94`](../packages/session/session-persistence-jsonl/src/index.ts) ```ts config-catalog -/** Plugin config for the JSONL backend's root and physical encoding. */ +/** Plugin config for the JSONL backend's root, physical encoding, and listing concurrency. */ export interface Config { /** * Root directory for all session files. Required (no default): a default of @@ -2786,6 +2786,12 @@ export interface Config { root: string /** Physical encoding; defaults to checksummed Zstandard frames. */ compression?: JsonlCompression + /** + * Maximum directory, header, and stat reads in flight across all concurrent + * listings of this backend (`list()`, the root encoding check, and historical + * fingerprints). Defaults to 3. + */ + listConcurrency?: number } /** Physical encoding selected for JSONL session artifacts. */ diff --git a/packages/session/session-persistence-jsonl/README.i18n.yaml b/packages/session/session-persistence-jsonl/README.i18n.yaml index 8821bbf6cb..e134bef8d2 100644 --- a/packages/session/session-persistence-jsonl/README.i18n.yaml +++ b/packages/session/session-persistence-jsonl/README.i18n.yaml @@ -21,8 +21,8 @@ en: 37c10302bbbde268 zh: 3d5c027bac868efd /deepseek-ai-dsh-session-persistence-jsonl/use-this-package/minimal-configuration: - en: 22e8c3b2da45edff - zh: d9effd892b514520 + en: b6c2f0f11f2fa9cf + zh: 9fac03fcbe5bbad4 /deepseek-ai-dsh-session-persistence-jsonl/use-this-package/on-disk-layout: en: e20cf32540d7c785 zh: 67e182529e9211ca diff --git a/packages/session/session-persistence-jsonl/README.md b/packages/session/session-persistence-jsonl/README.md index b26e59a89f..5782c61468 100644 --- a/packages/session/session-persistence-jsonl/README.md +++ b/packages/session/session-persistence-jsonl/README.md @@ -46,6 +46,7 @@ Choose this backend when consumers benefit from one artifact per session — nav |---|---|---| | `root` | required | Root directory for all session files | | `compression` | `'zstd'` | Physical encoding: `'zstd'` checksummed frames, or `'none'` newline-delimited UTF-8 text | +| `listConcurrency` | `3` | Maximum directory, header, and stat reads in flight, shared by every concurrent listing of this backend | Live-event write batching is not configuration: the batching window is the seam's internal scheduling policy inside each write handle. diff --git a/packages/session/session-persistence-jsonl/README.zh.md b/packages/session/session-persistence-jsonl/README.zh.md index fe87a1cbc3..dbd602afa3 100644 --- a/packages/session/session-persistence-jsonl/README.zh.md +++ b/packages/session/session-persistence-jsonl/README.zh.md @@ -46,6 +46,7 @@ kind: "package-reference" |---|---|---| | `root` | 必填 | 所有会话文件的根目录 | | `compression` | `'zstd'` | 物理编码:`'zstd'` 带校验和帧,或 `'none'` 换行分隔 UTF-8 文本 | +| `listConcurrency` | `3` | 同时进行的目录、header 与 stat 读取上限,由该后端所有并发的列举共用 | 实时事件的写入批处理不是配置:批处理窗口是该 seam 在每个写句柄内部的调度策略。 diff --git a/packages/session/session-persistence-jsonl/package.json b/packages/session/session-persistence-jsonl/package.json index 47e14f5a52..b972508e90 100644 --- a/packages/session/session-persistence-jsonl/package.json +++ b/packages/session/session-persistence-jsonl/package.json @@ -41,7 +41,8 @@ "@deepseek-ai/dsh-session-format-v3-to-v4": "workspace:*", "@deepseek-ai/node-addon-system": "workspace:~", "@deepseek-ai/schemastery": "workspace:~", - "koffi": "3.1.1" + "koffi": "3.1.1", + "p-limit": "7.3.3" }, "devDependencies": { "@deepseek-ai/dsh-session": "workspace:*", diff --git a/packages/session/session-persistence-jsonl/src/index.ts b/packages/session/session-persistence-jsonl/src/index.ts index bc918a2d1b..3c3c13589f 100644 --- a/packages/session/session-persistence-jsonl/src/index.ts +++ b/packages/session/session-persistence-jsonl/src/index.ts @@ -19,6 +19,7 @@ import { dirname, join, resolve } from 'node:path' import { performance } from 'node:perf_hooks' import { scheduler } from 'node:timers/promises' import { createHash, randomBytes } from 'node:crypto' +import pLimit, { type LimitFunction } from 'p-limit' import { SessionPersistence, SessionPersistenceRevision, SessionFormatUnsupportedError, SessionPersistenceCorruptionError, @@ -86,7 +87,10 @@ export const JsonlCompressionSchema: z = z.union([ z.const('none'), ]).default(DEFAULT_COMPRESSION) -/** Plugin config for the JSONL backend's root and physical encoding. */ +/** Default maximum listing filesystem operations in flight per backend. */ +const DEFAULT_LIST_CONCURRENCY = 3 + +/** Plugin config for the JSONL backend's root, physical encoding, and listing concurrency. */ export interface Config { /** * Root directory for all session files. Required (no default): a default of @@ -98,6 +102,12 @@ export interface Config { root: string /** Physical encoding; defaults to checksummed Zstandard frames. */ compression?: JsonlCompression + /** + * Maximum directory, header, and stat reads in flight across all concurrent + * listings of this backend (`list()`, the root encoding check, and historical + * fingerprints). Defaults to 3. + */ + listConcurrency?: number } /** Logical header paired with the catalog's selected physical-generation classification. */ @@ -202,6 +212,36 @@ function isENOENT(error: unknown): boolean { return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT' } +/** + * Run `task` over `items` through the backend's shared listing limiter and + * settle every call. Callers consume the outcomes in input order, so the first + * failure they report matches a sequential loop whatever the completion order. + * A limited task must never wait on the same limiter. + * @param limit - the backend's shared listing limiter. + * @param items - inputs in their sequential order. + * @param task - per-item operation. + * @returns one outcome per item, in input order. + */ +function settleLimited( + limit: LimitFunction, + items: readonly T[], + task: (item: T) => Promise, +): Promise[]> { + return Promise.allSettled(items.map(item => limit(() => task(item)))) +} + +/** + * Unwrap outcomes from {@link settleLimited}, rethrowing the first rejection in input order. + * @param outcomes - outcomes in input order. + * @returns the fulfilled values in input order. + */ +function settledValues(outcomes: readonly PromiseSettledResult[]): R[] { + return outcomes.map((outcome) => { + if (outcome.status === 'rejected') throw outcome.reason + return outcome.value + }) +} + /** Whether a filesystem-owned failure should retain its original errno and path. */ function isErrnoException(error: unknown): error is NodeJS.ErrnoException { return typeof (error as NodeJS.ErrnoException | null)?.code === 'string' @@ -254,6 +294,12 @@ class JsonlSessionPersistence extends SessionPersistence { private root: string private compression: JsonlCompression + /** + * Bounds directory, header, and stat I/O across every concurrent listing of + * this backend (`list()`, the root encoding check, historical fingerprints). + * Queued tasks of a cancelled listing run and reject at their signal check. + */ + private readonly listLimit: LimitFunction private rootEncodingCheck: Promise | undefined private readonly generationFormat: Omit /** @@ -279,6 +325,7 @@ class JsonlSessionPersistence extends SessionPersistence { // Resolve once so later process.cwd() changes cannot split one backend across roots. this.root = resolve(config.root) this.compression = config.compression ?? DEFAULT_COMPRESSION + this.listLimit = pLimit(config.listConcurrency ?? DEFAULT_LIST_CONCURRENCY) this.generationFormat = { currentVersion: sessionFormatCatalog.currentVersion, encodeHeader: (header, inheritedEventCount) => @@ -482,24 +529,30 @@ class JsonlSessionPersistence extends SessionPersistence { const artifacts = await this.listArtifacts(signal) const corpusRevision = artifacts.some(artifact => artifact.sourceVersion < SESSION_FORMAT_VERSION) ? await this.historicalCorpusRevision(signal) : undefined - for (const artifact of artifacts) { + const statted = settledValues(await settleLimited(this.listLimit, artifacts, async (artifact) => { signal?.throwIfAborted() try { const identity = await stat(artifact.path, { bigint: true }) signal?.throwIfAborted() - listed.add(artifact.header.id) - snapshots.push({ - header: artifact.header, - formatStatus: artifact.formatStatus, - revision: artifact.sourceVersion < SESSION_FORMAT_VERSION - ? SessionPersistenceRevision(`${fileRevision(identity)}:${corpusRevision}`) - : fileRevision(identity), - sizeBytes: Number(identity.size), - }) + return { artifact, identity } } catch (error: unknown) { signal?.throwIfAborted() if (!isENOENT(error)) throw error + return undefined } + })) + for (const entry of statted) { + if (entry === undefined) continue + const { artifact, identity } = entry + listed.add(artifact.header.id) + snapshots.push({ + header: artifact.header, + formatStatus: artifact.formatStatus, + revision: artifact.sourceVersion < SESSION_FORMAT_VERSION + ? SessionPersistenceRevision(`${fileRevision(identity)}:${corpusRevision}`) + : fileRevision(identity), + sizeBytes: Number(identity.size), + }) } for (const [id, entry] of pending) { if (!listed.has(id)) snapshots.push({ header: entry.header, revision: entry.revision }) @@ -1023,32 +1076,59 @@ class JsonlSessionPersistence extends SessionPersistence { /** Enumerate selected physical generations without interpreting their headers or bodies. */ private async listGenerations(signal?: AbortSignal): Promise { - const sources: ResolvedJsonlGeneration[] = [] - for (const project of await this.listProjectDirs(signal)) { - for (const dir of await this.listSessionDirs(project, signal)) { - signal?.throwIfAborted() - const selected = await this.resolveGenerationInDirectory(dir, signal) - if (selected !== undefined) sources.push(selected) + const selected = await this.mapSessionDirectories((dir) => { + signal?.throwIfAborted() + return this.resolveGenerationInDirectory(dir, signal) + }, signal) + return selected.filter(source => source !== undefined) + } + + /** + * Apply `task` to every Session directory through the shared listing + * limiter. Results and the first failure follow the sequential + * project-then-directory order; a project whose listing fails ends the + * traversal after the directories of the projects before it. + * @param task - per-directory operation; must not wait on the listing limiter. + * @param signal - optional cancellation for directory listings. + * @returns per-directory results in traversal order. + */ + private async mapSessionDirectories( + task: (dir: string) => Promise, + signal?: AbortSignal, + ): Promise { + const limit = this.listLimit + // The root read counts against the same bound as every other listing read. + const projectDirs = await limit(() => this.listProjectDirs(signal)) + const projects = await settleLimited(limit, projectDirs, project => this.listSessionDirs(project, signal)) + const dirs: string[] = [] + let projectFailure: PromiseRejectedResult | undefined + for (const outcome of projects) { + if (outcome.status === 'rejected') { + projectFailure = outcome + break } + // Appended one by one: spreading a large project as call arguments exceeds the engine's argument limit. + for (const dir of outcome.value) dirs.push(dir) } - return sources + const results = settledValues(await settleLimited(limit, dirs, task)) + if (projectFailure !== undefined) throw projectFailure.reason + return results } /** Historical logical events depend on the corpus, including members with unreadable headers. */ private async historicalCorpusRevision(signal?: AbortSignal): Promise { const paths = (await this.listGenerations(signal)).map(source => source.sourcePath).sort() - const hash = createHash('sha256') - for (const path of paths) { + const entries = settledValues(await settleLimited(this.listLimit, paths, async (path) => { signal?.throwIfAborted() - let revision: string try { - revision = fileRevision(await stat(path, { bigint: true })) + return { path, revision: fileRevision(await stat(path, { bigint: true })) } } catch (error: unknown) { if (!isENOENT(error)) throw error - revision = 'missing' + return { path, revision: 'missing' } } - hash.update(JSON.stringify([path, revision])) - } + })) + const hash = createHash('sha256') + for (const { path, revision } of entries) hash.update(JSON.stringify([path, revision])) signal?.throwIfAborted() return hash.digest('hex') } @@ -1059,20 +1139,24 @@ class JsonlSessionPersistence extends SessionPersistence { signal?.throwIfAborted() await this.ensureRootEncoding() signal?.throwIfAborted() - const artifacts: Array = [] - const ids = new Set() - for (const selected of await this.listGenerations(signal)) { + const generations = await this.listGenerations(signal) + const headers = await settleLimited(this.listLimit, generations, async (selected) => { signal?.throwIfAborted() - let metadata: GenerationHeader | undefined try { - metadata = await this.readGenerationHeader(selected, undefined, signal) + return { selected, metadata: await this.readGenerationHeader(selected, undefined, signal) } } catch (error: unknown) { - if (error instanceof SessionFormatUnsupportedError || error instanceof SessionPersistenceCorruptionError) continue + if (error instanceof SessionFormatUnsupportedError || error instanceof SessionPersistenceCorruptionError) { + return { selected, metadata: undefined } + } throw error } - if (metadata === undefined) { - continue - } + }) + const artifacts: Array = [] + const ids = new Set() + for (const outcome of headers) { + if (outcome.status === 'rejected') throw outcome.reason + const { selected, metadata } = outcome.value + if (metadata === undefined) continue if (ids.has(metadata.header.id)) { throw new Error(`duplicate JSONL session id "${metadata.header.id}" appears in multiple project directories`) } @@ -1613,12 +1697,10 @@ class JsonlSessionPersistence extends SessionPersistence { } private async checkRootEncoding(): Promise { - for (const project of await this.listProjectDirs()) { - for (const dir of await this.listSessionDirs(project)) { - const incompatible = await this.findOppositeGenerationInDirectory(dir) - if (incompatible !== undefined) throw this.encodingMismatch(incompatible) - } - } + await this.mapSessionDirectories(async (dir) => { + const incompatible = await this.findOppositeGenerationInDirectory(dir) + if (incompatible !== undefined) throw this.encodingMismatch(incompatible) + }) } private async rejectLegacyFlatArtifact( @@ -1722,10 +1804,11 @@ class JsonlSessionPersistence extends SessionPersistence { /** JSONL persistence plugin label. */ export const name = 'session-persistence-jsonl' -/** Loader schema for the backend's root and physical encoding. */ +/** Loader schema for the backend's root, physical encoding, and listing concurrency. */ export const Config: z = z.object({ root: z.string().required(), compression: JsonlCompressionSchema, + listConcurrency: z.natural().min(1).default(DEFAULT_LIST_CONCURRENCY), }) /** @@ -1733,7 +1816,7 @@ export const Config: z = z.object({ * consumer unloads triggered by removing this provider. Consumers must own * cleanup in their injecting scope; concurrent root disposal is not ordered. * @param ctx - the owning plugin context. - * @param config - the storage root and physical encoding. + * @param config - the storage root, physical encoding, and listing concurrency. * @returns resolves when the service is ready. */ export async function apply(ctx: Context, config: Config): Promise { diff --git a/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts index fb0da7bc9b..760aaa46c3 100644 --- a/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts @@ -39,6 +39,18 @@ const readdirFailure = vi.hoisted(() => ({ error: undefined as Error | undefined, })) +/** + * While active, every `readdir` and `stat` waits `delayMs`, records how many + * such calls overlap, and fails with the error registered for its path. + */ +const listingProbe = vi.hoisted(() => ({ + active: false, + delayMs: 0, + inFlight: 0, + maxInFlight: 0, + failures: new Map(), +})) + const readTally = vi.hoisted(() => ({ /** Physical whole-file reads per path suffix; keyed by session id segment. */ bySuffix: new Map(), @@ -62,9 +74,22 @@ const pausedRead = vi.hoisted(() => ({ vi.mock('node:fs/promises', async (importOriginal) => { const actual = await importOriginal() + const probeListingRead = async (path: string, read: () => Promise): Promise => { + listingProbe.inFlight += 1 + listingProbe.maxInFlight = Math.max(listingProbe.maxInFlight, listingProbe.inFlight) + try { + await new Promise(resolvePromise => setTimeout(resolvePromise, listingProbe.delayMs)) + const failure = listingProbe.failures.get(path) + if (failure !== undefined) throw failure + return await read() + } finally { + listingProbe.inFlight -= 1 + } + } return { ...actual, stat: (async (...args: Parameters) => { + if (listingProbe.active) return probeListingRead(String(args[0]), () => actual.stat(...args)) if (String(args[0]) === statFailure.path && statFailure.error !== undefined) throw statFailure.error const identity = await actual.stat(...args) if (String(args[0]) !== statRace.path || !('mtimeNs' in identity)) return identity @@ -95,6 +120,7 @@ vi.mock('node:fs/promises', async (importOriginal) => { } }) as typeof actual.readFile, readdir: (async (...args: Parameters) => { + if (listingProbe.active) return probeListingRead(String(args[0]), () => actual.readdir(...args)) if (String(args[0]) === readdirFailure.path && readdirFailure.error !== undefined) { throw readdirFailure.error } @@ -2789,3 +2815,78 @@ describe('JsonlSessionPersistence: edge cases', () => { expect((await readAll(ctx.sessionPersistence, m.id)).events).toEqual(events) }) }) + +describe('JsonlSessionPersistence listing concurrency', () => { + let listingRoot: string + let listingCtx: Context + + beforeEach(async () => { + listingRoot = await mkdtemp(join(tmpdir(), 'dsh-jsonl-listing-')) + listingCtx = new Context() + await listingCtx.plugin(JsonlSessionPersistence, { root: listingRoot, compression: 'none', listConcurrency: 2 }) + }) + + afterEach(async () => { + listingProbe.active = false + listingProbe.delayMs = 0 + listingProbe.inFlight = 0 + listingProbe.maxInFlight = 0 + listingProbe.failures.clear() + await listingCtx.fiber.dispose() + await rm(listingRoot, { recursive: true, force: true }) + }) + + /** Persist one-turn Sessions under one cwd. */ + async function seed(cwd: string, ids: readonly string[]): Promise { + for (const id of ids) await writeLog(listingCtx.sessionPersistence, meta(id, cwd), oneTurnLog()) + } + + /** Directory entries in the order the backend's listing reads them. */ + async function directoriesOf(dir: string): Promise { + return (await readdir(dir, { withFileTypes: true })).filter(entry => entry.isDirectory()).map(entry => join(dir, entry.name)) + } + + it('validates listConcurrency as a positive integer that defaults to 3', () => { + expect(JsonlSessionPersistence.Config({ root: listingRoot }).listConcurrency).toBe(3) + expect(JsonlSessionPersistence.Config({ root: listingRoot, listConcurrency: 1 }).listConcurrency).toBe(1) + expect(() => JsonlSessionPersistence.Config({ root: listingRoot, listConcurrency: 0 })).toThrow() + expect(() => JsonlSessionPersistence.Config({ root: listingRoot, listConcurrency: 1.5 })).toThrow() + }) + + it('bounds directory and stat reads across concurrent listings of one backend', async () => { + await seed('/work-a', ['bound-a1', 'bound-a2', 'bound-a3']) + await seed('/work-b', ['bound-b1', 'bound-b2', 'bound-b3']) + const expected = ['bound-a1', 'bound-a2', 'bound-a3', 'bound-b1', 'bound-b2', 'bound-b3'] + expect((await listingCtx.sessionPersistence.list()).map(s => s.header.id).sort()).toEqual(expected) + + listingProbe.delayMs = 5 + listingProbe.active = true + const listings = await Promise.all([ + listingCtx.sessionPersistence.list(), + listingCtx.sessionPersistence.list(), + ]) + listingProbe.active = false + + expect(listingProbe.maxInFlight).toBe(2) + for (const listing of listings) expect(listing.map(s => s.header.id).sort()).toEqual(expected) + }) + + it('reports the failure a sequential traversal would reach first', async () => { + await seed('/work-a', ['order-a1', 'order-a2']) + await seed('/work-b', ['order-b1', 'order-b2']) + await listingCtx.sessionPersistence.list() + const [earlierProject, laterProject] = await directoriesOf(listingRoot) as [string, string] + const [earlierSession, laterSession] = await directoriesOf(earlierProject) as [string, string] + const projectFailure = Object.assign(new Error('later project denied'), { code: 'EACCES' }) + const laterSessionFailure = Object.assign(new Error('later session denied'), { code: 'EACCES' }) + const earlierSessionFailure = Object.assign(new Error('earlier session denied'), { code: 'EACCES' }) + listingProbe.active = true + + listingProbe.failures.set(laterProject, projectFailure) + await expect(listingCtx.sessionPersistence.list()).rejects.toBe(projectFailure) + listingProbe.failures.set(laterSession, laterSessionFailure) + await expect(listingCtx.sessionPersistence.list()).rejects.toBe(laterSessionFailure) + listingProbe.failures.set(earlierSession, earlierSessionFailure) + await expect(listingCtx.sessionPersistence.list()).rejects.toBe(earlierSessionFailure) + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3ac8d8bddb..977eeaa9a8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11984,6 +11984,9 @@ importers: koffi: specifier: 3.1.1 version: 3.1.1 + p-limit: + specifier: 7.3.3 + version: 7.3.3 devDependencies: '@deepseek-ai/cordis': specifier: workspace:~ @@ -21624,6 +21627,10 @@ packages: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} + p-limit@7.3.3: + resolution: {integrity: sha512-SKgVvDU5TNIxQ4r30U75BNLC6a+6GOEjngrk/ysM5+Zu3oJuk1SC5ApzIzY/7PjCEjoVPMWJ0zho/4QUiEB7TQ==} + engines: {node: '>=20'} + p-locate@5.0.0: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} @@ -23137,6 +23144,10 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + yocto-queue@1.2.2: + resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} + engines: {node: '>=12.20'} + yoctocolors@2.1.2: resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} engines: {node: '>=18'} @@ -29369,6 +29380,10 @@ snapshots: dependencies: yocto-queue: 0.1.0 + p-limit@7.3.3: + dependencies: + yocto-queue: 1.2.2 + p-locate@5.0.0: dependencies: p-limit: 3.1.0 @@ -30922,6 +30937,8 @@ snapshots: yocto-queue@0.1.0: {} + yocto-queue@1.2.2: {} + yoctocolors@2.1.2: {} zip-stream@4.1.1: From 768473d755eb06ff8dadda824bacab83260a2acd Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 14 Oct 2026 00:11:58 +0800 Subject: [PATCH 30/35] perf(session-reference): resolve label services once per candidate listing Every `@` candidate query labeled each listed Session through three service lookups, and each lookup and method read through a Cordis service proxy allocates wrappers, so the labeling step grew with the Session count. The resolver now resolves sessions, projections, and the projection cache once per call, indexes attached Sessions in one map, and binds cachedSnapshot once for cold Sessions. --- .../context/session-reference/src/index.ts | 52 +++++++++++++------ 1 file changed, 35 insertions(+), 17 deletions(-) diff --git a/packages/context/session-reference/src/index.ts b/packages/context/session-reference/src/index.ts index f2082d85e2..2423410bf9 100644 --- a/packages/context/session-reference/src/index.ts +++ b/packages/context/session-reference/src/index.ts @@ -63,6 +63,9 @@ ${REFERENCE_WARNING} ` const PROMPT_SUFFIX = '\n' +/** Projection keys a discovery label is answered from. */ +const LABEL_KEYS = ['title', 'subagent'] as const + declare module '@deepseek-ai/cordis' { interface Context { sessionReferenceResolver: SessionReferenceResolver @@ -202,7 +205,7 @@ export class SessionReferenceResolver extends TypertRemoteService { const records = (await settleWithCancellation(this.ctx.sessionQuery.listSessions(signal), signal)) .filter(record => record.header.id !== agent.id) .map((record, index) => ({ record, index })) - const labelled = records.map(({ record, index }) => ({ record, index, ...this.projectedLabels(record) })) + const labelled = this.projectedLabels(records) return labelled.filter(({ record, label, displayTitle }) => { if (needle === '') return true return record.header.id.toLocaleLowerCase().includes(needle) @@ -223,7 +226,7 @@ export class SessionReferenceResolver extends TypertRemoteService { } /** - * The mention label and display title a Session's projections can answer without reading its log. + * The mention label and display title each listed Session's projections can answer without reading its log. * * Attachment is decided by the store at read time, not by the listing: * a session that attached in between would otherwise be answered from a @@ -240,23 +243,38 @@ export class SessionReferenceResolver extends TypertRemoteService { * session that no projection can answer for — one persisted before the * cache was composed — is labeled by its id and cannot be found by its * title until it is opened once, which checkpoints it. - * @param record - the listed session, live or cold. - * @returns the title-backed mention label and the subagent-label-first display title. + * + * Services are resolved once per call and the cache's snapshot method is + * bound once: a service lookup per listed session would dominate the call. + * @param items - the listed sessions, live or cold. + * @returns each item with its title-backed mention label and subagent-label-first display title. */ - private projectedLabels(record: SessionRecord): { label: string; displayTitle: string } { - const attached = this.ctx.get('sessions')?.get(record.header.id) + private projectedLabels( + items: readonly T[], + ): Array { const projections = this.ctx.get('sessionProjections') - const snapshot = attached !== undefined && projections !== undefined - ? projections.snapshot(attached, ['title', 'subagent']) - : this.ctx.get('sessionProjectionCache')?.cachedSnapshot(record.header, ['title', 'subagent']) - const label = titleOf(snapshot) ?? record.header.id - const subagent = snapshot?.values.subagent - return { - label, - displayTitle: subagent === undefined || subagent === null - ? label - : subagent.label ?? label, - } + const sessions = this.ctx.get('sessions') + // An attached session answers from its live cut only while the registry is mounted. + const attached = new Map(sessions === undefined || projections === undefined + ? undefined + : sessions.list().map(session => [session.id, session])) + const cache = this.ctx.get('sessionProjectionCache') + const cachedSnapshot = cache?.cachedSnapshot.bind(cache) + return items.map((item) => { + const session = attached.get(item.record.header.id) + const snapshot = session !== undefined && projections !== undefined + ? projections.snapshot(session, LABEL_KEYS) + : cachedSnapshot?.(item.record.header, LABEL_KEYS) + const label = titleOf(snapshot) ?? item.record.header.id + const subagent = snapshot?.values.subagent + return { + ...item, + label, + displayTitle: subagent === undefined || subagent === null + ? label + : subagent.label ?? label, + } + }) } /** From 33c37200f8df66e6f73739476790afaa3580ef81 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 14 Oct 2026 00:12:08 +0800 Subject: [PATCH 31/35] perf(token-meter): keep only system prompts in the context breakdown state contextBreakdown kept one entry per retained surface node, so its checkpoint grew with the message count (7.13 MB across 836 local Sessions, up to 103 KB for one) and every cached Session listing validated it. The state now holds the surviving system prompts, one surface total, and at most one shadow-price claim. Appends add their price; a replacement subtracts the adjacent compaction/summary or compaction/prune price and drops the system prompts its shadowedSeqs name; a system-prompt rewrite subtracts the retained prompt price. Other unmetered replacements fold at zero delta, as in contextPressure. State version 6 discards positional rows. Folding all 836 local logs with both versions gives identical figures after every event; retained state drops to 110 KB. --- ...xt-breakdown-system-prompt-state.i18n.yaml | 22 +++ ...3-context-breakdown-system-prompt-state.md | 36 ++++ ...ontext-breakdown-system-prompt-state.zh.md | 36 ++++ ...istory-system-prompt-replacement.i18n.yaml | 8 +- ...02-in-history-system-prompt-replacement.md | 4 +- ...in-history-system-prompt-replacement.zh.md | 4 +- packages/llm/token-meter/README.i18n.yaml | 8 +- packages/llm/token-meter/README.md | 4 +- packages/llm/token-meter/README.zh.md | 4 +- .../token-meter/src/breakdown-projection.ts | 172 ++++++++++++++---- packages/llm/token-meter/src/surface-fold.ts | 8 +- .../llm/token-meter/src/surface-projection.ts | 2 +- .../context-breakdown-projection.spec.ts | 84 ++++++--- 13 files changed, 316 insertions(+), 76 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-10-13-context-breakdown-system-prompt-state.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-10-13-context-breakdown-system-prompt-state.md create mode 100644 .agents/notes/implemented/architecture/2026-10-13-context-breakdown-system-prompt-state.zh.md diff --git a/.agents/notes/implemented/architecture/2026-10-13-context-breakdown-system-prompt-state.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-13-context-breakdown-system-prompt-state.i18n.yaml new file mode 100644 index 0000000000..a5dc5cf3f9 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-13-context-breakdown-system-prompt-state.i18n.yaml @@ -0,0 +1,22 @@ +# Bilingual-pair consistency record for 2026-10-13-context-breakdown-system-prompt-state.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-13-context-breakdown-system-prompt-state.md +/agent-note-context-breakdown-retains-only-system-prompts: + en: 9b155f87202e037e + zh: 8d4190c00412b892 +/agent-note-context-breakdown-retains-only-system-prompts/problem: + en: 7cbbdfbe7da9ae7a + zh: 50368b272d7b5ebc +/agent-note-context-breakdown-retains-only-system-prompts/decision: + en: 52d5c42112c5c6dc + zh: 744dbbcc52193300 +/agent-note-context-breakdown-retains-only-system-prompts/verification: + en: 68a7375ca7fe99e8 + zh: e3c7d108740e8952 +/agent-note-context-breakdown-retains-only-system-prompts/alternatives-considered: + en: 2bd0e83c5298da2b + zh: 336259b6985eb7b5 +/agent-note-context-breakdown-retains-only-system-prompts/consequences: + en: 839a919ff16bc53c + zh: bf0d4f3a783ead1d diff --git a/.agents/notes/implemented/architecture/2026-10-13-context-breakdown-system-prompt-state.md b/.agents/notes/implemented/architecture/2026-10-13-context-breakdown-system-prompt-state.md new file mode 100644 index 0000000000..7d4e69bd0f --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-13-context-breakdown-system-prompt-state.md @@ -0,0 +1,36 @@ +# Agent Note: Context breakdown retains only system prompts + +Status: implemented + +English | [中文](2026-10-13-context-breakdown-system-prompt-state.zh.md) + +## Problem + +`contextBreakdown` kept one `{ seq, heuristicTokens, system }` entry per retained surface node so that any replacement could subtract the exact price of its range and re-select the last nonempty system prompt. Its checkpoint therefore grew with the number of messages in the model context. In a local corpus of 836 Sessions, 151,122 entries made the unit's rows 7.13 MB of the projection cache and up to 103 KB for one Session, and every cached Session listing validated all of them. Long tool-heavy runs below the compaction threshold kept about 2,000 entries each. + +## Decision + +[`breakdown-projection.ts`](../../../../packages/llm/token-meter/src/breakdown-projection.ts) retains the surviving `system/message` nodes in surface order with their heuristic prices, one running surface total, and at most one armed shadow-price claim. State version 6 discards version-5 rows; other projection keys keep their rows. + +- An append adds its price; a `system/message` append also joins the end of the system list. +- `compaction/summary` and `compaction/prune` arm a claim from `shadowedRange`, `shadowedTokenCount`, and the retained system prompts named by `shadowedSeqs`. The next event consumes or expires it. A replacement over the claimed range subtracts the claim price and drops those prompts; a replacement naming another range, or putting a `system/message` in place of a metered range, throws. +- An unmetered replacement of one retained system prompt subtracts that prompt's own price. A `system/message` replacement keeps its position; any other replacement removes it. +- Any other unmetered replacement folds at zero delta, the rule `contextPressure` already applies. + +`systemTokens` is the last retained prompt with a positive price, and `messageTokens` is the surface total minus it; both are floored at zero. `shadowedSeqs` is the authoritative set of shadowed nodes, so membership decides which prompts leave without locating non-system endpoints in surface order. + +## Verification + +Folding all 836 stored logs with the version-5 and version-6 definitions produced identical breakdowns after every event. The logs held 2,206 metered replacements, 106 system-prompt rewrites, and no other replacement. Retained state fell from 7.13 MB to 110 KB, with at most five surviving system prompts in one Session. + +## Alternatives considered + +**Keep per-node entries with a faster validator.** A hand-written node check cut listing validation from 75 ms to 4.5 ms but kept 7.13 MB of rows, an O(surface) array copy per surface event, and checkpoint writes proportional to the message count. + +**Columnar per-node entries.** Parallel arrays shrink rows about 5.7 times but keep O(surface) state for information the shadow-price events already carry. + +**Exact pricing of unmetered non-system replacements.** That requires per-node prices. Current producers meter every non-system replacement, and released formats from v0 carry the shadow-price fields, so the remaining case is a log that breaks producer adjacency; it drifts like `contextPressure`. + +## Consequences + +Checkpoint size and per-event cost follow the number of surviving system prompts rather than messages, and cached listings no longer validate per-message entries. The figures stay exact for metered replacements and system-prompt rewrites, and their sum equals the `measure()` heuristic total under those producers. An unmetered non-system replacement leaves the figures off by that replacement's price difference. A future range replacement that inserts a system prompt is rejected until the fold can order it among the retained prompts. This supersedes the positional-entry choice for this unit in the [in-history system-prompt replacement](../feature/2026-09-02-in-history-system-prompt-replacement.md) note; `measure()` keeps positional nodes for route pricing. diff --git a/.agents/notes/implemented/architecture/2026-10-13-context-breakdown-system-prompt-state.zh.md b/.agents/notes/implemented/architecture/2026-10-13-context-breakdown-system-prompt-state.zh.md new file mode 100644 index 0000000000..bab33f441d --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-13-context-breakdown-system-prompt-state.zh.md @@ -0,0 +1,36 @@ +# Agent Note: 上下文构成只保留系统提示词 + +Status: implemented + +[English](2026-10-13-context-breakdown-system-prompt-state.md) | 中文 + +## Problem + +`contextBreakdown` 曾为每个保留的 surface 节点存一条 `{ seq, heuristicTokens, system }`,以便任意替换都能减去其范围的精确价格,并重新选出最后一个非空系统提示词。因此它的检查点随模型上下文中的消息数增长。在一个包含 836 个 Session 的本地语料中,151,122 条条目让该单元的行占用 projection cache 的 7.13 MB,单个 Session 最多 103 KB,而每次缓存会话列表都要校验全部条目。低于压缩阈值、工具调用密集的长时间运行各自保留约 2,000 条条目。 + +## Decision + +[`breakdown-projection.ts`](../../../../packages/llm/token-meter/src/breakdown-projection.ts) 按 surface 顺序保留存活的 `system/message` 节点及其启发式价格、一个 surface 运行总量,以及至多一个已武装的影子价 claim。状态版本 6 丢弃版本 5 的行;其他 projection key 保留各自的行。 + +- 追加加上其价格;`system/message` 追加还会加入系统列表末尾。 +- `compaction/summary` 和 `compaction/prune` 依据 `shadowedRange`、`shadowedTokenCount` 以及 `shadowedSeqs` 中列出的已保留系统提示词武装 claim。下一个事件消费或使其失效。覆盖所 claim 范围的替换减去 claim 价格并移除这些提示词;指向其他范围的替换,或以 `system/message` 取代已计量范围的替换,会抛出错误。 +- 对单个已保留系统提示词的未计量替换减去该提示词自身的价格。`system/message` 替换保持其位置;其他替换将其移除。 +- 其他未计量替换以零增量折叠,这与 `contextPressure` 已采用的规则相同。 + +`systemTokens` 是最后一个价格为正的保留提示词,`messageTokens` 是 surface 总量减去它;两者都不低于零。`shadowedSeqs` 是被遮蔽节点的权威集合,因此由成员关系决定哪些提示词离开,无需在 surface 顺序中定位非系统端点。 + +## Verification + +用版本 5 和版本 6 的定义折叠全部 836 份已存日志,每个事件之后的构成数字都完全相同。这些日志包含 2,206 次已计量替换、106 次系统提示词改写,没有其他替换。保留状态从 7.13 MB 降到 110 KB,单个 Session 最多有五个存活的系统提示词。 + +## Alternatives considered + +**保留逐节点条目,换用更快的校验器。** 手写节点检查把列表校验从 75 ms 降到 4.5 ms,但仍保留 7.13 MB 的行、每个 surface 事件一次 O(surface) 数组复制,以及与消息数成正比的检查点写入。 + +**列式逐节点条目。** 并行数组让行缩小约 5.7 倍,但仍为影子价事件已经携带的信息保留 O(surface) 状态。 + +**精确定价未计量的非系统替换。** 这需要逐节点价格。当前生产方会计量每一次非系统替换,自 v0 起的已发布格式都携带影子价字段,因此剩余情况只是破坏生产方相邻性的日志;它像 `contextPressure` 一样产生偏差。 + +## Consequences + +检查点大小和每个事件的成本取决于存活系统提示词的数量而不是消息数,缓存列表也不再校验逐消息条目。对已计量替换和系统提示词改写,数字保持精确,其和在这些生产方下等于 `measure()` 的启发式总量。未计量的非系统替换会让数字偏离该替换的价格差。未来插入系统提示词的范围替换会被拒绝,直到折叠能确定它在已保留提示词中的顺序。这取代了 [历史内系统提示词替换](../feature/2026-09-02-in-history-system-prompt-replacement.zh.md) 说明中针对该单元的位置条目选择;`measure()` 仍为路由定价保留位置节点。 diff --git a/.agents/notes/implemented/feature/2026-09-02-in-history-system-prompt-replacement.i18n.yaml b/.agents/notes/implemented/feature/2026-09-02-in-history-system-prompt-replacement.i18n.yaml index b8b63582e2..379e4f0ad9 100644 --- a/.agents/notes/implemented/feature/2026-09-02-in-history-system-prompt-replacement.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-02-in-history-system-prompt-replacement.i18n.yaml @@ -18,14 +18,14 @@ en: 554d01427ebebee1 zh: 664c9bf0a9337a04 /agent-note-in-history-system-prompt-replacement-for-cache-stable-prompt-changes/decision/presentation-and-accounting: - en: 00a91b4fb592e1e4 - zh: dac2e4f0a3ee9ecf + en: 800d579e2fcf2cce + zh: 1e92354d9161c50a /agent-note-in-history-system-prompt-replacement-for-cache-stable-prompt-changes/decision/compaction: en: c21a3e7a773860e5 zh: 566669e245ccdbaf /agent-note-in-history-system-prompt-replacement-for-cache-stable-prompt-changes/alternatives-considered: - en: f3dec242b2550529 - zh: 0397fa07cc1806bb + en: 02b49911bc4601d0 + zh: bfa19fd7c4337812 /agent-note-in-history-system-prompt-replacement-for-cache-stable-prompt-changes/consequences: en: beda064bcd194a2a zh: 8a3bb81d5edcd60a diff --git a/.agents/notes/implemented/feature/2026-09-02-in-history-system-prompt-replacement.md b/.agents/notes/implemented/feature/2026-09-02-in-history-system-prompt-replacement.md index 644592d2a1..82402feff9 100644 --- a/.agents/notes/implemented/feature/2026-09-02-in-history-system-prompt-replacement.md +++ b/.agents/notes/implemented/feature/2026-09-02-in-history-system-prompt-replacement.md @@ -43,7 +43,7 @@ An empty head with no active later system node represents no prompt. Dormant emp Web presents an appended in-history node at its own position. `SystemPromptNode` carries `{ seq, time, turn, step, text, update }`, `update` being true for an appended `system/message` that follows an earlier system node in the loaded window. Chat renders a non-empty update as a collapsed `system-prompt` card titled by the locale key `message.systemPromptUpdate`, and a `request/header` in the same turn and step does not repeat the prompt card; `inspectRequestPrompt` reports no system change for a header that follows an update. Trajectory folds an update following a loaded request header into a synthetic request-header fact with `promptChange.kind = 'system'`, so later requests show the effective prompt without a real header change. When the loaded window lacks the earlier system node, the update is presented as an initial prompt. Transcript projections skip it like every `system/message`. -`dsh-token-meter` prices the last nonempty surviving system node in surface order as `contextBreakdown.systemTokens`; every other visible node, including superseded prompts, contributes to `messageTokens`. Empty dormant nodes are ignored. The sum equals the fixed-heuristic surface total after every replacement, whether or not a shadow-price claim exists. Compact retained entries reuse the measurement surface planner: state and transitions cost O(current retained surface), not O(1) or O(total historical log). Replaced entries and message bodies are discarded, and state version 4 rejects scalar checkpoints. `cacheReadTokens` on subsequent assistant usage remains the observable provider-cache effect. +`dsh-token-meter` prices the last nonempty surviving system node in surface order as `contextBreakdown.systemTokens`; every other visible node, including superseded prompts, contributes to `messageTokens`. Empty dormant nodes are ignored. The unit retains only the surviving system prompts and a running surface total, and prices replacements from the adjacent shadow-price event ([system-prompt state](../architecture/2026-10-13-context-breakdown-system-prompt-state.md)). `cacheReadTokens` on subsequent assistant usage remains the observable provider-cache effect. Trajectory chooses the newer of the preceding real header and the preceding synthetic system header as the comparison state. A real header owns configuration and tools; an appended prompt can advance that state without another real header. Comparing only real headers would report A rather than B as the previous prompt for an A → B → C sequence. @@ -73,7 +73,7 @@ Chat and Trajectory interpret the effective prompt through the pure `uiConversat **Clear only the latest system node.** Empty nodes project to no message, so an older prompt would become effective again. Clearing all active versions preserves the meaning of an empty rendering without deleting conversation history. Rejected. -**Keep only scalar totals or prompt ancestry.** A scalar shadow price cannot identify which bucket lost the newest prompt or restore its predecessor. Prompt-only entries cannot locate arbitrary nonprompt replacement endpoints; `sourceEventSeqs` may also cite surviving prompts, and event sequence order differs from surface order after rewrites. Retaining compact current surface entries reuses the existing planner without full-log access, a second validator, or consumer-specific durable events. Summing all surviving prompts as system tokens would change the intended effective-prompt meaning rather than fix classification. +**Keep only scalar totals.** A scalar shadow price cannot identify which bucket lost the newest prompt or restore its predecessor. Summing all surviving prompts as system tokens would change the intended effective-prompt meaning rather than fix classification. Retained system prompts with the shadow-price event's `shadowedSeqs` answer both questions; [the system-prompt state note](../architecture/2026-10-13-context-breakdown-system-prompt-state.md) records that choice over positional entries. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-09-02-in-history-system-prompt-replacement.zh.md b/.agents/notes/implemented/feature/2026-09-02-in-history-system-prompt-replacement.zh.md index b734bc74f7..a2c8c60bbf 100644 --- a/.agents/notes/implemented/feature/2026-09-02-in-history-system-prompt-replacement.zh.md +++ b/.agents/notes/implemented/feature/2026-09-02-in-history-system-prompt-replacement.zh.md @@ -43,7 +43,7 @@ Status: implemented Web 在追加的历史内节点自己的位置呈现它。`SystemPromptNode` 携带 `{ seq, time, turn, step, text, update }`,其中 `update` 对已加载窗口内跟在更早系统节点之后的追加 `system/message` 为真。Chat 把非空的更新渲染为一张折叠的 `system-prompt` 卡片,标题取自 locale 键 `message.systemPromptUpdate`,同一 turn 与 step 内的 `request/header` 不会重复提示词卡片;`inspectRequestPrompt` 对跟在更新之后的 header 不报告系统变更。Trajectory 把跟在已加载请求 header 之后的更新折叠为一条合成的请求 header 事实,`promptChange.kind = 'system'`,因此之后的请求无需真实的 header 变更就能显示有效提示词。已加载窗口缺少更早的系统节点时,更新按初始提示词呈现。转录投影像对待所有 `system/message` 一样跳过它。 -`dsh-token-meter` 把 surface 顺序中最后一个非空且存活的系统节点计入 `contextBreakdown.systemTokens`;其余可见节点(包括被取代的提示词)计入 `messageTokens`。休眠空节点被忽略。每次替换后,两者之和都等于固定启发式 surface 总量,无论是否存在影子价 claim。紧凑的保留条目复用测量服务的 surface 规划器:状态和转换成本为 O(当前保留 surface),不是 O(1) 或 O(完整历史日志)。被替换条目和消息正文被丢弃,状态版本 4 拒绝标量检查点。后续 assistant 用量中的 `cacheReadTokens` 仍是可观察的提供方缓存效果。 +`dsh-token-meter` 把 surface 顺序中最后一个非空且存活的系统节点计入 `contextBreakdown.systemTokens`;其余可见节点(包括被取代的提示词)计入 `messageTokens`。休眠空节点被忽略。该单元只保留存活的系统提示词和一个 surface 运行总量,并依据紧邻的影子价事件为替换定价([系统提示词状态](../architecture/2026-10-13-context-breakdown-system-prompt-state.zh.md))。后续 assistant 用量中的 `cacheReadTokens` 仍是可观察的提供方缓存效果。 Trajectory 选择前一条真实 header 与前一条合成系统 header 中较新的一个作为比较状态。真实 header 拥有配置与工具;追加的提示词可以在没有另一条真实 header 时推进该状态。只比较真实 header 会在 A → B → C 序列中把 A 而不是 B 报告为先前提示词。 @@ -73,7 +73,7 @@ Chat 与 Trajectory 通过纯操作 `uiConversation.inspectSystemPrompt` 解释 **仅清除最新系统节点。** 空节点不投影为消息,因此更早的提示词会重新生效。清除所有生效版本才能保留空渲染文本的含义,同时不删除对话历史。被否决。 -**只保留标量总量或提示词祖先链。** 标量影子价无法判断最新提示词从哪个分类消失,也无法恢复其前一个版本。仅有提示词条目无法定位任意非提示词替换端点;`sourceEventSeqs` 还可能引用存活提示词,改写后的事件序号顺序也不同于 surface 顺序。保留紧凑的当前 surface 条目可以复用现有规划器,无需完整日志访问、第二套验证器或消费方专用持久事件。把所有存活提示词之和归入系统数字会改变有效提示词的含义,而不是修复分类。 +**只保留标量总量。** 标量影子价无法判断最新提示词从哪个分类消失,也无法恢复其前一个版本。把所有存活提示词之和归入系统数字会改变有效提示词的含义,而不是修复分类。保留系统提示词并结合影子价事件的 `shadowedSeqs` 可以回答这两个问题;[系统提示词状态说明](../architecture/2026-10-13-context-breakdown-system-prompt-state.zh.md)记录了为何以这种方式取代位置条目。 ## Consequences diff --git a/packages/llm/token-meter/README.i18n.yaml b/packages/llm/token-meter/README.i18n.yaml index 8e50701bef..17a68fbe02 100644 --- a/packages/llm/token-meter/README.i18n.yaml +++ b/packages/llm/token-meter/README.i18n.yaml @@ -24,8 +24,8 @@ en: 58de3b364ab5af6b zh: c5440af43d321e56 /deepseek-ai-dsh-token-meter/use-this-package/session-projections: - en: 0a4c3ae2763be227 - zh: 0e2c5a77f9b4b94f + en: 6720ae9c7a0602b2 + zh: 699a0b9927300aa3 /deepseek-ai-dsh-token-meter/use-this-package/composition: en: 1a0b7f49e01cba08 zh: 666213a1024767c5 @@ -45,8 +45,8 @@ en: c97959d84cf8a1a4 zh: f17570caa42a1a8e /deepseek-ai-dsh-token-meter/understand-the-implementation/projection-semantics: - en: 91930cc59e849205 - zh: a530fcc3d57133bc + en: d862c68a79e9eba1 + zh: 3a592a7a885cb6b2 /deepseek-ai-dsh-token-meter/further-exploration: en: 32cf70f8c66ca097 zh: a7daf5e0732f31c3 diff --git a/packages/llm/token-meter/README.md b/packages/llm/token-meter/README.md index f329fa1c56..51eee2f2ae 100644 --- a/packages/llm/token-meter/README.md +++ b/packages/llm/token-meter/README.md @@ -50,7 +50,7 @@ When the composition provides `ctx.sessionProjections`, token-meter registers th Image offload reprices existing node identities while preserving prior usage anchors. The fixed reference heuristic excludes `offloaded` metadata, so an offload decision does not change `contextBreakdown` or the scalar heuristic total; route-aware measurement replaces the selected visual prices with placeholder text. -`contextBreakdown` classifies the last nonempty surviving `system/message` in surface order as `systemTokens`; empty dormant nodes contribute nothing, and no nonempty system means zero. `messageTokens` includes every other visible node, including superseded prompts. Their sum always equals `measure().nodes[].heuristicTokens`, including after unmetered replacements, compaction, and per-node prompt clearing. `toolsTokens` follows the latest `request/header`. All three use the fixed heuristic, not route image pricing or file-handle projection; they are approximate composition, not billing or `projectedTokens`. +`contextBreakdown` classifies the last nonempty surviving `system/message` in surface order as `systemTokens`; empty dormant nodes contribute nothing, and no nonempty system means zero. `messageTokens` includes every other visible node, including superseded prompts. Their sum equals `measure().nodes[].heuristicTokens` after appends, metered compaction and pruning, and system-prompt rewrites or clearing; an unmetered replacement of a non-system range folds at zero delta, like `contextPressure`. `toolsTokens` follows the latest `request/header`. All three use the fixed heuristic, not route image pricing or file-handle projection; they are approximate composition, not billing or `projectedTokens`. `deriveTurnTokenUsage(events)` folds one complete turn into exact per-attempt and whole-turn usage for browser consumers. It returns no result when lifecycle evidence is missing, counts are unsafe, or exact totals conflict; each corresponding aggregate appears only when every participating attempt reports its optional cache, reasoning, or route value. @@ -100,7 +100,7 @@ Each `measure()` call synchronizes the fold to the current durable tail, then re ### Projection semantics -`contextBreakdown` retains plain-JSON `{ seq, heuristicTokens, system }` entries in surface order and reuses the measurement plan/commit fold. Its state and surface transitions are O(current retained surface), not O(1) and not O(total historical log); replaced entries and message bodies are not retained. State version 5 invalidates checkpoints priced with offload metadata. `contextPressure` remains the scalar shadow-price consumer: replacements without adjacent claims contribute zero delta. The usage fold retains one last-sample slot because legal logs never report usage for an earlier step after a later step reports usage. +`contextBreakdown` retains only the surviving `system/message` nodes with their heuristic prices, one running surface total, and at most one armed shadow-price claim; a replacement subtracts the adjacent claim, whose `shadowedSeqs` also name the system prompts it removes, or the retained price of the one system prompt it rewrites ([decision](../../../.agents/notes/implemented/architecture/2026-10-13-context-breakdown-system-prompt-state.md)). Its state follows the number of surviving system prompts, not the message count; state version 6 invalidates positional checkpoints. `contextPressure` is the other scalar shadow-price consumer: replacements without adjacent claims contribute zero delta. The usage fold retains one last-sample slot because legal logs never report usage for an earlier step after a later step reports usage. `./estimate` exports the stateless text/content estimators used by tool-result retention. Model adapters supply request-image costs through `imageRequestPricing`. diff --git a/packages/llm/token-meter/README.zh.md b/packages/llm/token-meter/README.zh.md index 0fbbbdc2be..0dc929f492 100644 --- a/packages/llm/token-meter/README.zh.md +++ b/packages/llm/token-meter/README.zh.md @@ -50,7 +50,7 @@ const price = ctx.tokenMeter.estimateMessage(message) 图片省略重新计算现有节点的价格,同时保留此前的用量锚点。固定引用启发式规则不计入 `offloaded` 元数据,因此一次省略决定不改变 `contextBreakdown` 或标量启发式总量,按路由的测量则把所选图片的视觉价格换成占位文本价格。 -`contextBreakdown` 把 surface 顺序中最后一个非空且存活的 `system/message` 归入 `systemTokens`;休眠的空节点不贡献 token,没有非空系统消息时为零。`messageTokens` 包含其余所有可见节点,包括被取代的提示词。两者之和始终等于 `measure().nodes[].heuristicTokens`,未计量替换、压缩和逐节点清空提示词之后也成立。`toolsTokens` 跟随最新 `request/header`。三个数字都使用固定启发式规则,而非路由图片定价或文件句柄投影;它们是近似构成,不是计费数据或 `projectedTokens`。 +`contextBreakdown` 把 surface 顺序中最后一个非空且存活的 `system/message` 归入 `systemTokens`;休眠的空节点不贡献 token,没有非空系统消息时为零。`messageTokens` 包含其余所有可见节点,包括被取代的提示词。在追加、已计量的压缩与裁剪、系统提示词改写或清空之后,两者之和等于 `measure().nodes[].heuristicTokens`;对非系统范围的未计量替换以零增量折叠,与 `contextPressure` 相同。`toolsTokens` 跟随最新 `request/header`。三个数字都使用固定启发式规则,而非路由图片定价或文件句柄投影;它们是近似构成,不是计费数据或 `projectedTokens`。 `deriveTurnTokenUsage(events)` 为浏览器消费方把一个完整轮次折叠为精确的逐次尝试与整轮用量。生命周期证据缺失、计数不安全或精确总量矛盾时不返回结果;只有每次参与的尝试都报告可选缓存、推理或路由值时,相应汇总才会出现。 @@ -100,7 +100,7 @@ const price = ctx.tokenMeter.estimateMessage(message) ### 投影语义 -`contextBreakdown` 按 surface 顺序保留纯 JSON 的 `{ seq, heuristicTokens, system }` 条目,并复用测量服务的 plan/commit fold。其状态与 surface 转换成本为 O(当前保留 surface),不是 O(1),也不是 O(完整历史日志);被替换条目和消息正文不保留。状态版本 5 使计入省略元数据的检查点失效。`contextPressure` 仍是标量影子价消费方:没有相邻 claim 的替换贡献零增量。用量 fold 保留一个最后样本槽,因为合法日志不会在更晚步骤报告用量后再次报告更早步骤的用量。 +`contextBreakdown` 只保留存活的 `system/message` 节点及其启发式价格、一个 surface 运行总量,以及至多一个已武装的影子价 claim;替换减去紧邻的 claim(其 `shadowedSeqs` 同时列出被移除的系统提示词),或减去被改写的那个系统提示词的保留价格([决策](../../../.agents/notes/implemented/architecture/2026-10-13-context-breakdown-system-prompt-state.zh.md))。其状态取决于存活系统提示词的数量,而非消息数;状态版本 6 使位置检查点失效。`contextPressure` 是另一个标量影子价消费方:没有相邻 claim 的替换贡献零增量。用量 fold 保留一个最后样本槽,因为合法日志不会在更晚步骤报告用量后再次报告更早步骤的用量。 `./estimate` 导出无服务状态的文字与内容估算函数,供工具结果保留复用。图片的实际请求成本由模型适配器的 `imageRequestPricing` 提供。 diff --git a/packages/llm/token-meter/src/breakdown-projection.ts b/packages/llm/token-meter/src/breakdown-projection.ts index dd6d4a5d55..66ea6ae398 100644 --- a/packages/llm/token-meter/src/breakdown-projection.ts +++ b/packages/llm/token-meter/src/breakdown-projection.ts @@ -1,14 +1,18 @@ /** * Heuristic composition of the current retained surface, independent of route - * image pricing and provider usage. Positional entries preserve system-prompt - * classification across replacements without retaining historical messages. + * image pricing and provider usage. The state keeps the surviving system + * prompts in surface order and one running surface total; every other node is + * priced only as a delta, so state size follows the system prompts, not the + * message count. */ import { z } from 'zod' -import { canonicalHeader, isSurfaceEvent, SessionSeq } from '@deepseek-ai/dsh-session' +import { canonicalHeader, deriveEventMessage, isSurfaceEvent, SessionSeq } from '@deepseek-ai/dsh-session' +import type { SurfaceEvent } from '@deepseek-ai/dsh-session' import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' -import { estimateToolsTokens } from './estimate.ts' -import { commitSurfaceTokens, planSurfaceTokens } from './surface-fold.ts' +// Type-only: the `compaction/*` SessionEventMap merges (shadow-price events). +import type {} from '@deepseek-ai/dsh-compaction' +import { estimateMessage, estimateToolsTokens } from './estimate.ts' // Import for the `contextBreakdown` SessionProjectionStateMap key merge. import type {} from './projection.ts' @@ -27,52 +31,156 @@ const breakdownSchema = z.object({ messageTokens: tokenCount, }).strict() -/** Plain-JSON checkpoint: one compact entry per retained surface position. */ +/** One surviving `system/message` node and its heuristic price. */ +const systemNodeSchema = z.object({ + seq: sessionSeq, + tokens: tokenCount, +}).strict() + +/** + * Shadow price armed by the `compaction/summary` or `compaction/prune` event + * immediately before a replacement: the replaced range, its heuristic price, + * and the surviving system prompts inside it. + */ +const claimSchema = z.object({ + start: sessionSeq, + end: sessionSeq, + tokens: tokenCount, + systemSeqs: z.array(sessionSeq), +}).strict() + +/** Plain-JSON checkpoint: surviving system prompts in surface order, the surface total, and an armed claim. */ const contextBreakdownStateSchema = z.object({ - nodes: z.array(z.object({ - seq: sessionSeq, - heuristicTokens: tokenCount, - system: z.boolean(), - }).strict()), + systems: z.array(systemNodeSchema), + surfaceTokens: tokenCount, + claim: claimSchema.optional(), breakdown: breakdownSchema, }).strict() type ContextBreakdownState = z.infer +type SystemNode = ContextBreakdownState['systems'][number] +type BreakdownClaim = z.infer + +/** The system prompts and surface total after one surface event. */ +interface SurfaceTotals { + readonly systems: SystemNode[] + readonly surfaceTokens: number +} + +/** + * Place one surface event's priced node. + * @param state - state before the event, without its armed claim. + * @param claim - shadow price armed by the immediately preceding event, if any. + * @param event - the surface event. + * @param tokens - the event message's heuristic price. + * @returns the system prompts and surface total after the event. + * @throws when a metered replacement names another range than its claim, or + * puts a system message in place of a metered range. + */ +function placeSurfaceEvent( + state: ContextBreakdownState, + claim: BreakdownClaim | undefined, + event: SurfaceEvent, + tokens: number, +): SurfaceTotals { + const op = event.surfaceOp + const node: SystemNode = { seq: event.seq, tokens } + const isSystem = event.type === 'system/message' + if (op === 'append') { + return { systems: isSystem ? [...state.systems, node] : state.systems, surfaceTokens: state.surfaceTokens + tokens } + } + if (claim !== undefined) { + if (claim.start !== op.startSeq || claim.end !== op.endSeq) { + throw new Error( + `context breakdown: replace at seq ${event.seq} over range ${op.startSeq}-${op.endSeq} has no adjacent shadow price` + + ` (armed claim covers ${claim.start}-${claim.end})`, + ) + } + if (isSystem) { + throw new Error( + `context breakdown: replace at seq ${event.seq} puts a system message in place of a metered range;` + + ' its order among the surviving system prompts is unknown', + ) + } + const shadowed = new Set(claim.systemSeqs) + return { + systems: state.systems.filter(system => !shadowed.has(system.seq)), + surfaceTokens: state.surfaceTokens + tokens - claim.tokens, + } + } + const index = op.startSeq === op.endSeq ? state.systems.findIndex(system => system.seq === op.startSeq) : -1 + const replaced = state.systems[index] + if (replaced === undefined) { + // Bounded state cannot price an unmetered non-system range, so it folds at + // zero delta; current producers meter every such replacement. + return { systems: state.systems, surfaceTokens: state.surfaceTokens } + } + return { + systems: isSystem ? state.systems.with(index, node) : state.systems.toSpliced(index, 1), + surfaceTokens: state.surfaceTokens + tokens - replaced.tokens, + } +} + +/** Publish totals, keeping the previous breakdown object when its figures are unchanged. */ +function withTotals(state: ContextBreakdownState, totals: SurfaceTotals): ContextBreakdownState { + const surfaceTokens = Math.max(0, totals.surfaceTokens) + const systemTokens = totals.systems.findLast(system => system.tokens > 0)?.tokens ?? 0 + const messageTokens = Math.max(0, surfaceTokens - systemTokens) + const breakdown = systemTokens === state.breakdown.systemTokens && messageTokens === state.breakdown.messageTokens + ? state.breakdown + : { systemTokens, toolsTokens: state.breakdown.toolsTokens, messageTokens } + return { ...state, systems: totals.systems, surfaceTokens, breakdown } +} + +/** Drop an armed claim; the state is returned unchanged when none is armed. */ +function withoutClaim(state: ContextBreakdownState): ContextBreakdownState { + if (state.claim === undefined) return state + const { claim: _expired, ...unclaimed } = state + return unclaimed +} /** * Context composition with the last nonempty surviving system in surface * order classified as system tokens; all other visible prices are messages. - * Replacements use the measurement planner, not shadow-price claims. State - * and surface transitions cost O(current retained surface), not O(log length). - * Tools are priced from the latest request header. No route pricing applies. + * Appends add their price; a replacement subtracts the shadow price of the + * adjacent `compaction/summary` or `compaction/prune` event, whose + * `shadowedSeqs` also name the system prompts it removes, and a system-prompt + * rewrite subtracts the retained price of the system node it replaces. Tools + * are priced from the latest request header. No route pricing applies. */ export const contextBreakdownProjectionDefinition = { key: 'contextBreakdown', - stateVersion: 5, + stateVersion: 6, stateSchema: contextBreakdownStateSchema, init: (): ContextBreakdownState => ({ - nodes: [], + systems: [], + surfaceTokens: 0, breakdown: { systemTokens: 0, toolsTokens: 0, messageTokens: 0 }, }), apply: (state, event) => { + if (event.type === 'compaction/summary' || event.type === 'compaction/prune') { + const shadowed = new Set(event.data.shadowedSeqs) + return { + ...state, + claim: { + start: SessionSeq(event.data.shadowedRange.start), + end: SessionSeq(event.data.shadowedRange.end), + tokens: event.data.shadowedTokenCount, + systemSeqs: state.systems.filter(system => shadowed.has(system.seq)).map(system => system.seq), + }, + } + } + // A claim prices only the event immediately after its metering event. + const unclaimed = withoutClaim(state) if (event.type === 'request/header') { const toolsTokens = estimateToolsTokens(canonicalHeader(event.data.header)) - return toolsTokens === state.breakdown.toolsTokens - ? state - : { ...state, breakdown: { ...state.breakdown, toolsTokens } } + return toolsTokens === unclaimed.breakdown.toolsTokens + ? unclaimed + : { ...unclaimed, breakdown: { ...unclaimed.breakdown, toolsTokens } } } - if (!isSurfaceEvent(event)) return state - const plan = planSurfaceTokens(state.nodes, event) - const nodes = [...state.nodes] - commitSurfaceTokens(nodes, { - ...plan, - node: { seq: event.seq, heuristicTokens: plan.tokens, system: event.type === 'system/message' }, - }) - const systemTokens = nodes.findLast(node => node.system && node.heuristicTokens > 0)?.heuristicTokens ?? 0 - const messageTokens = state.breakdown.systemTokens + state.breakdown.messageTokens + plan.deltaTokens - systemTokens - const breakdown = systemTokens === state.breakdown.systemTokens && messageTokens === state.breakdown.messageTokens - ? state.breakdown - : { systemTokens, toolsTokens: state.breakdown.toolsTokens, messageTokens } - return { nodes, breakdown } + if (!isSurfaceEvent(event)) return unclaimed + const message = deriveEventMessage(event) + const tokens = message === null ? 0 : estimateMessage(message) + return withTotals(unclaimed, placeSurfaceEvent(unclaimed, state.claim, event, tokens)) }, wire: { viewSchema: breakdownSchema, diff --git a/packages/llm/token-meter/src/surface-fold.ts b/packages/llm/token-meter/src/surface-fold.ts index 29df77f87a..22235ba032 100644 --- a/packages/llm/token-meter/src/surface-fold.ts +++ b/packages/llm/token-meter/src/surface-fold.ts @@ -1,8 +1,8 @@ /** - * Positional pricing shared by measurement and the context-breakdown fold: - * measurement retains attachment details for route pricing; breakdown keeps - * only retained node identities, heuristic prices, and system classification. - * The occupancy projection uses the scalar shadow-price protocol instead. + * Positional pricing for measurement: each retained node keeps its identity, + * heuristic price, and the attachment details route pricing replaces. The + * occupancy and context-breakdown projections use the scalar shadow-price + * protocol instead. * * The fold is a plan/commit pair: {@link planSurfaceTokens} runs every * fallible step read-only and {@link commitSurfaceTokens} mutates in place, diff --git a/packages/llm/token-meter/src/surface-projection.ts b/packages/llm/token-meter/src/surface-projection.ts index c8d81f02e9..b23d31e192 100644 --- a/packages/llm/token-meter/src/surface-projection.ts +++ b/packages/llm/token-meter/src/surface-projection.ts @@ -2,7 +2,7 @@ * The context-pressure projection's O(1) surface-token fold. * * Its checkpoint keeps scalar totals rather than the retained surface used - * by context breakdown. Replacements ride the compact + * by measurement. Replacements ride the compact * seam's shadow-price protocol: the metering event immediately before a * surface `replace` (`compaction/summary` or `compaction/prune`) states the * heuristic price of the exact replaced range, so the fold keeps a running diff --git a/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts b/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts index d6fee3cb04..9818d45163 100644 --- a/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts +++ b/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts @@ -294,21 +294,35 @@ describe('contextBreakdown session projection', () => { expect(agree()).toBeLessThan(grown) }) - it('prices unmetered replacements and rejects absent ranges without mutating prior state', async () => { - const { session } = await harness() + it('folds an unmetered non-system replacement at zero delta and rejects contradicting claims without mutating prior state', async () => { + const { ctx, session } = await harness() const first = appendUser(session, 'first message') const last = appendUser(session, 'last message') const definition = contextBreakdownProjectionDefinition const state = session.snapshotEvents().reduce(definition.apply, definition.init()) const before = JSON.stringify(state) - const replacement = session.append('user/message', createUserMessage({ - content: [{ type: 'text', text: 'summary' }], source: { kind: 'user' }, - }), { surfaceOp: { op: 'replace', startSeq: first, endSeq: last }, sourceEventSeqs: [first, last] }) - expect(definition.wire.view(definition.apply(state, replacement)).messageTokens).toBe(10) - expect(JSON.stringify(state)).toBe(before) - const invalid = { ...replacement, surfaceOp: { op: 'replace', startSeq: SessionSeq(999), endSeq: last } } as SessionEvent - expect(() => definition.apply(state, invalid)).toThrow('invalid current range') + const summary = createUserMessage({ content: [{ type: 'text', text: 'summary' }], source: { kind: 'user' } }) + const replacement = session.append('user/message', summary, { + surfaceOp: { op: 'replace', startSeq: first, endSeq: last }, sourceEventSeqs: [first, last], + }) + // Bounded state cannot price a range without an adjacent shadow price. + expect(definition.wire.view(definition.apply(state, replacement))).toEqual(definition.wire.view(state)) expect(JSON.stringify(state)).toBe(before) + + appendSummaryMeter(ctx, session, replacement.seq, replacement.seq) + const claimed = session.snapshotEvents().reduce(definition.apply, definition.init()) + const armed = JSON.stringify(claimed) + const elsewhere = { ...replacement, seq: SessionSeq(session.seq), surfaceOp: { op: 'replace', startSeq: first, endSeq: first } } as SessionEvent + expect(() => definition.apply(claimed, elsewhere)).toThrow('has no adjacent shadow price') + const asSystem = { + type: 'system/message', + seq: SessionSeq(session.seq), + time: 0, + data: { turn: 1, step: 1, message: createSystemMessage('prompt') }, + surfaceOp: { op: 'replace', startSeq: replacement.seq, endSeq: replacement.seq }, + } as SessionEvent + expect(() => definition.apply(claimed, asSystem)).toThrow('puts a system message in place of a metered range') + expect(JSON.stringify(claimed)).toBe(armed) }) it('classifies the last nonempty system by position despite rewrites and extra source citations', async () => { @@ -326,15 +340,19 @@ describe('contextBreakdown session projection', () => { .toBe(ctx.tokenMeter.measure(session).nodes.reduce((total, node) => total + node.heuristicTokens, 0)) } agree('last prompt in surface order') + appendSummaryMeter(ctx, session, question, question) question = session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'rewritten question' }], source: { kind: 'user' }, }), { surfaceOp: { op: 'replace', startSeq: question, endSeq: question }, sourceEventSeqs: [question] }).seq expect(question).toBeGreaterThan(middle) - // Source-event references can cite a surviving prompt outside the replaced span. + // Source-event references can cite a surviving prompt outside the replaced + // span; the shadow price's own node list decides which prompts leave. + appendSummaryMeter(ctx, session, question, middle) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'middle summary' }], source: { kind: 'user' }, }), { surfaceOp: { op: 'replace', startSeq: question, endSeq: middle }, sourceEventSeqs: [question, middle, tail] }) agree('last prompt in surface order') + // An unmetered replacement of a retained system prompt is priced from that prompt's own entry. session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'tail summary' }], source: { kind: 'user' }, }), { surfaceOp: { op: 'replace', startSeq: tail, endSeq: tail }, sourceEventSeqs: [tail, head] }) @@ -365,8 +383,9 @@ describe('contextBreakdown session projection', () => { expect(projected(ctx, session)).toMatchObject({ systemTokens: 0, messageTokens: 10 }) }) - it('retains only compact current surface entries as history grows and compacts', async () => { + it('retains only system prompts and one surface total as history grows and compacts', async () => { const { ctx, session } = await harness() + appendSystem(session, 'head') const first = appendUser(session, 'the first of many messages') for (let index = 0; index < 24; index += 1) appendUser(session, `message number ${index} with some text`) const last = appendUser(session, 'the last message before compaction') @@ -375,14 +394,17 @@ describe('contextBreakdown session projection', () => { if (current === undefined) throw new Error('registered context breakdown has no state') return current } - expect(state().nodes).toHaveLength(26) - expect(Object.keys(state().nodes[0]!).sort()).toEqual(['heuristicTokens', 'seq', 'system']) - const shadowed = [...session.surface.nodes] + expect(Object.keys(state()).sort()).toEqual(['breakdown', 'surfaceTokens', 'systems']) + expect(state().systems).toHaveLength(1) + appendSummaryMeter(ctx, session, first, last) + expect(state().claim).toMatchObject({ start: first, end: last, systemSeqs: [] }) + const shadowed = session.surface.nodes.filter(seq => seq >= first) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'summary' }], source: { kind: 'test' }, }), { surfaceOp: { op: 'replace', startSeq: first, endSeq: last }, sourceEventSeqs: shadowed }) - expect(state().nodes).toHaveLength(1) - expect(projected(ctx, session).messageTokens).toBe(10) + expect(state()).not.toHaveProperty('claim') + expect(state().systems).toHaveLength(1) + expect(projected(ctx, session)).toMatchObject({ systemTokens: 5, messageTokens: 10 }) }) it('retains wire identity when a same-price rewrite changes only checkpoint positions', async () => { @@ -394,7 +416,7 @@ describe('contextBreakdown session projection', () => { const next = definition.apply(state, session.snapshotEvents().at(-1)!) expect(next).not.toBe(state) expect(definition.wire.view(next)).toBe(definition.wire.view(state)) - expect(state.nodes[0]?.seq).toBe(head) + expect(state.systems[0]?.seq).toBe(head) }) it('replays late registration, resumes a compact checkpoint, and discards scalar version 2', async () => { @@ -412,31 +434,33 @@ describe('contextBreakdown session projection', () => { ctx.sessionProjections.checkpoint(session), )) as ReturnType const row = checkpoint['contextBreakdown']! - expect(row.ver).toBe(5) + expect(row.ver).toBe(6) expect(ctx.sessionProjections.viewCheckpoint(checkpoint).contextBreakdown).toEqual(projected(ctx, session)) + appendSummaryMeter(ctx, session, first, last) + const meter = session.snapshotEvents().at(-1)! const replacement = session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'summary' }], source: { kind: 'user' }, }), { surfaceOp: { op: 'replace', startSeq: first, endSeq: last }, sourceEventSeqs: [first, last] }) const restored = ctx.sessionProjections.restore( - checkpoint, [replacement], SessionLogOffset(replacement.seq), session.header, session.inheritedEventCount, + checkpoint, [meter, replacement], SessionLogOffset(meter.seq), session.header, session.inheritedEventCount, ) expect(restored.snapshot.values.contextBreakdown).toEqual({ systemTokens: 5, toolsTokens: 0, messageTokens: 10 }) const stale = { ...checkpoint, contextBreakdown: { ...row, ver: 2, val: { systemTokens: 1004, toolsTokens: 0, messageTokens: -989 } } } expect(ctx.sessionProjections.viewCheckpoint(stale).contextBreakdown).toBeUndefined() expect(ctx.sessionProjections.restoreFloor(stale)).toBe(0) expect(() => ctx.sessionProjections.restore( - stale, [replacement], SessionLogOffset(replacement.seq), session.header, session.inheritedEventCount, + stale, [meter, replacement], SessionLogOffset(meter.seq), session.header, session.inheritedEventCount, )).toThrow('re-read from seq 0') const replayed = ctx.sessionProjections.restore( stale, session.snapshotEvents(), SessionLogOffset(0), session.header, session.inheritedEventCount, ) expect(replayed.snapshot.values.contextBreakdown).toEqual(projected(ctx, session)) - expect(replayed.checkpoint['contextBreakdown']?.ver).toBe(5) + expect(replayed.checkpoint['contextBreakdown']?.ver).toBe(6) const invalid = { ...checkpoint, contextBreakdown: { ...row, - val: { nodes: [{ seq: 0, heuristicTokens: -1, system: true }], breakdown: { systemTokens: 0, toolsTokens: 0, messageTokens: 0 } }, + val: { systems: [{ seq: 0, tokens: -1 }], surfaceTokens: 0, breakdown: { systemTokens: 0, toolsTokens: 0, messageTokens: 0 } }, }, } expect(() => ctx.sessionProjections.restore( @@ -465,7 +489,7 @@ describe('contextBreakdown session projection', () => { systemTokens: 8, toolsTokens: staleValue.toolsTokens, messageTokens: 9, }) expect(restored.checkpoint).toEqual(current) - expect(restored.checkpoint['contextBreakdown']?.ver).toBe(5) + expect(restored.checkpoint['contextBreakdown']?.ver).toBe(6) } finally { await ctx.fiber.dispose() } @@ -494,6 +518,20 @@ describe('contextBreakdown session projection', () => { messageTokens: 9, }) }) + + it('expires an armed shadow price at the next event so a later replacement folds at zero delta', async () => { + const { ctx, session } = await harness() + const first = appendUser(session, 'first message') + const last = appendUser(session, 'last message') + appendSummaryMeter(ctx, session, first, last) + session.append('step/start', { turn: 1, step: 1 }) + expect(ctx.sessionProjections.stateOf(session, 'contextBreakdown')).not.toHaveProperty('claim') + const before = projected(ctx, session) + session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'summary' }], source: { kind: 'user' }, + }), { surfaceOp: { op: 'replace', startSeq: first, endSeq: last }, sourceEventSeqs: [first, last] }) + expect(projected(ctx, session)).toEqual(before) + }) }) describe('shared estimator', () => { From 7f7940f0e2a569e30522d628891eed0b262d17de Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 14 Oct 2026 01:51:06 +0800 Subject: [PATCH 32/35] fix(ui-chat): address second review on single fold motion - A tail request counts only for closing rows that can start a batch, using the same membership rule as the batch, and detaching clears it. - A batch keeps the reader landing while reading movement awaits sampling, so an unsampled scroll away from the tail is not undone. - Reader scrolling during a reader landing moves the reference row's recorded position, so the final correction keeps only layout changes. - The blank-area matrix releases a partly opened world, fails a case on cleanup errors, mounts only the provider catalog for the Turn that fails before its model call, names its gap as a layout gap, and labels the steering case as a no-fold control. - The note records the pending-sampling rule and the opacity of a row that joins the travelling set after fading. --- ...26-10-13-chat-single-fold-motion.i18n.yaml | 8 +-- .../2026-10-13-chat-single-fold-motion.md | 6 +- .../2026-10-13-chat-single-fold-motion.zh.md | 6 +- apps/web/tests/fold-blank-matrix.e2e.ts | 65 ++++++++++++++----- .../ui-chat/src/client/chat/flow-motion.ts | 9 ++- .../src/client/chat/use-chat-reading.ts | 1 + .../src/client/chat/use-chat-viewport.ts | 9 ++- .../tests/flow-motion-scope.client.spec.tsx | 2 +- .../ui-chat/tests/flow-motion.client.spec.tsx | 14 ++++ .../ui-chat/tests/fold-follow.client.spec.ts | 14 +++- 10 files changed, 104 insertions(+), 30 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.i18n.yaml index fede5ed837..d6931dfe47 100644 --- a/.agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.i18n.yaml @@ -15,14 +15,14 @@ en: 45cb2d17e4d15b09 zh: e11e75c7bcc6bd8e /agent-note-one-fold-motion-for-collapsing-on-the-next-message/decision/landings: - en: f048652e38757699 - zh: fe02896384df467a + en: a28a833311d64223 + zh: 187e640331d9c906 /agent-note-one-fold-motion-for-collapsing-on-the-next-message/decision/own-input: en: ca6a5c372bb3cad1 zh: 1cd5028269b58c65 /agent-note-one-fold-motion-for-collapsing-on-the-next-message/testing: - en: a304b52774b7676d - zh: e99683e30f0c1173 + en: 0423a607ee0589b5 + zh: 61df892a01c5aa43 /agent-note-one-fold-motion-for-collapsing-on-the-next-message/alternatives-considered: en: d8e1ea66e293cd93 zh: 0aaa9e693f441673 diff --git a/.agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.md b/.agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.md index b0199720c6..bd86ab9833 100644 --- a/.agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.md +++ b/.agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.md @@ -48,7 +48,7 @@ Let `start` be the scrollTop when the batch begins, `above` the height of closin | Landing | When | Target scrollTop | After the batch | |---|---|---|---| -| **Tail** | This client's own input, or the reader was following the tail when the batch began | `max(base, bottom)` | Commit follow ownership | +| **Tail** | This client's own input, or the reader was following the tail when the batch began and had no reading movement awaiting sampling | `max(base, bottom)` | Commit follow ownership | | **Reader content still** | Otherwise | `base` | Keep the reader's reading state and acknowledge the new position | - Both landings install only the exact deficit as room: `max(0, target + viewport height − folded height)`. @@ -56,7 +56,7 @@ Let `start` be the scrollTop when the batch begins, `above` the height of closin - Own input during a running batch starts a new segment from the current position toward the tail; it takes over even when a reading gesture cancelled the automatic target. A tail request counts only while a batch runs or is scheduled; rows still closing on CSS transitions after a batch leave own input to ordinary following. - A reading gesture cancels a tail landing's automatic target and keeps the reader's position. During a reader-content-still landing, the reader's own scrolling between frames moves the position the batch keeps still. - For the reader-content-still landing, the driver takes the first visible top-level row outside the batch as a reference, found by binary search over the ordered top-level rows. Neighbouring spacing changes instantly when the batch begins and when closing rows commit their hidden state; the driver measures the reference's displacement at those two points and offsets scrollTop by it. -- When a tail landing scrolls forward past closing rows that start below the viewport top, those rows stay opaque, scroll with the content, and clip while they shrink; nested closing rows keep their size until the batch ends. Other closing rows fade in place. A newer tail request that takes over a running batch adds the closing rows still below the viewport top. Scrolling forward therefore never crosses space that is already transparent but not yet closed. +- When a tail landing scrolls forward past closing rows that start below the viewport top, those rows stay opaque, scroll with the content, and clip while they shrink; nested closing rows keep their size until the batch ends. Other closing rows fade in place. A newer tail request that takes over a running batch adds the closing rows still below the viewport top; a row that has already faded becomes opaque again for its remaining height. Scrolling forward therefore never crosses space that is already transparent but not yet closed. ### Own input @@ -72,7 +72,7 @@ Let `start` be the scrollTop when the batch begins, `above` the height of closin ## Testing -[fold-blank-matrix.e2e.ts](../../../../apps/web/tests/fold-blank-matrix.e2e.ts) covers 19 main-Session cases and 8 right-Sidebar child cases across reader position (bottom, middle of the body, top of the body, inside the steps, previous Turn), input source (composer, Turn failing before its message is recorded, another tab, queued delivery, steer, parent message, several Turns folding at once), body length, and Standard and "On completion" controls. Blank means the tallest band between the scrollport top and the composer that no visible row covers: at most 120 px once settled, and over that for at most one animation frame, because a closing row fading in place can expose its space for one frame before it finishes shrinking. Tail cases assert the floor landing; reader-content-still cases assert that the reader's anchor row moves at most 2 px on screen. Short-body cases assert only that no room remains after the reply arrives. +[fold-blank-matrix.e2e.ts](../../../../apps/web/tests/fold-blank-matrix.e2e.ts) covers 19 main-Session cases and 8 right-Sidebar child cases across reader position (bottom, middle of the body, top of the body, inside the steps, previous Turn), input source (composer, Turn failing before its message is recorded, another tab, queued delivery, steer, parent message, several Turns folding at once), body length, and Standard and "On completion" controls. The layout gap is the tallest band between the scrollport top and the composer that no visible row's box covers: at most 120 px once settled, and over that for at most one animation frame. It counts boxes, so a closing row that has faded but not finished shrinking still covers its space. Tail cases assert the floor landing; reader-content-still cases assert that the reader's anchor row moves at most 2 px on screen. Short-body cases assert only that no room remains after the reply arrives. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.zh.md b/.agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.zh.md index ebb163560c..0a5c7ff339 100644 --- a/.agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.zh.md +++ b/.agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.zh.md @@ -48,7 +48,7 @@ Status: implemented | 落点 | 何时 | 目标 scrollTop | 结束后 | |---|---|---|---| -| **到底部** | 本客户端的自己输入,或批次开始时读者正在跟随底部 | `max(base, bottom)` | 提交跟随状态 | +| **到底部** | 本客户端的自己输入,或批次开始时读者正在跟随底部、且没有尚待采样的阅读滚动 | `max(base, bottom)` | 提交跟随状态 | | **读者内容不动** | 其余情况 | `base` | 保持读者的阅读状态,确认新位置 | - 两种落点都只安装精确差额作为补白:`max(0, 目标 + 视口高 − 折叠后高度)`。 @@ -56,7 +56,7 @@ Status: implemented - 批次进行中出现自己输入时,从当前位置开新段,改为到底部;即使阅读手势已取消自动目标,也由新输入接管。只有批次正在运行或已排定时才接受到底部请求;批次结束后仍在用 CSS 过渡收起的行,不接管自己的输入,交给普通跟随。 - 读者的滚动手势会取消「到底部」的自动目标,保留读者位置。「读者内容不动」时,帧间读者自己的滚动会并入批次保持的位置。 - 「读者内容不动」时,驱动器对有序的顶层行二分查找,选取视口里第一行不属于本批次的可见顶层行作为参照。批次开始和收起行提交 hidden 时,相邻行的间距会瞬间改变;驱动器在这两处测量参照行的位移,抵消到 scrollTop 上。 -- 「到底部」且需要向前滚过视口顶部以下的收起行时,这些行保持不透明、随内容滚动,在收缩中裁剪显示,嵌套的收起行在批次结束前保持原尺寸。其余收起行原地淡出。新的到底部请求接管运行中的批次时,会把仍在视口顶部以下的收起行加入随行。这样向前滚动不会穿过一片已经透明、还没收完的空位。 +- 「到底部」且需要向前滚过视口顶部以下的收起行时,这些行保持不透明、随内容滚动,在收缩中裁剪显示,嵌套的收起行在批次结束前保持原尺寸。其余收起行原地淡出。新的到底部请求接管运行中的批次时,会把仍在视口顶部以下的收起行加入随行;已经淡出的行会以剩余高度重新变为不透明。这样向前滚动不会穿过一片已经透明、还没收完的空位。 ### 自己输入的判定 @@ -72,7 +72,7 @@ Status: implemented ## Testing -[fold-blank-matrix.e2e.ts](../../../../apps/web/tests/fold-blank-matrix.e2e.ts) 覆盖主会话 19 例与右侧栏子会话 8 例,维度包括读者位置(底部、正文中段、正文开头、步骤中间、上一轮)、输入来源(输入框、回合在写入消息前失败、另一个标签页、排队投递、steer、父消息、一次折叠多轮)、正文长短,以及 Standard 与「完成时收起」对照。空白指视口顶部到输入框之间没有任何行覆盖的最大连续高度:落定后不超过 120 px;动画中至多 1 帧超过,因为原地淡出的收起行可能在收完之前露出一帧空位。「到底部」的用例断言落到底部;「内容不动」的用例断言读者锚定行的屏幕位置变化不超过 2 px。正文很短的用例只断言回复到达后不再留空。 +[fold-blank-matrix.e2e.ts](../../../../apps/web/tests/fold-blank-matrix.e2e.ts) 覆盖主会话 19 例与右侧栏子会话 8 例,维度包括读者位置(底部、正文中段、正文开头、步骤中间、上一轮)、输入来源(输入框、回合在写入消息前失败、另一个标签页、排队投递、steer、父消息、一次折叠多轮)、正文长短,以及 Standard 与「完成时收起」对照。布局空洞指视口顶部到输入框之间没有任何可见行的盒子覆盖的最大连续高度:落定后不超过 120 px,动画中至多 1 帧超过。它按盒子计算,已经淡出但尚未收完的收起行仍算作覆盖。「到底部」的用例断言落到底部;「内容不动」的用例断言读者锚定行的屏幕位置变化不超过 2 px。正文很短的用例只断言回复到达后不再留空。 ## Alternatives considered diff --git a/apps/web/tests/fold-blank-matrix.e2e.ts b/apps/web/tests/fold-blank-matrix.e2e.ts index 750817d3ae..3422c15238 100644 --- a/apps/web/tests/fold-blank-matrix.e2e.ts +++ b/apps/web/tests/fold-blank-matrix.e2e.ts @@ -20,6 +20,7 @@ import { SESSION_FORMAT_VERSION, SessionId, SessionLogOffset, SessionSeq, type SessionEvent, type SessionHeader, } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-system-prompt' +import { sessionFixtureName } from '@deepseek-ai/dsh-session-snapshot' import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent' import { createChatScrollFixture, type ChatScrollFixture } from './chat-scroll-fixture.ts' import { launchWebScaffold, parseSeedFixture, seedSession, watchConsole, webSnapshotMode, type WebScaffold } from './scaffold.ts' @@ -40,8 +41,8 @@ const LIVE_STEPS = 6 const STEER_STEPS = 3 const BLANK_LIMIT = 120 /** - * A closing row fading in place can expose its emptied space for one frame before it finishes - * shrinking; a longer blank is a defect. + * Frames whose layout gap may exceed {@link BLANK_LIMIT}. The gap counts rows by their boxes, so a + * closing row that has faded but not finished shrinking still covers its space. */ const TRANSIENT_FRAMES = 1 const EVIDENCE = fileURLToPath(new URL('../../../.artifacts/screenshots/fold-blank-matrix/', import.meta.url)) @@ -115,7 +116,8 @@ const CASES: readonly Case[] = [ { ...MAIN, id: 'main/other-tab/previous-turn', origin: 'other-tab', reader: PREVIOUS_TURN, landing: 'reader' }, { ...MAIN, id: 'main/queued/bottom', origin: 'queued', reader: AT_BOTTOM }, { ...MAIN, id: 'main/queued/mid-body', origin: 'queued', reader: LIVE_MID_BODY, landing: 'reader' }, - { ...MAIN, id: 'main/steer/bottom', origin: 'steer', reader: AT_BOTTOM }, + // Control: steering joins the running Turn, which opens no exchange and folds nothing. + { ...MAIN, id: 'main/steer/no-fold-control', origin: 'steer', reader: AT_BOTTOM }, { ...MAIN, id: 'main/composer/standard/mid-body', reader: SEEDED_MID_BODY, mode: 'Standard' }, { ...MAIN, id: 'main/composer/completion/mid-body', reader: SEEDED_MID_BODY, timing: 'On completion' }, { ...SIDE, id: 'side/composer/bottom', reader: AT_BOTTOM }, @@ -281,14 +283,15 @@ function replayFor(scenario: Case): ReplayEntry[] { case 'composer': case 'other-tab': return [replyEntry('FOLDMX_REPLY')] - case 'error': - return [] case 'queued': return [...liveLongTurn('FOLDMX_LIVE'), replyEntry('FOLDMX_REPLY')] case 'steer': return [...liveLongTurn('FOLDMX_LIVE').slice(0, STEER_STEPS), replyEntry('FOLDMX_STEERED')] case 'parent-message': return [replyEntry('FOLDMX_PARENT_REPLY')] + case 'error': + // The Turn fails before its model call; launchScaffold mounts only the provider catalog. + return [] case 'big-exchange': return [...liveLongTurn('FOLDMX_WAKE_A'), ...liveLongTurn('FOLDMX_WAKE_B'), replyEntry('FOLDMX_REPLY')] } @@ -538,15 +541,43 @@ async function createChild(scaffold: WebScaffold, parentId: SessionId, scenario: } async function openWorld(browser: Browser, scenario: Case, replayDir: string): Promise { + const scaffold = await launchScaffold(scenario, replayDir) + let page: Page | undefined + try { + page = await newEnglishPage(browser, 900) + return await prepareWorld(browser, scenario, scaffold, page) + } catch (error) { + const failures: unknown[] = [error] + if (page !== undefined) await page.context().close().catch((cleanupError: unknown) => failures.push(cleanupError)) + await scaffold.close().catch((cleanupError: unknown) => failures.push(cleanupError)) + if (failures.length === 1) throw error + throw new AggregateError(failures, `${scenario.id}: world setup failed and cleanup was incomplete`) + } +} + +/** + * Launch the scaffold with the case's replay script. A Turn that fails before its model call consumes + * no script, so that case mounts only the provider catalog from a header-only fixture. + */ +async function launchScaffold(scenario: Case, replayDir: string): Promise { + if (scenario.origin === 'error') { + const header = seededFixture(scenario.id, scenario.bodyParagraphs).log.split('\n')[0] ?? '' + const fixture = join(replayDir, sessionFixtureName(0, SESSION_FORMAT_VERSION)) + await writeFile(fixture, `${header}\n`) + return launchWebScaffold({ replayFixture: fixture, replayProvidersOnly: true }) + } await writeFile(join(replayDir, 'replay.override.json'), JSON.stringify(replayFor(scenario))) - const scaffold = await launchWebScaffold({ + return launchWebScaffold({ replayFixture: join(replayDir, 'override-only.jsonl'), replayOverride: join(replayDir, 'replay.override.json'), paceMs: 16, replayContextWindow: 10_000_000, }) +} + +/** Seed the case's Sessions, open the surface, and choose the display preferences. */ +async function prepareWorld(browser: Browser, scenario: Case, scaffold: WebScaffold, page: Page): Promise { await writeFile(join(scaffold.workspaceCwd, READ_FILE), 'fold matrix notes\n') - const page = await newEnglishPage(browser, 900) const tripwire = watchConsole(page) await page.addInitScript(installMeasure) await page.setViewportSize(scenario.surface === 'main' ? { width: 1280, height: 720 } : { width: 1680, height: 900 }) @@ -810,14 +841,14 @@ describe.skipIf(MODE === 'record')('web e2e: blank area after next-message foldi // Per-frame evidence is written for failures, or for every case with FOLDMX_EVIDENCE=1. if (process.env.FOLDMX_EVIDENCE === '1') await evidence() else onTestFailed(evidence) - if (scenario.origin === 'parent-message') { - expect.soft(samples.some(sample => sample.collapsing), 'a notice folds nothing').toBe(false) + if (scenario.origin === 'parent-message' || scenario.origin === 'steer') { + expect.soft(samples.some(sample => sample.collapsing), 'a control case folds nothing').toBe(false) } if (scenario.roomUntilReply !== true) { - expect.soft(afterFold.maxGap, 'blank after the fold settles').toBeLessThanOrEqual(BLANK_LIMIT) - expect.soft(transient.length, 'animation frames showing a blank').toBeLessThanOrEqual(TRANSIENT_FRAMES) + expect.soft(afterFold.maxGap, 'layout gap after the fold settles').toBeLessThanOrEqual(BLANK_LIMIT) + expect.soft(transient.length, 'animation frames with a layout gap').toBeLessThanOrEqual(TRANSIENT_FRAMES) } - expect.soft(afterTurn.maxGap, 'blank after the Turn settles').toBeLessThanOrEqual(BLANK_LIMIT) + expect.soft(afterTurn.maxGap, 'layout gap after the Turn settles').toBeLessThanOrEqual(BLANK_LIMIT) if (scenario.landing === 'tail') { expect.soft(afterFold.floor - afterFold.top, 'distance from the floor after the fold').toBeLessThanOrEqual(1) expect.soft(afterTurn.floor - afterTurn.top, 'distance from the floor after the Turn').toBeLessThanOrEqual(1) @@ -829,9 +860,13 @@ describe.skipIf(MODE === 'record')('web e2e: blank area after next-message foldi } expect.soft(world.tripwire.pageErrors).toEqual([]) } finally { - await world?.page.context().close().catch(() => undefined) - await world?.scaffold.close().catch((error: unknown) => { console.log(`FOLDMX close ${scenario.id}: ${String(error)}`) }) - await rm(replayDir, { recursive: true, force: true }) + // Scaffold close also asserts that every replay entry was consumed; its failures fail the case. + const failures: unknown[] = [] + await world?.page.context().close().catch((error: unknown) => failures.push(error)) + await world?.scaffold.close().catch((error: unknown) => failures.push(error)) + await rm(replayDir, { recursive: true, force: true }).catch((error: unknown) => failures.push(error)) + if (failures.length === 1) throw failures[0] + if (failures.length > 1) throw new AggregateError(failures, `${scenario.id}: cleanup failed`) } }) } diff --git a/packages/client/ui-chat/src/client/chat/flow-motion.ts b/packages/client/ui-chat/src/client/chat/flow-motion.ts index 91c712c0df..74f9c965e2 100644 --- a/packages/client/ui-chat/src/client/chat/flow-motion.ts +++ b/packages/client/ui-chat/src/client/chat/flow-motion.ts @@ -129,9 +129,14 @@ export function createFlowMotion(driver: () => InputFoldDriver | null, foldingCh if (batch) cancelled = false } + /** Rows inside a closing ancestor change with it and never join a batch on their own. */ + function batchMember(box: InputFoldBox): boolean { + return box.element.parentElement?.closest('[data-chat-motion="collapse"]') === null + } + function runBatch(current: InputFoldDriver): void { const owned = [...boxes.values()] - const members = owned.filter(box => box.element.parentElement?.closest('[data-chat-motion="collapse"]') === null) + const members = owned.filter(batchMember) .sort((left, right) => left.element.compareDocumentPosition(right.element) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1) if (!members.some(box => box.closing)) return batch = true @@ -383,7 +388,7 @@ export function createFlowMotion(driver: () => InputFoldDriver | null, foldingCh return { collapse, reveal, cancelMotion, foldActive, onFoldIdle, cancelInput, resumeInput, clear, - inputActive: () => batch || (batchScheduled && [...boxes.values()].some(box => box.closing)), + inputActive: () => batch || (batchScheduled && [...boxes.values()].some(box => box.closing && batchMember(box))), } } diff --git a/packages/client/ui-chat/src/client/chat/use-chat-reading.ts b/packages/client/ui-chat/src/client/chat/use-chat-reading.ts index 4d1f9f98d5..cca1c11a39 100644 --- a/packages/client/ui-chat/src/client/chat/use-chat-reading.ts +++ b/packages/client/ui-chat/src/client/chat/use-chat-reading.ts @@ -107,6 +107,7 @@ export class ChatReading { this.cancelPending() this.commit(landing, true, this.viewport.latestTurn) }, + pending: () => this.pending, }) } diff --git a/packages/client/ui-chat/src/client/chat/use-chat-viewport.ts b/packages/client/ui-chat/src/client/chat/use-chat-viewport.ts index 976ab2b811..9890188110 100644 --- a/packages/client/ui-chat/src/client/chat/use-chat-viewport.ts +++ b/packages/client/ui-chat/src/client/chat/use-chat-viewport.ts @@ -43,6 +43,8 @@ export interface FoldReader { readonly follow: ScrollFollow /** @param landing - the actual geometry after a batch landed at the tail. */ readonly landedAtTail: (landing: ViewportLanding) => void + /** @returns whether reader movement awaits sampling; until then follow intent is undecided. */ + readonly pending: () => boolean } interface PagingPosition { @@ -116,6 +118,8 @@ export class ChatViewport { this.observer = null this.elements = null this.events = null + this.tailRequested = false + this.retarget = null this.turns = [] this.observation = { top: 0, landing: null } } @@ -240,7 +244,8 @@ export class ChatViewport { ) return { begin: (boxes) => { - tail = this.tailRequested || follow.active + // Own input lands at the tail; otherwise an unsampled reading movement outranks follow intent. + tail = this.tailRequested || (follow.active && !reader.pending()) this.tailRequested = false viewportTop = elements.scroller.getBoundingClientRect().top start = elements.scroller.scrollTop @@ -302,6 +307,8 @@ export class ChatViewport { if (Math.abs(drift) > 0.5) { base = Math.max(0, base + drift) segmentTop += drift + // The reference moves on screen with the reader's scroll; only layout changes are corrected. + referenceTop -= drift aim() } } diff --git a/packages/client/ui-chat/tests/flow-motion-scope.client.spec.tsx b/packages/client/ui-chat/tests/flow-motion-scope.client.spec.tsx index 258d2683d1..ad337a426e 100644 --- a/packages/client/ui-chat/tests/flow-motion-scope.client.spec.tsx +++ b/packages/client/ui-chat/tests/flow-motion-scope.client.spec.tsx @@ -21,7 +21,7 @@ function viewport() { document.body.append(list) const owner = new ChatViewport() owner.attach(list, column) - const unbind = owner.bindReader({ follow: new ScrollFollow(false, 25), landedAtTail: vi.fn() }) + const unbind = owner.bindReader({ follow: new ScrollFollow(false, 25), landedAtTail: vi.fn(), pending: () => false }) return { list, row, spacer, owner, dispose: () => { unbind(); owner.detach(); list.remove() } } } diff --git a/packages/client/ui-chat/tests/flow-motion.client.spec.tsx b/packages/client/ui-chat/tests/flow-motion.client.spec.tsx index 8bca075841..3852786025 100644 --- a/packages/client/ui-chat/tests/flow-motion.client.spec.tsx +++ b/packages/client/ui-chat/tests/flow-motion.client.spec.tsx @@ -313,6 +313,20 @@ describe('flow motion', () => { expect(motion.inputActive()).toBe(false) }) + it('does not report a batch when every closing row sits inside an ancestor that closes on its own', async () => { + const calls = batchDriver() + const parent = rowWithHeight(180) + // The ancestor's motion is not owned by this batch, so its descendants cannot start one. + parent.dataset.chatMotion = 'collapse' + const child = rowWithHeight(60, parent) + motion.collapse(child, vi.fn()) + expect(motion.inputActive()).toBe(false) + await Promise.resolve() + expect(calls.begin).not.toHaveBeenCalled() + motion.cancelMotion(child) + delete parent.dataset.chatMotion + }) + it('reports reader cancellation to the driver and lets a newer request resume the target', async () => { vi.useFakeTimers() const calls = batchDriver() diff --git a/packages/client/ui-chat/tests/fold-follow.client.spec.ts b/packages/client/ui-chat/tests/fold-follow.client.spec.ts index 8226ada2e9..b4200ce7d7 100644 --- a/packages/client/ui-chat/tests/fold-follow.client.spec.ts +++ b/packages/client/ui-chat/tests/fold-follow.client.spec.ts @@ -24,7 +24,6 @@ const frames = new Map() let frameClock = 0 let nextFrame = 0 -/** Run queued animation frames until the batch stops requesting more. */ /** Run a fixed number of queued animation frames. */ function runFrameCount(count: number): void { for (let index = 0; index < count && frames.size > 0; index++) { @@ -35,6 +34,7 @@ function runFrameCount(count: number): void { } } +/** Run queued animation frames until the batch stops requesting more. */ function runFrames(): void { for (let guard = 0; frames.size > 0 && guard < 100; guard++) { const pending = [...frames.values()] @@ -162,6 +162,18 @@ it('adds a reader\'s own scrolling during the fold to the position it keeps stil expect(h.reading.followingTail).toBe(false) }) +it('keeps the reader landing while reader movement awaits sampling', async () => { + const h = fixture({ earlier: 100, later: 500, top: 2_000, following: true }) + h.list.dispatchEvent(new Event('wheel')) + h.scroll(1_990) + expect(h.reading.pending).toBe(true) + h.viewport.motion.collapse(h.row, () => { h.row.hidden = true; h.grow(600) }) + await Promise.resolve() + runFrames() + expect(h.list.scrollTop).toBe(190) + vi.advanceTimersByTime(500) +}) + it('keeps a reader below folding rows on the same content without room or follow', async () => { const h = fixture({ earlier: 100, later: 500, top: 2_000, following: false }) await h.close() From 57fa64db18b281eb84d88bef3dc142aa3b5023c5 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 14 Oct 2026 03:13:00 +0800 Subject: [PATCH 33/35] fix(ui-chat): fold rows that close mid-batch in the next batch A row that started closing while a fold batch ran kept its own CSS transition and outlived the batch. During that window following moved to the floor while reclaimBelow held still, so the bottom room was followed in full instead of being consumed by the content that arrived, and it stayed under the reader afterwards. - A row that closes while a batch runs keeps its size without a transition until the batch settles; the next batch starts at once and drives it. Without a driver at that flush the held row finishes at once. - A batch takes over a closing row at its current size and finishes a row that has no size left. - The tail landing reads the committed layout when it finishes: it lands at the larger of the target and the real floor and keeps only the room that target needs. --- ...26-10-13-chat-single-fold-motion.i18n.yaml | 8 +-- .../2026-10-13-chat-single-fold-motion.md | 5 +- .../2026-10-13-chat-single-fold-motion.zh.md | 5 +- packages/client/ui-chat/README.i18n.yaml | 4 +- packages/client/ui-chat/README.md | 2 +- packages/client/ui-chat/README.zh.md | 2 +- .../ui-chat/src/client/chat/flow-motion.ts | 64 +++++++++++++----- .../src/client/chat/use-chat-viewport.ts | 8 ++- .../ui-chat/tests/chat-scroll.client.spec.tsx | 3 + .../ui-chat/tests/flow-motion.client.spec.tsx | 65 +++++++++++++++++++ .../ui-chat/tests/fold-follow.client.spec.ts | 11 ++++ 11 files changed, 149 insertions(+), 28 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.i18n.yaml index d6931dfe47..a5ccc1dad8 100644 --- a/.agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.i18n.yaml @@ -12,11 +12,11 @@ en: 972bbe45fe8ac28e zh: 593b851c142894e1 /agent-note-one-fold-motion-for-collapsing-on-the-next-message/decision/batches: - en: 45cb2d17e4d15b09 - zh: e11e75c7bcc6bd8e + en: 80dfd50ec6c284ba + zh: 52001456790a2682 /agent-note-one-fold-motion-for-collapsing-on-the-next-message/decision/landings: - en: a28a833311d64223 - zh: 187e640331d9c906 + en: b8278678170f1507 + zh: 113a51ba5cf27de1 /agent-note-one-fold-motion-for-collapsing-on-the-next-message/decision/own-input: en: ca6a5c372bb3cad1 zh: 1cd5028269b58c65 diff --git a/.agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.md b/.agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.md index bd86ab9833..25c13e36f7 100644 --- a/.agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.md +++ b/.agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.md @@ -38,7 +38,8 @@ Subagent children showed no local echo because, when echoes were introduced, `su - `finish`: install the final room. Content added during the batch updates the remaining movement by starting a new segment. -- A batch with only revealing rows needs no room and keeps CSS transitions. When the viewport has no reading policy attached, closing rows also keep CSS transitions; under reduced motion they finish immediately. +- A row that starts closing while a batch runs keeps its size, without a transition of its own, until that batch settles; the next batch starts at once and drives it. Rows therefore never close outside a batch while following continues, which would follow the bottom room without the growth that should have consumed it. When a batch takes over a closing row, it measures the row's current size; a row with no size left finishes without animation. +- A batch with only revealing rows needs no room and keeps CSS transitions. When the viewport has no reading policy attached, closing rows also keep CSS transitions, and held rows finish immediately; under reduced motion closing rows finish immediately. - Closing rows commit their hidden state first, the driver then installs the final geometry, and only then are fold-idle listeners notified (for example, clearing room after a display-mode change). - The generic path is removed: the `reserve` callback, the `pendingReserve` and `pendingGrowth` reservation arithmetic, `reserveBelow`, and the post-fold `followAfterFold`. `reclaimBelow` (returning room after new content or reader scrolling) and `resetBelow` (clearing room after a display-mode change) remain. @@ -51,7 +52,7 @@ Let `start` be the scrollTop when the batch begins, `above` the height of closin | **Tail** | This client's own input, or the reader was following the tail when the batch began and had no reading movement awaiting sampling | `max(base, bottom)` | Commit follow ownership | | **Reader content still** | Otherwise | `base` | Keep the reader's reading state and acknowledge the new position | -- Both landings install only the exact deficit as room: `max(0, target + viewport height − folded height)`. +- Both landings install only the exact deficit as room: `max(0, target + viewport height − folded height)`. The tail landing reads the committed layout when it finishes: it lands at the larger of the target and the real floor, and keeps only the room that target needs. - Content never slides back down: content already above the viewport top does not return. When the retained body and the new message cannot fill the viewport, the deficit remains as room until new content fills it. - Own input during a running batch starts a new segment from the current position toward the tail; it takes over even when a reading gesture cancelled the automatic target. A tail request counts only while a batch runs or is scheduled; rows still closing on CSS transitions after a batch leave own input to ordinary following. - A reading gesture cancels a tail landing's automatic target and keeps the reader's position. During a reader-content-still landing, the reader's own scrolling between frames moves the position the batch keeps still. diff --git a/.agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.zh.md b/.agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.zh.md index 0a5c7ff339..cee318aad1 100644 --- a/.agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.zh.md +++ b/.agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.zh.md @@ -38,7 +38,8 @@ Status: implemented - `finish`:安装最终补白。 批次中途新增的内容按分段规则更新剩余运动。 -- 只含展开行的批次不需要补白,仍用 CSS 过渡。viewport 未挂载阅读策略时,收起行也用 CSS 过渡;减少动态效果时立即生效。 +- 批次运行期间开始收起的行保持原尺寸、不走自己的过渡,等当前批次落定后立刻进入下一批,由下一批驱动。因此不会出现「批次之外有行在收起、同时跟随照常进行」的情况;否则跟随会把底部空间整块跟进去,而本该抵扣它的新内容没有被计入。批次接管收起行时按其当前尺寸测量,已无尺寸的行直接完成、不做动画。 +- 只含展开行的批次不需要补白,仍用 CSS 过渡。viewport 未挂载阅读策略时,收起行也用 CSS 过渡、被挂起的行立即完成;减少动态效果时收起行立即完成。 - 收起行先提交 hidden,驱动器再安装最终几何,之后才通知 fold-idle 监听者(例如切换展示模式后清空补白)。 - 通用路径已删除:`reserve` 回调、`pendingReserve` 与 `pendingGrowth` 的预留计算、`reserveBelow`、折叠结束后再跟随的 `followAfterFold`。保留 `reclaimBelow`(新内容到来或读者滚动后归还补白)与 `resetBelow`(切换展示模式后清空补白)。 @@ -51,7 +52,7 @@ Status: implemented | **到底部** | 本客户端的自己输入,或批次开始时读者正在跟随底部、且没有尚待采样的阅读滚动 | `max(base, bottom)` | 提交跟随状态 | | **读者内容不动** | 其余情况 | `base` | 保持读者的阅读状态,确认新位置 | -- 两种落点都只安装精确差额作为补白:`max(0, 目标 + 视口高 − 折叠后高度)`。 +- 两种落点都只安装精确差额作为补白:`max(0, 目标 + 视口高 − 折叠后高度)`。到底部落点在结束时读取已提交的实际排版:落在目标与真实底部中较大的位置,只保留该目标所需的空间。 - 「内容只上不下」:已经在视口顶部以上的内容不会回滑。保留的正文和新消息不足一屏时留出差额,新内容到来后填满。 - 批次进行中出现自己输入时,从当前位置开新段,改为到底部;即使阅读手势已取消自动目标,也由新输入接管。只有批次正在运行或已排定时才接受到底部请求;批次结束后仍在用 CSS 过渡收起的行,不接管自己的输入,交给普通跟随。 - 读者的滚动手势会取消「到底部」的自动目标,保留读者位置。「读者内容不动」时,帧间读者自己的滚动会并入批次保持的位置。 diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index e930ea7167..ffd27bfa4d 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/packages/client/ui-chat/README.i18n.yaml @@ -36,8 +36,8 @@ en: 81d7ca7595c34d39 zh: 29eb8248c75e9ea6 /deepseek-ai-dsh-client-ui-chat/scroll-ownership: - en: 82c23ea0545c2f60 - zh: 42fdd5a16a0ca301 + en: 1eb49a039102d605 + zh: 83de8fa4fbbedaf3 /deepseek-ai-dsh-client-ui-chat/model-experience: en: c17b4dd43a61c569 zh: f7414309810c3a08 diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index 1b35267bd5..8d4be1aa84 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -138,7 +138,7 @@ The transcript root uses `overflow-x: visible; overflow-y: clip`: vertical overf Outer transcript following and each open capped group's following are independent. Native animation progress retains follow intent; only that scrollport's own `scrollend` settles its animation. Browsers without `scrollend` may retain the native follow target until a later bottom-aligned update or reader gesture. A reader gesture interrupts the animation, and actual movement determines whether following remains enabled. Scroll chaining can move the outer transcript, which then applies its own distance threshold. The back-to-bottom button restores only outer following. -Next-message folding moves every batch of closing rows and the scrollport on one frame clock, with browser scroll anchoring disabled during the fold; there is no separate reserve-then-follow path. The batch lands at the tail when this client's own input caused it (including a subagent child's continuation) or when the reader was following; otherwise the reader's visible content stays still, whoever sent the message. Content above the reading line never slides back down: when the retained answer and the new content cannot fill the viewport, only that deficit remains as bottom space, and new content fills it before following advances again. Reader gestures cancel the automatic target and active native following; resize cannot revive it in the same fold, but new submitted input can. Acknowledged scrolling releases only space that can be removed without clamping the reader. Unloading Chat cancels waiting callbacks and row transitions. Reduced-motion users receive immediate visibility and positioning changes. +Next-message folding moves every batch of closing rows and the scrollport on one frame clock, with browser scroll anchoring disabled during the fold; there is no separate reserve-then-follow path. A row that starts closing while a batch runs keeps its size until that batch settles and then folds in the next batch. The batch lands at the tail when this client's own input caused it (including a subagent child's continuation) or when the reader was following; otherwise the reader's visible content stays still, whoever sent the message. Content above the reading line never slides back down: when the retained answer and the new content cannot fill the viewport, only that deficit remains as bottom space, and new content fills it before following advances again. Reader gestures cancel the automatic target and active native following; resize cannot revive it in the same fold, but new submitted input can. Acknowledged scrolling releases only space that can be removed without clamping the reader. Unloading Chat cancels waiting callbacks and row transitions. Reduced-motion users receive immediate visibility and positioning changes. Changing Work details clears reserved bottom space after active folds finish and reconciles the scroll range with the selected mode. Readers already following the tail stay at the new bottom; other readers are not forced to follow. diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index c26efa2e8b..1b1d37acfe 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -138,7 +138,7 @@ Chat 会在历史前插与 renderer 重新挂载时恢复语义锚点,并且 外层文本记录与每个已展开限高组的跟随状态彼此独立。原生动画的中间滚动保留跟随意图;只有该滚动容器自身的 `scrollend` 才结束其动画。不支持 `scrollend` 的浏览器可能保留原生跟随目标,直到后续贴底更新或读者手势将其结束。读者手势中断动画,是否继续跟随由实际位移决定。滚动传递可以带动外层,外层再按自身离底距离判断。回到底部按钮只恢复外层跟随。 -下次消息触发的折叠中,每一批收起行与滚动容器在同一个帧时钟上推进,折叠期间禁用浏览器滚动锚定;不存在「先预留、再跟随」的另一条路径。由本客户端自己的输入(包括子代理会话的 continuation)触发、或读者原本在跟尾时,这一批落到底部;其余情况下,无论消息由谁发出,读者正在看的内容保持不动。阅读线以上的内容不会回滑:保留的正文和新内容填不满视口时,只把差额保留为底部空间,后续新增内容先填满它,再继续跟尾。读者手势取消自动目标及进行中的原生跟随;同一次折叠中的尺寸变化不能恢复它,但新提交的输入可以。已确认的滚动只释放不会造成位置钳制的多余空间。卸载 Chat 时取消等待回调和行过渡。启用减少动态效果偏好时,可见性和定位立即变化。 +下次消息触发的折叠中,每一批收起行与滚动容器在同一个帧时钟上推进,折叠期间禁用浏览器滚动锚定;不存在「先预留、再跟随」的另一条路径。批次运行期间开始收起的行先保持原尺寸,等当前批次落定后在下一批中收起。由本客户端自己的输入(包括子代理会话的 continuation)触发、或读者原本在跟尾时,这一批落到底部;其余情况下,无论消息由谁发出,读者正在看的内容保持不动。阅读线以上的内容不会回滑:保留的正文和新内容填不满视口时,只把差额保留为底部空间,后续新增内容先填满它,再继续跟尾。读者手势取消自动目标及进行中的原生跟随;同一次折叠中的尺寸变化不能恢复它,但新提交的输入可以。已确认的滚动只释放不会造成位置钳制的多余空间。卸载 Chat 时取消等待回调和行过渡。启用减少动态效果偏好时,可见性和定位立即变化。 切换工作步骤展示档位时,当前折叠结束后清空底部预留空间,并按所选档位调整滚动范围。原本跟随底部的读者保持在新的底部;其他读者不会被强制拉回底部。 diff --git a/packages/client/ui-chat/src/client/chat/flow-motion.ts b/packages/client/ui-chat/src/client/chat/flow-motion.ts index 74f9c965e2..582ab29587 100644 --- a/packages/client/ui-chat/src/client/chat/flow-motion.ts +++ b/packages/client/ui-chat/src/client/chat/flow-motion.ts @@ -99,6 +99,20 @@ export function hiddenOrCollapsing(element: HTMLElement): boolean { return element.hasAttribute('hidden') || element.dataset.chatMotion === 'collapse' } +/** A box this viewport owns until it finishes or is cancelled. */ +interface OwnedBox { + readonly element: HTMLElement + /** Measured when the box starts, and again when a batch takes over a closing box. */ + height: number + marginTop: number + readonly closing: boolean + /** The box closed while a batch ran; it keeps its size without a CSS transition until the next batch. */ + readonly held: boolean + /** Stop the box's own transition and fallback timer before a batch drives it. */ + take(): void + finish(): void +} + /** * Create animation coordination without cross-viewport broadcasts. * @param driver - the viewport's driver for the next batch; null while detached. @@ -110,7 +124,7 @@ export function createFlowMotion(driver: () => InputFoldDriver | null, foldingCh let folding = 0 const foldIdleListeners = new Set<() => void>() let batchScheduled = false - const boxes = new Map void; finish: () => void }>() + const boxes = new Map() let frameRequest: number | null = null let batch = false let cancelled = false @@ -135,6 +149,14 @@ export function createFlowMotion(driver: () => InputFoldDriver | null, foldingCh } function runBatch(current: InputFoldDriver): void { + // A closing box continues from its current size: its own transition may have moved it, and a held box + // may now sit inside a row that finished closing. + for (const box of [...boxes.values()]) { + if (!box.closing || !batchMember(box)) continue + box.height = box.element.offsetHeight + box.marginTop = Number.parseFloat(getComputedStyle(box.element).marginTop) || 0 + if (box.height === 0 && box.marginTop === 0) box.finish() + } const owned = [...boxes.values()] const members = owned.filter(batchMember) .sort((left, right) => left.element.compareDocumentPosition(right.element) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1) @@ -183,6 +205,8 @@ export function createFlowMotion(driver: () => InputFoldDriver | null, foldingCh settling = false } if (folding === 0) notifyIdle() + // Rows that closed while this batch ran wait in place; the next batch starts at once. + else if ([...boxes.values()].some(box => box.held)) flushBatch() } cancelEmptyBatch = () => { if (members.some(box => box.closing && boxes.get(box.element) === box)) return @@ -236,12 +260,14 @@ export function createFlowMotion(driver: () => InputFoldDriver | null, foldingCh function flushBatch(): void { batchScheduled = false - // Rows that start closing during a batch keep their CSS transitions; the batch follows their size changes. + // Rows that start closing during a batch are held until it settles, which starts the next batch. if (batch || ![...boxes.values()].some(box => box.closing)) return const current = driver() if (current !== null) runBatch(current) - else if (typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches) { - for (const box of [...boxes.values()]) box.finish() + else { + const reduced = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches + // Held rows have no transition of their own, so without a driver they finish at once. + for (const box of [...boxes.values()]) if (reduced || box.held) box.finish() } } @@ -275,7 +301,11 @@ export function createFlowMotion(driver: () => InputFoldDriver | null, foldingCh for (const listener of listeners) listener() } - function clearMotion(element: HTMLElement, onEnd: (event: TransitionEvent) => void, timer: ReturnType): void { + function clearMotion( + element: HTMLElement, + onEnd: (event: TransitionEvent) => void, + timer: ReturnType | undefined, + ): void { element.removeEventListener('transitionend', onEnd) clearTimeout(timer) running.delete(element) @@ -304,6 +334,8 @@ export function createFlowMotion(driver: () => InputFoldDriver | null, foldingCh return } const { style } = element + // A running batch owns the scrollport; a row closing now keeps its size until the next batch drives it. + const held = batch element.dataset.chatMotion = 'collapse' foldStarted() scheduleBatch() @@ -311,12 +343,14 @@ export function createFlowMotion(driver: () => InputFoldDriver | null, foldingCh style.overflow = 'hidden' style.height = `${height}px` style.marginTop = `${marginTop}px` - void element.offsetHeight - style.transition = `opacity ${FADE_OUT_MS}ms ${EASING}, ` - + `height ${CLOSE_MS}ms ${EASING} ${CLOSE_DELAY_MS}ms, margin-top ${CLOSE_MS}ms ${EASING} ${CLOSE_DELAY_MS}ms` - style.opacity = '0' - style.height = '0px' - style.marginTop = '0px' + if (!held) { + void element.offsetHeight + style.transition = `opacity ${FADE_OUT_MS}ms ${EASING}, ` + + `height ${CLOSE_MS}ms ${EASING} ${CLOSE_DELAY_MS}ms, margin-top ${CLOSE_MS}ms ${EASING} ${CLOSE_DELAY_MS}ms` + style.opacity = '0' + style.height = '0px' + style.marginTop = '0px' + } // The hidden state must land before fold-idle listeners measure the flow. const finish = (): void => { @@ -328,14 +362,14 @@ export function createFlowMotion(driver: () => InputFoldDriver | null, foldingCh const onEnd = (event: TransitionEvent): void => { if (event.target === element && event.propertyName === 'height') finish() } - const timer = setTimeout(finish, CLOSE_DELAY_MS + CLOSE_MS + SETTLE_SLACK_MS) + const timer = held ? undefined : setTimeout(finish, CLOSE_DELAY_MS + CLOSE_MS + SETTLE_SLACK_MS) const cancel = (): void => { clearMotion(element, onEnd, timer) foldEnded() } - element.addEventListener('transitionend', onEnd) + if (!held) element.addEventListener('transitionend', onEnd) running.set(element, cancel) - boxes.set(element, { element, height, marginTop, closing: true, finish, + boxes.set(element, { element, height, marginTop, closing: true, held, finish, take: () => { element.removeEventListener('transitionend', onEnd); clearTimeout(timer) } }) } @@ -368,7 +402,7 @@ export function createFlowMotion(driver: () => InputFoldDriver | null, foldingCh const timer = setTimeout(finish, CLOSE_DELAY_MS + CLOSE_MS + SETTLE_SLACK_MS) element.addEventListener('transitionend', onEnd) running.set(element, finish) - boxes.set(element, { element, height, marginTop, closing: false, finish, + boxes.set(element, { element, height, marginTop, closing: false, held: false, finish, take: () => { element.removeEventListener('transitionend', onEnd); clearTimeout(timer) } }) } diff --git a/packages/client/ui-chat/src/client/chat/use-chat-viewport.ts b/packages/client/ui-chat/src/client/chat/use-chat-viewport.ts index 9890188110..40ea51de98 100644 --- a/packages/client/ui-chat/src/client/chat/use-chat-viewport.ts +++ b/packages/client/ui-chat/src/client/chat/use-chat-viewport.ts @@ -360,7 +360,13 @@ export class ChatViewport { else this.observation = { top: elements.scroller.scrollTop, landing: null } return } - this.setSpacer(finalRoom) + // The estimate can differ from the committed layout, for example settled sibling spacing. + // Land on the actual content: never above the target, and at the real floor when content reaches past it. + const content = elements.scroller.scrollHeight - this.spacerHeight() + const clientHeight = elements.scroller.clientHeight + const top = Math.max(target, content - clientHeight, 0) + this.setSpacer(top === 0 ? 0 : Math.max(0, top + clientHeight - content)) + elements.scroller.scrollTop = top const metrics = scrollMetrics(elements.scroller) const landing = { metrics, position: null, turn: this.latestTurn } this.observation = { top: metrics.top, landing } diff --git a/packages/client/ui-chat/tests/chat-scroll.client.spec.tsx b/packages/client/ui-chat/tests/chat-scroll.client.spec.tsx index 6f58eefd3c..6eb4ac4520 100644 --- a/packages/client/ui-chat/tests/chat-scroll.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-scroll.client.spec.tsx @@ -373,6 +373,8 @@ describe('Chat scroll collapse timing', () => { expect(h.scrollTo).not.toHaveBeenCalled() expect(h.scroller.scrollTop).toBe(0) await act(async () => { await Promise.resolve() }) + // The folded row leaves the committed layout; the landing reads the actual content height. + h.grow(920) act(() => { vi.advanceTimersByTime(400) }) expect(h.scrollTo).not.toHaveBeenCalled() expect(h.scroller.scrollTop).toBe(520) @@ -480,6 +482,7 @@ describe('Chat scroll collapse timing', () => { h.resize() h.update({ order: ['streaming-change'], running: true }) if (newInput) h.update({ submissionId: 'second' }) + h.grow(920) h.finishFold() await act(async () => { await Promise.resolve() }) act(() => { vi.advanceTimersByTime(400) }) diff --git a/packages/client/ui-chat/tests/flow-motion.client.spec.tsx b/packages/client/ui-chat/tests/flow-motion.client.spec.tsx index 3852786025..c8d7bd036d 100644 --- a/packages/client/ui-chat/tests/flow-motion.client.spec.tsx +++ b/packages/client/ui-chat/tests/flow-motion.client.spec.tsx @@ -327,6 +327,71 @@ describe('flow motion', () => { delete parent.dataset.chatMotion }) + it('holds a row that closes during a batch in place and drives it in the next batch', async () => { + vi.useFakeTimers() + const calls = batchDriver() + const first = rowWithHeight(180) + const idle = vi.fn() + motion.collapse(first, vi.fn()) + await Promise.resolve() + subscriptions.add(motion.onFoldIdle(idle)) + vi.advanceTimersByTime(32) + const late = rowWithHeight(60) + const lateDone = vi.fn() + motion.collapse(late, lateDone) + expect(late.style.transition).toBe('none') + expect(late.style.height).toBe('60px') + expect(late.style.opacity).toBe('') + await Promise.resolve() + expect(calls.begin).toHaveBeenCalledTimes(1) + vi.advanceTimersByTime(200) + expect(calls.begin).toHaveBeenCalledTimes(2) + expect(calls.begin.mock.calls[1]?.[0].map(box => box.element)).toEqual([late]) + expect(idle).not.toHaveBeenCalled() + expect(motion.inputActive()).toBe(true) + vi.advanceTimersByTime(400) + expect(lateDone).toHaveBeenCalledTimes(1) + expect(idle).toHaveBeenCalledTimes(1) + expect(calls.finish).toHaveBeenCalledTimes(2) + }) + + it('finishes a held row at once when the next flush has no driver', async () => { + vi.useFakeTimers() + batchDriver() + const first = rowWithHeight(180) + motion.collapse(first, vi.fn()) + await Promise.resolve() + vi.advanceTimersByTime(32) + const late = rowWithHeight(60) + const lateDone = vi.fn() + motion.collapse(late, lateDone) + driver.mockReturnValue(null) + vi.advanceTimersByTime(400) + expect(lateDone).toHaveBeenCalledTimes(1) + expectCleared(late) + }) + + it('takes over a closing row at its current size and finishes one that no longer has any', async () => { + vi.useFakeTimers() + const moving = rowWithHeight(180) + motion.collapse(moving, vi.fn()) + await Promise.resolve() + // No driver yet: the row closes on its own transition and shrinks to 120 before a batch takes it over. + Object.defineProperty(moving, 'offsetHeight', { value: 120, configurable: true }) + const calls = batchDriver() + const gone = rowWithHeight(60) + const goneDone = vi.fn() + motion.collapse(gone, goneDone) + // Before the batch starts, the row's ancestor finished closing, so the row has no size left. + Object.defineProperty(gone, 'offsetHeight', { value: 0, configurable: true }) + expect(goneDone).not.toHaveBeenCalled() + await Promise.resolve() + expect(goneDone).toHaveBeenCalledTimes(1) + expectCleared(gone) + expect(calls.begin.mock.calls[0]?.[0].map(box => [box.element, box.height])).toEqual([[moving, 120]]) + vi.advanceTimersByTime(400) + }) + it('reports reader cancellation to the driver and lets a newer request resume the target', async () => { vi.useFakeTimers() const calls = batchDriver() diff --git a/packages/client/ui-chat/tests/fold-follow.client.spec.ts b/packages/client/ui-chat/tests/fold-follow.client.spec.ts index b4200ce7d7..19a3dea326 100644 --- a/packages/client/ui-chat/tests/fold-follow.client.spec.ts +++ b/packages/client/ui-chat/tests/fold-follow.client.spec.ts @@ -174,6 +174,17 @@ it('keeps the reader landing while reader movement awaits sampling', async () => vi.advanceTimersByTime(500) }) +it('lands a tail batch on the committed content when it reaches past the estimate', async () => { + const h = fixture(VISIBLE_ROW) + // Content arriving with the hidden commit is not in the estimate; the landing reads the real floor. + h.viewport.motion.collapse(h.row, () => { h.row.hidden = true; h.grow(1_200) }) + await Promise.resolve() + runFrames() + expect(h.list.scrollTop).toBe(800) + expect(h.spacer.style.height).toBe('') + expect(h.reading.followingTail).toBe(true) +}) + it('keeps a reader below folding rows on the same content without room or follow', async () => { const h = fixture({ earlier: 100, later: 500, top: 2_000, following: false }) await h.close() From a6e9071b82b5b5a81bad293456ee49cd99fea6b3 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 14 Oct 2026 03:38:35 +0800 Subject: [PATCH 34/35] fix(ui-chat): continue batch members from their current size A revealing row that ran on its own CSS transition and was then taken over by a batch entered the landing twice: the measured starting layout held the height it had already reached, and the growth amount added its full height again while the batch restarted it from zero. The extra height raised the tail target above the real floor and stayed as bottom room. Rows revealed while a batch ran kept their own transitions and were taken over this way by the next batch. - A row that starts revealing while a batch runs waits at zero size like a held closing row; the next flush drives it in a batch or starts its own transition when nothing closes. - A batch continues every member from its current size: closing rows shrink from the height they have now, and revealing rows grow from the height they reached, so only the remaining growth enters the landing. --- ...26-10-13-chat-single-fold-motion.i18n.yaml | 4 +- .../2026-10-13-chat-single-fold-motion.md | 2 +- .../2026-10-13-chat-single-fold-motion.zh.md | 2 +- packages/client/ui-chat/README.i18n.yaml | 4 +- packages/client/ui-chat/README.md | 2 +- packages/client/ui-chat/README.zh.md | 2 +- .../ui-chat/src/client/chat/flow-motion.ts | 132 ++++++++++++------ .../ui-chat/tests/flow-motion.client.spec.tsx | 38 +++++ 8 files changed, 138 insertions(+), 48 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.i18n.yaml index a5ccc1dad8..be7bf32098 100644 --- a/.agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.i18n.yaml @@ -12,8 +12,8 @@ en: 972bbe45fe8ac28e zh: 593b851c142894e1 /agent-note-one-fold-motion-for-collapsing-on-the-next-message/decision/batches: - en: 80dfd50ec6c284ba - zh: 52001456790a2682 + en: f44b784545c6d9a2 + zh: 3eb6fcb9f348dec4 /agent-note-one-fold-motion-for-collapsing-on-the-next-message/decision/landings: en: b8278678170f1507 zh: 113a51ba5cf27de1 diff --git a/.agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.md b/.agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.md index 25c13e36f7..8c279abb34 100644 --- a/.agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.md +++ b/.agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.md @@ -38,7 +38,7 @@ Subagent children showed no local echo because, when echoes were introduced, `su - `finish`: install the final room. Content added during the batch updates the remaining movement by starting a new segment. -- A row that starts closing while a batch runs keeps its size, without a transition of its own, until that batch settles; the next batch starts at once and drives it. Rows therefore never close outside a batch while following continues, which would follow the bottom room without the growth that should have consumed it. When a batch takes over a closing row, it measures the row's current size; a row with no size left finishes without animation. +- A row that starts closing or revealing while a batch runs keeps its current size, without a transition of its own, until that batch settles; the next flush drives it in a new batch, or starts a held revealing row's own transition when nothing closes. Rows therefore never change outside a batch while following continues, which would follow the bottom room without the growth that should have consumed it. A batch takes over every member at its current size: a closing row shrinks from the height it has now, and a revealing row grows from the height its own transition already reached, so only the remaining growth enters the landing; a closing row with no size left finishes without animation. - A batch with only revealing rows needs no room and keeps CSS transitions. When the viewport has no reading policy attached, closing rows also keep CSS transitions, and held rows finish immediately; under reduced motion closing rows finish immediately. - Closing rows commit their hidden state first, the driver then installs the final geometry, and only then are fold-idle listeners notified (for example, clearing room after a display-mode change). - The generic path is removed: the `reserve` callback, the `pendingReserve` and `pendingGrowth` reservation arithmetic, `reserveBelow`, and the post-fold `followAfterFold`. `reclaimBelow` (returning room after new content or reader scrolling) and `resetBelow` (clearing room after a display-mode change) remain. diff --git a/.agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.zh.md b/.agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.zh.md index cee318aad1..96e36590e7 100644 --- a/.agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.zh.md +++ b/.agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.zh.md @@ -38,7 +38,7 @@ Status: implemented - `finish`:安装最终补白。 批次中途新增的内容按分段规则更新剩余运动。 -- 批次运行期间开始收起的行保持原尺寸、不走自己的过渡,等当前批次落定后立刻进入下一批,由下一批驱动。因此不会出现「批次之外有行在收起、同时跟随照常进行」的情况;否则跟随会把底部空间整块跟进去,而本该抵扣它的新内容没有被计入。批次接管收起行时按其当前尺寸测量,已无尺寸的行直接完成、不做动画。 +- 批次运行期间开始收起或展开的行保持当前尺寸、不走自己的过渡,等当前批次落定后由下一次刷新处理:有收起行时进入新批次,只剩展开行时启动它自己的过渡。因此不会出现「批次之外有行在变化、同时跟随照常进行」的情况;否则跟随会把底部空间整块跟进去,而本该抵扣它的新内容没有被计入。批次按每个成员的当前尺寸接管:收起行从现有高度收缩,展开行从它自己的过渡已达到的高度继续增长,只有剩余的增长量计入落点;已无尺寸的收起行直接完成、不做动画。 - 只含展开行的批次不需要补白,仍用 CSS 过渡。viewport 未挂载阅读策略时,收起行也用 CSS 过渡、被挂起的行立即完成;减少动态效果时收起行立即完成。 - 收起行先提交 hidden,驱动器再安装最终几何,之后才通知 fold-idle 监听者(例如切换展示模式后清空补白)。 - 通用路径已删除:`reserve` 回调、`pendingReserve` 与 `pendingGrowth` 的预留计算、`reserveBelow`、折叠结束后再跟随的 `followAfterFold`。保留 `reclaimBelow`(新内容到来或读者滚动后归还补白)与 `resetBelow`(切换展示模式后清空补白)。 diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index ffd27bfa4d..3f4ae6e1ae 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/packages/client/ui-chat/README.i18n.yaml @@ -36,8 +36,8 @@ en: 81d7ca7595c34d39 zh: 29eb8248c75e9ea6 /deepseek-ai-dsh-client-ui-chat/scroll-ownership: - en: 1eb49a039102d605 - zh: 83de8fa4fbbedaf3 + en: 284737900cb75668 + zh: 12e0844082ff79ce /deepseek-ai-dsh-client-ui-chat/model-experience: en: c17b4dd43a61c569 zh: f7414309810c3a08 diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index 8d4be1aa84..0438804975 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -138,7 +138,7 @@ The transcript root uses `overflow-x: visible; overflow-y: clip`: vertical overf Outer transcript following and each open capped group's following are independent. Native animation progress retains follow intent; only that scrollport's own `scrollend` settles its animation. Browsers without `scrollend` may retain the native follow target until a later bottom-aligned update or reader gesture. A reader gesture interrupts the animation, and actual movement determines whether following remains enabled. Scroll chaining can move the outer transcript, which then applies its own distance threshold. The back-to-bottom button restores only outer following. -Next-message folding moves every batch of closing rows and the scrollport on one frame clock, with browser scroll anchoring disabled during the fold; there is no separate reserve-then-follow path. A row that starts closing while a batch runs keeps its size until that batch settles and then folds in the next batch. The batch lands at the tail when this client's own input caused it (including a subagent child's continuation) or when the reader was following; otherwise the reader's visible content stays still, whoever sent the message. Content above the reading line never slides back down: when the retained answer and the new content cannot fill the viewport, only that deficit remains as bottom space, and new content fills it before following advances again. Reader gestures cancel the automatic target and active native following; resize cannot revive it in the same fold, but new submitted input can. Acknowledged scrolling releases only space that can be removed without clamping the reader. Unloading Chat cancels waiting callbacks and row transitions. Reduced-motion users receive immediate visibility and positioning changes. +Next-message folding moves every batch of closing rows and the scrollport on one frame clock, with browser scroll anchoring disabled during the fold; there is no separate reserve-then-follow path. A row that starts closing or revealing while a batch runs keeps its size until that batch settles and then changes in the next batch; a batch continues each row from the size it already has. The batch lands at the tail when this client's own input caused it (including a subagent child's continuation) or when the reader was following; otherwise the reader's visible content stays still, whoever sent the message. Content above the reading line never slides back down: when the retained answer and the new content cannot fill the viewport, only that deficit remains as bottom space, and new content fills it before following advances again. Reader gestures cancel the automatic target and active native following; resize cannot revive it in the same fold, but new submitted input can. Acknowledged scrolling releases only space that can be removed without clamping the reader. Unloading Chat cancels waiting callbacks and row transitions. Reduced-motion users receive immediate visibility and positioning changes. Changing Work details clears reserved bottom space after active folds finish and reconciles the scroll range with the selected mode. Readers already following the tail stay at the new bottom; other readers are not forced to follow. diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index 1b1d37acfe..27d61eaa9a 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -138,7 +138,7 @@ Chat 会在历史前插与 renderer 重新挂载时恢复语义锚点,并且 外层文本记录与每个已展开限高组的跟随状态彼此独立。原生动画的中间滚动保留跟随意图;只有该滚动容器自身的 `scrollend` 才结束其动画。不支持 `scrollend` 的浏览器可能保留原生跟随目标,直到后续贴底更新或读者手势将其结束。读者手势中断动画,是否继续跟随由实际位移决定。滚动传递可以带动外层,外层再按自身离底距离判断。回到底部按钮只恢复外层跟随。 -下次消息触发的折叠中,每一批收起行与滚动容器在同一个帧时钟上推进,折叠期间禁用浏览器滚动锚定;不存在「先预留、再跟随」的另一条路径。批次运行期间开始收起的行先保持原尺寸,等当前批次落定后在下一批中收起。由本客户端自己的输入(包括子代理会话的 continuation)触发、或读者原本在跟尾时,这一批落到底部;其余情况下,无论消息由谁发出,读者正在看的内容保持不动。阅读线以上的内容不会回滑:保留的正文和新内容填不满视口时,只把差额保留为底部空间,后续新增内容先填满它,再继续跟尾。读者手势取消自动目标及进行中的原生跟随;同一次折叠中的尺寸变化不能恢复它,但新提交的输入可以。已确认的滚动只释放不会造成位置钳制的多余空间。卸载 Chat 时取消等待回调和行过渡。启用减少动态效果偏好时,可见性和定位立即变化。 +下次消息触发的折叠中,每一批收起行与滚动容器在同一个帧时钟上推进,折叠期间禁用浏览器滚动锚定;不存在「先预留、再跟随」的另一条路径。批次运行期间开始收起或展开的行先保持当前尺寸,等当前批次落定后在下一批中变化;批次从每一行已有的尺寸继续。由本客户端自己的输入(包括子代理会话的 continuation)触发、或读者原本在跟尾时,这一批落到底部;其余情况下,无论消息由谁发出,读者正在看的内容保持不动。阅读线以上的内容不会回滑:保留的正文和新内容填不满视口时,只把差额保留为底部空间,后续新增内容先填满它,再继续跟尾。读者手势取消自动目标及进行中的原生跟随;同一次折叠中的尺寸变化不能恢复它,但新提交的输入可以。已确认的滚动只释放不会造成位置钳制的多余空间。卸载 Chat 时取消等待回调和行过渡。启用减少动态效果偏好时,可见性和定位立即变化。 切换工作步骤展示档位时,当前折叠结束后清空底部预留空间,并按所选档位调整滚动范围。原本跟随底部的读者保持在新的底部;其他读者不会被强制拉回底部。 diff --git a/packages/client/ui-chat/src/client/chat/flow-motion.ts b/packages/client/ui-chat/src/client/chat/flow-motion.ts index 582ab29587..6d941ecfdc 100644 --- a/packages/client/ui-chat/src/client/chat/flow-motion.ts +++ b/packages/client/ui-chat/src/client/chat/flow-motion.ts @@ -99,15 +99,34 @@ export function hiddenOrCollapsing(element: HTMLElement): boolean { return element.hasAttribute('hidden') || element.dataset.chatMotion === 'collapse' } +/** + * Opacity of a revealing box at batch progress, continuing from the share its own transition reached. + * @param box - revealing box. + * @param progress - shared eased progress from zero to one. + * @returns opacity from zero to one. + */ +function revealedShare(box: OwnedBox, progress: number): number { + const start = box.height === 0 ? 0 : box.fromHeight / box.height + return start + (1 - start) * progress +} + /** A box this viewport owns until it finishes or is cancelled. */ interface OwnedBox { readonly element: HTMLElement - /** Measured when the box starts, and again when a batch takes over a closing box. */ + /** + * Full size: a closing box shrinks from it, a revealing box grows to it. A batch re-measures a closing + * box's current size when it takes the box over. + */ height: number marginTop: number + /** Size a revealing box had already reached on its own transition when a batch took it over. */ + fromHeight: number + fromMarginTop: number readonly closing: boolean - /** The box closed while a batch ran; it keeps its size without a CSS transition until the next batch. */ - readonly held: boolean + /** The box changed while a batch ran; it waits without a CSS transition until the next batch. */ + held: boolean + /** Start a held revealing box's own transition when no batch will drive it. */ + release(): void /** Stop the box's own transition and fallback timer before a batch drives it. */ take(): void finish(): void @@ -149,13 +168,21 @@ export function createFlowMotion(driver: () => InputFoldDriver | null, foldingCh } function runBatch(current: InputFoldDriver): void { - // A closing box continues from its current size: its own transition may have moved it, and a held box - // may now sit inside a row that finished closing. + // Every member continues from the size it has now, so the driver's measured layout and its change + // amounts describe the same boxes: a closing box may have moved on its own transition or sit inside a + // row that finished closing, and a revealing box may have grown part of the way on its own transition. for (const box of [...boxes.values()]) { - if (!box.closing || !batchMember(box)) continue - box.height = box.element.offsetHeight - box.marginTop = Number.parseFloat(getComputedStyle(box.element).marginTop) || 0 - if (box.height === 0 && box.marginTop === 0) box.finish() + if (!batchMember(box)) continue + const height = box.element.offsetHeight + const marginTop = Number.parseFloat(getComputedStyle(box.element).marginTop) || 0 + if (!box.closing) { + box.fromHeight = Math.min(height, box.height) + box.fromMarginTop = Math.min(marginTop, box.marginTop) + continue + } + box.height = height + box.marginTop = marginTop + if (height === 0 && marginTop === 0) box.finish() } const owned = [...boxes.values()] const members = owned.filter(batchMember) @@ -163,14 +190,19 @@ export function createFlowMotion(driver: () => InputFoldDriver | null, foldingCh if (!members.some(box => box.closing)) return batch = true cancelled = false - const travelling = current.begin(members) + const travelling = current.begin(members.map(box => box.closing ? box : { + element: box.element, + height: box.height - box.fromHeight, + marginTop: box.marginTop - box.fromMarginTop, + closing: false, + })) batchCleanup = () => { current.finish(true) } for (const box of owned) box.take() for (const box of members) { box.element.style.transition = 'none' - box.element.style.height = `${box.closing ? box.height : 0}px` - box.element.style.marginTop = `${box.closing ? box.marginTop : 0}px` - box.element.style.opacity = box.closing ? '1' : '0' + box.element.style.height = `${box.closing ? box.height : box.fromHeight}px` + box.element.style.marginTop = `${box.closing ? box.marginTop : box.fromMarginTop}px` + box.element.style.opacity = box.closing ? '1' : String(revealedShare(box, 0)) } const memberSet = new Set(members) const settledTravel = new Set() @@ -205,8 +237,8 @@ export function createFlowMotion(driver: () => InputFoldDriver | null, foldingCh settling = false } if (folding === 0) notifyIdle() - // Rows that closed while this batch ran wait in place; the next batch starts at once. - else if ([...boxes.values()].some(box => box.held)) flushBatch() + // Rows that changed while this batch ran wait in place; the next flush drives or releases them. + if ([...boxes.values()].some(box => box.held)) flushBatch() } cancelEmptyBatch = () => { if (members.some(box => box.closing && boxes.get(box.element) === box)) return @@ -224,11 +256,15 @@ export function createFlowMotion(driver: () => InputFoldDriver | null, foldingCh freezeTravelling() for (const box of members) { if (boxes.get(box.element) !== box) continue - const remaining = box.closing ? 1 - progress : progress - box.element.style.height = `${box.height * remaining}px` - box.element.style.marginTop = `${box.marginTop * remaining}px` - box.element.style.opacity = !box.closing ? String(progress) - : travelling.has(box.element) ? '1' : String(Math.max(0, 1 - elapsed / FADE_OUT_MS)) + if (box.closing) { + box.element.style.height = `${box.height * (1 - progress)}px` + box.element.style.marginTop = `${box.marginTop * (1 - progress)}px` + box.element.style.opacity = travelling.has(box.element) ? '1' : String(Math.max(0, 1 - elapsed / FADE_OUT_MS)) + } else { + box.element.style.height = `${box.fromHeight + (box.height - box.fromHeight) * progress}px` + box.element.style.marginTop = `${box.fromMarginTop + (box.marginTop - box.fromMarginTop) * progress}px` + box.element.style.opacity = String(revealedShare(box, progress)) + } } displacement = current.frame(progress, cancelled) let removedBefore = 0 @@ -238,7 +274,7 @@ export function createFlowMotion(driver: () => InputFoldDriver | null, foldingCh // Transforming the closing box changes its pixels, not its flow contribution. if (!travelling.has(box.element)) box.element.style.translate = `0 ${displacement + removedBefore}px` removedBefore += (box.height + box.marginTop) * progress - } else removedBefore -= (box.height + box.marginTop) * progress + } else removedBefore -= (box.height - box.fromHeight + box.marginTop - box.fromMarginTop) * progress } if (linear < 1) { frameRequest = requestAnimationFrame(tick) @@ -260,14 +296,18 @@ export function createFlowMotion(driver: () => InputFoldDriver | null, foldingCh function flushBatch(): void { batchScheduled = false - // Rows that start closing during a batch are held until it settles, which starts the next batch. - if (batch || ![...boxes.values()].some(box => box.closing)) return - const current = driver() - if (current !== null) runBatch(current) - else { - const reduced = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches - // Held rows have no transition of their own, so without a driver they finish at once. - for (const box of [...boxes.values()]) if (reduced || box.held) box.finish() + // Rows that change during a batch are held until it settles, which flushes again. + if (batch) return + const current = [...boxes.values()].some(box => box.closing) ? driver() : null + if (current !== null) { + runBatch(current) + return + } + const reduced = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches + // Without a batch, a held closing row finishes at once and a held revealing row starts its own transition. + for (const box of [...boxes.values()]) { + if (reduced || (box.held && box.closing)) box.finish() + else if (box.held) box.release() } } @@ -369,7 +409,8 @@ export function createFlowMotion(driver: () => InputFoldDriver | null, foldingCh } if (!held) element.addEventListener('transitionend', onEnd) running.set(element, cancel) - boxes.set(element, { element, height, marginTop, closing: true, held, finish, + boxes.set(element, { element, height, marginTop, fromHeight: height, fromMarginTop: marginTop, closing: true, held, finish, + release: () => {}, take: () => { element.removeEventListener('transitionend', onEnd); clearTimeout(timer) } }) } @@ -386,12 +427,6 @@ export function createFlowMotion(driver: () => InputFoldDriver | null, foldingCh style.opacity = '0' style.height = '0px' style.marginTop = '0px' - void element.offsetHeight - style.transition = `height ${CLOSE_MS}ms ${EASING} ${CLOSE_DELAY_MS}ms, margin-top ${CLOSE_MS}ms ${EASING} ${CLOSE_DELAY_MS}ms, ` - + `opacity ${CLOSE_MS}ms ${EASING} ${CLOSE_DELAY_MS}ms` - style.height = `${height}px` - style.marginTop = `${marginTop}px` - style.opacity = '1' const finish = (): void => { if (running.get(element) !== finish) return clearMotion(element, onEnd, timer) @@ -399,11 +434,28 @@ export function createFlowMotion(driver: () => InputFoldDriver | null, foldingCh const onEnd = (event: TransitionEvent): void => { if (event.target === element && event.propertyName === 'height') finish() } - const timer = setTimeout(finish, CLOSE_DELAY_MS + CLOSE_MS + SETTLE_SLACK_MS) - element.addEventListener('transitionend', onEnd) + let timer: ReturnType | undefined + const startTransition = (): void => { + void element.offsetHeight + style.transition = `height ${CLOSE_MS}ms ${EASING} ${CLOSE_DELAY_MS}ms, margin-top ${CLOSE_MS}ms ${EASING} ${CLOSE_DELAY_MS}ms, ` + + `opacity ${CLOSE_MS}ms ${EASING} ${CLOSE_DELAY_MS}ms` + style.height = `${height}px` + style.marginTop = `${marginTop}px` + style.opacity = '1' + timer = setTimeout(finish, CLOSE_DELAY_MS + CLOSE_MS + SETTLE_SLACK_MS) + element.addEventListener('transitionend', onEnd) + } + // A running batch owns the scrollport; a row revealed now waits at zero size until the next flush. + const held = batch + if (!held) startTransition() running.set(element, finish) - boxes.set(element, { element, height, marginTop, closing: false, held: false, finish, - take: () => { element.removeEventListener('transitionend', onEnd); clearTimeout(timer) } }) + const box: OwnedBox = { element, height, marginTop, fromHeight: 0, fromMarginTop: 0, closing: false, held, finish, + release: () => { + box.held = false + startTransition() + }, + take: () => { element.removeEventListener('transitionend', onEnd); clearTimeout(timer) } } + boxes.set(element, box) } function clear(): void { diff --git a/packages/client/ui-chat/tests/flow-motion.client.spec.tsx b/packages/client/ui-chat/tests/flow-motion.client.spec.tsx index c8d7bd036d..f2f2e4a6c1 100644 --- a/packages/client/ui-chat/tests/flow-motion.client.spec.tsx +++ b/packages/client/ui-chat/tests/flow-motion.client.spec.tsx @@ -219,6 +219,8 @@ describe('flow motion', () => { motion.collapse(parent, vi.fn()) motion.collapse(sibling, vi.fn()) motion.reveal(opening) + // A fresh reveal has not grown yet when the batch measures it. + Object.defineProperty(opening, 'offsetHeight', { value: 0, configurable: true }) await Promise.resolve() expect(calls.begin).toHaveBeenCalledTimes(1) expect(calls.begin.mock.calls[0]?.[0].map(box => [box.element, box.height, box.closing])).toEqual([ @@ -392,6 +394,42 @@ describe('flow motion', () => { vi.advanceTimersByTime(400) }) + it('counts only the remaining growth of a reveal a batch takes over mid-transition', async () => { + vi.useFakeTimers() + const opening = rowWithHeight(120) + motion.reveal(opening) + await Promise.resolve() + // The reveal ran alone on its own transition and reached 45px before a closing row starts a batch. + Object.defineProperty(opening, 'offsetHeight', { value: 45, configurable: true }) + const calls = batchDriver() + const closing = rowWithHeight(80) + motion.collapse(closing, vi.fn()) + await Promise.resolve() + expect(calls.begin.mock.calls[0]?.[0].map(box => [box.element, box.height, box.closing])).toEqual([ + [opening, 75, false], [closing, 80, true], + ]) + expect(opening.style.height).toBe('45px') + vi.advanceTimersByTime(400) + }) + + it('holds a row revealed during a batch and starts its own transition when no batch follows', async () => { + vi.useFakeTimers() + batchDriver() + const closing = rowWithHeight(180) + motion.collapse(closing, vi.fn()) + await Promise.resolve() + vi.advanceTimersByTime(32) + const opening = rowWithHeight(40) + motion.reveal(opening) + expect(opening.style.transition).toBe('none') + expect(opening.style.height).toBe('0px') + vi.advanceTimersByTime(200) + expect(opening.style.transition).toContain('height') + expect(opening.style.height).toBe('40px') + vi.runAllTimers() + expectCleared(opening) + }) + it('reports reader cancellation to the driver and lets a newer request resume the target', async () => { vi.useFakeTimers() const calls = batchDriver() From ecebe2a2b3a3296fc8d49e9831a0abdd023ee2e9 Mon Sep 17 00:00:00 2001 From: Ziya Date: Tue, 13 Oct 2026 19:51:03 -0700 Subject: [PATCH 35/35] fix(plugin): clarify DSH badge skill descriptions in English and Chinese (#6156) * fix(plugin): clarify DSH badge skill descriptions * fix(plugin): remove redundant badge attribution explanation --------- Co-authored-by: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> --- packages/experimental/badge-skill-bundle/README.i18n.yaml | 8 ++++---- packages/experimental/badge-skill-bundle/README.md | 4 ++-- packages/experimental/badge-skill-bundle/README.zh.md | 4 ++-- packages/experimental/badge-skill-bundle/locale/en.json | 2 +- packages/experimental/badge-skill-bundle/locale/zh.json | 2 +- packages/experimental/badge-skill-bundle/package.json | 2 +- 6 files changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/experimental/badge-skill-bundle/README.i18n.yaml b/packages/experimental/badge-skill-bundle/README.i18n.yaml index f13ffde9d7..60e8cbb8d4 100644 --- a/packages/experimental/badge-skill-bundle/README.i18n.yaml +++ b/packages/experimental/badge-skill-bundle/README.i18n.yaml @@ -3,14 +3,14 @@ # After editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/experimental/badge-skill-bundle/README.md /: - en: d4789188cbe49f34 - zh: 2ff516d81a08f4e4 + en: fc73b6a11f583014 + zh: 9b2a3ee017befdd8 /deepseek-ai-dsh-experimental-badge-skill-bundle: en: e9f3f3f4aea1c588 zh: 706c6a9f82b3358a /deepseek-ai-dsh-experimental-badge-skill-bundle/summary: - en: 15e22b36e1581507 - zh: e53678cae329b498 + en: 98328e636b0f758a + zh: 0d8cf62176fb5e10 /deepseek-ai-dsh-experimental-badge-skill-bundle/table-of-contents: en: d152484eb41ac6b4 zh: 988e839059414d27 diff --git a/packages/experimental/badge-skill-bundle/README.md b/packages/experimental/badge-skill-bundle/README.md index 5effa5ed01..e7ca5eca42 100644 --- a/packages/experimental/badge-skill-bundle/README.md +++ b/packages/experimental/badge-skill-bundle/README.md @@ -1,5 +1,5 @@ --- -description: "Offer the powered-by-DSH badge as a discoverable skill." +description: "Let agents add a “powered by dsh” badge to documents, pull requests, and other outputs." kind: "package-bundle" --- @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Offer the experimental DSH badge skill to Agents whose presets load skills. The shared provider adds no model tool. It ships switched off in Web and Desktop. Select it in Plugins to enable it. +Let Agents whose presets load skills add a “powered by dsh” badge to documents, pull requests, and other outputs. The shared provider adds no model tool. It ships switched off in Web and Desktop. Select it in Plugins to enable it. ## Table of Contents diff --git a/packages/experimental/badge-skill-bundle/README.zh.md b/packages/experimental/badge-skill-bundle/README.zh.md index 186ca6e9c5..20f005258b 100644 --- a/packages/experimental/badge-skill-bundle/README.zh.md +++ b/packages/experimental/badge-skill-bundle/README.zh.md @@ -1,5 +1,5 @@ --- -description: "提供可发现的 powered-by-DSH 徽章技能。" +description: "让 Agent 为文档、PR 等产出添加「powered by dsh」徽章。" kind: "package-bundle" --- @@ -9,7 +9,7 @@ kind: "package-bundle" ## 概述 -向预设支持技能加载的智能体提供实验性 DSH 徽章技能。共享提供者不添加模型工具。 Web 和 Desktop 随附此功能,默认关闭。在插件页选择它即可启用。 +让预设支持技能加载的智能体为文档、PR 等产出添加「powered by dsh」徽章。共享提供者不添加模型工具。 Web 和 Desktop 随附此功能,默认关闭。在插件页选择它即可启用。 ## 目录 diff --git a/packages/experimental/badge-skill-bundle/locale/en.json b/packages/experimental/badge-skill-bundle/locale/en.json index 42cfcce322..8dde418fa7 100644 --- a/packages/experimental/badge-skill-bundle/locale/en.json +++ b/packages/experimental/badge-skill-bundle/locale/en.json @@ -1,6 +1,6 @@ { "meta": { "title": "DSH badge skill", - "description": "Offer the powered-by-DSH badge as a discoverable skill." + "description": "Let agents add a “powered by dsh” badge to documents, pull requests, and other outputs." } } diff --git a/packages/experimental/badge-skill-bundle/locale/zh.json b/packages/experimental/badge-skill-bundle/locale/zh.json index ce1b448ab6..d4791032e7 100644 --- a/packages/experimental/badge-skill-bundle/locale/zh.json +++ b/packages/experimental/badge-skill-bundle/locale/zh.json @@ -1,6 +1,6 @@ { "meta": { "title": "DSH 徽章技能", - "description": "提供可发现的 powered-by-DSH 徽章技能。" + "description": "让 Agent 为文档、PR 等产出添加「powered by dsh」徽章。" } } diff --git a/packages/experimental/badge-skill-bundle/package.json b/packages/experimental/badge-skill-bundle/package.json index a3d065bb7e..03ed23a354 100644 --- a/packages/experimental/badge-skill-bundle/package.json +++ b/packages/experimental/badge-skill-bundle/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-experimental-badge-skill-bundle", - "description": "Offer the powered-by-DSH badge as a discoverable skill.", + "description": "Let agents add a “powered by dsh” badge to documents, pull requests, and other outputs.", "version": "0.2.1-alpha.2", "icon": "./icon.svg", "publishConfig": {