From f22cacc63b7c503cdde6915ba6c21b98774e8cfe Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 4 Aug 2026 17:49:21 +0800 Subject: [PATCH 01/63] fix: advance resolving issue status from PRs --- ...-04-forward-only-pr-issue-status.i18n.yaml | 6 +++ ...2026-08-04-forward-only-pr-issue-status.md | 39 ++++++++++++++++ ...6-08-04-forward-only-pr-issue-status.zh.md | 39 ++++++++++++++++ .github/issue-management/policy.mjs | 32 ++++++++++---- .github/issue-management/policy.test.mjs | 44 +++++++++++++++++++ package.json | 1 + scripts/run-gates.ts | 3 ++ 7 files changed, 155 insertions(+), 9 deletions(-) create mode 100644 .agents/notes/implemented/process/2026-08-04-forward-only-pr-issue-status.i18n.yaml create mode 100644 .agents/notes/implemented/process/2026-08-04-forward-only-pr-issue-status.md create mode 100644 .agents/notes/implemented/process/2026-08-04-forward-only-pr-issue-status.zh.md diff --git a/.agents/notes/implemented/process/2026-08-04-forward-only-pr-issue-status.i18n.yaml b/.agents/notes/implemented/process/2026-08-04-forward-only-pr-issue-status.i18n.yaml new file mode 100644 index 0000000000..1b704da8f1 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-04-forward-only-pr-issue-status.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-04-forward-only-pr-issue-status.md +2026-08-04-forward-only-pr-issue-status.md: dd567707bc7fccd0a631943ab3ffd2838a7f2f76 +2026-08-04-forward-only-pr-issue-status.zh.md: f19cceafbde074d298a7c7f27829c8ab919f00b6 diff --git a/.agents/notes/implemented/process/2026-08-04-forward-only-pr-issue-status.md b/.agents/notes/implemented/process/2026-08-04-forward-only-pr-issue-status.md new file mode 100644 index 0000000000..dd567707bc --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-04-forward-only-pr-issue-status.md @@ -0,0 +1,39 @@ +# Agent Note: Forward-only PR-to-Issue status projection + +Status: implemented + +English | [中文](2026-08-04-forward-only-pr-issue-status.zh.md) + +## Problem + +The Issue Project status represents the phase of the work, while an exact same-repository resolving keyword establishes the authoritative PR-to-Issue relationship. Restricting lifecycle advancement to Issues already in `Ready` leaves an Issue in `Inbox` or `Backlog` after implementation has demonstrably started. Requiring otherwise valid PR metadata before projecting the phase also conflates policy compliance with the work's observable state. + +## Decision + +PR and PR-review events project the current PR phase to every exact same-repository resolving Issue. A draft PR, or a non-draft PR without a review request or submitted review, targets `In progress`. A non-draft PR with either form of review activity targets `In review`. + +The active statuses have the order `Inbox`, `Backlog`, `Ready`, `In progress`, and `In review`. Projection writes only when the target is later in that order. It does not move an Issue backward, alter `Done` or `No action`, or add an Issue that has no Project status. The lifecycle path is independent of PR metadata validation; the separate required PR policy check continues to enforce labels, references, and priority consistency. + +This projection is intentionally one-way. It does not query from an Issue to related PRs, and it does not add a scheduled reconciler. PR events are the source of lifecycle advancement. The pure transition decision is exercised by the Issue-management test and that test runs in the `check-all`, `ci-primary`, and `ci-static` gates. + +## Verification + +`.github/issue-management/policy.test.mjs` covers advancement from every earlier active status, the draft and review distinctions, metadata-policy independence, and protection against backward or terminal transitions. `scripts/run-gates.ts` owns execution of that focused policy test in top-level local and CI gate modes. + +## Alternatives considered + +**Require `Ready` as the only source status.** This preserves a manual prerequisite but leaves stale `Inbox` and `Backlog` items even though the resolving PR proves implementation has begun. + +**Add bidirectional or scheduled reconciliation.** Looking up PRs from Issue events or sweeping the Project could repair more histories, but it adds another authority direction and recurring API work beyond the required PR-driven lifecycle. + +**Gate projection on complete PR metadata.** Labels, references, and priority still require enforcement, but a metadata defect does not make the implementation or review phase untrue. + +**Move statuses backward when a PR becomes a draft or loses reviewers.** That would make transient PR state overwrite a later observed work phase and complicate status ownership. Projection therefore remains monotonic. + +## Consequences + +- A PR event self-corrects a resolving Issue left in `Inbox`, `Backlog`, or `Ready`. +- An Issue created after the last relevant PR event waits for a later PR event or a manual status update because there is no reverse lookup or scheduled sweep. +- A draft PR remains `In progress` even if it has historical review activity; only a non-draft PR targets `In review`. +- Terminal statuses and later active statuses remain protected from regression. +- PR metadata failures remain visible through the required policy check without suppressing lifecycle projection. diff --git a/.agents/notes/implemented/process/2026-08-04-forward-only-pr-issue-status.zh.md b/.agents/notes/implemented/process/2026-08-04-forward-only-pr-issue-status.zh.md new file mode 100644 index 0000000000..f19cceafbd --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-04-forward-only-pr-issue-status.zh.md @@ -0,0 +1,39 @@ +# Agent Note: PR 到 Issue 的状态仅向前投射 + +Status: implemented + +[English](2026-08-04-forward-only-pr-issue-status.md) | 中文 + +## 问题 + +Issue Project 状态表示工作所处阶段,同仓库内精确匹配的解决型关键字引用则建立权威的 PR(Pull Request)到 Issue 关系。若仅允许已处于 `Ready` 的 Issue 推进生命周期,即使实现已经明确开始,处于 `Inbox` 或 `Backlog` 的 Issue 仍会停留在原状态。只有 PR 元数据在其他方面均有效时才投射工作阶段,也会把政策合规性与可观察到的工作状态混为一谈。 + +## 决策 + +PR 事件和 PR 评审事件会把当前 PR 阶段投射到同仓库内被精确引用的每个解决型 Issue。草稿 PR,或既没有评审请求也没有已提交评审的非草稿 PR,目标状态为 `In progress`。具备上述任一类评审活动的非草稿 PR,目标状态为 `In review`。 + +活跃状态依次为 `Inbox`、`Backlog`、`Ready`、`In progress` 和 `In review`。只有目标状态在该顺序中位于当前状态之后时,投射才会写入。投射不会把 Issue 状态向后移动,不会改动 `Done` 或 `No action`,也不会把没有 Project 状态的 Issue 加入 Project。生命周期路径独立于 PR 元数据校验;另行执行的必需 PR 政策检查继续强制落实标签、引用和优先级一致性。 + +这项投射刻意保持单向。它不会从 Issue 反查关联 PR,也不会添加定时对账任务。PR 事件是推进生命周期的来源。Issue 管理测试会验证纯函数实现的状态转换决策,并且该测试会在 `check-all`、`ci-primary` 和 `ci-static` 门禁中运行。 + +## 验证 + +`.github/issue-management/policy.test.mjs` 覆盖从所有更早活跃状态推进、区分草稿与评审状态、独立于元数据政策,以及防止状态倒退或改动终态。`scripts/run-gates.ts` 负责在顶层本地门禁模式和 CI 门禁模式中执行这项专项政策测试。 + +## 考虑过的替代方案 + +**仅允许从 `Ready` 状态推进。** 这种方案保留了人工前置条件,但解决型 PR 已经证明实现开始后,仍会让处于 `Inbox` 和 `Backlog` 的条目保持陈旧状态。 + +**增加双向或定时对账。** 由 Issue 事件反查 PR,或定期扫描 Project,可以修复更多历史遗留状态;但这会新增一条反向的权威状态更新路径,并增加周期性 API 工作量,超出所需的 PR 驱动生命周期范围。 + +**以完整的 PR 元数据作为投射前提。** 标签、引用和优先级仍须强制落实,但元数据缺陷并不能否定工作实际处于实现或评审阶段。 + +**PR 转为草稿或失去评审人时将状态向后移动。** 这会让临时的 PR 状态覆盖已经观察到的更靠后工作阶段,也会使状态所有权更复杂。因此,投射保持单调。 + +## 后果 + +- PR 事件会自动纠正停留在 `Inbox`、`Backlog` 或 `Ready` 的解决型 Issue。 +- 若 Issue 创建于最后一个相关 PR 事件之后,则必须等待后续 PR 事件或人工更新状态,因为系统不会反向查找或定时扫描。 +- 即使存在历史评审活动,草稿 PR 仍保持 `In progress`;只有非草稿 PR 才会以 `In review` 为目标状态。 +- 终态以及顺序中更靠后的活跃状态不会倒退。 +- 必需的政策检查仍会暴露 PR 元数据错误,而不会因此阻止生命周期投射。 diff --git a/.github/issue-management/policy.mjs b/.github/issue-management/policy.mjs index 4c9242bab5..73703bd199 100644 --- a/.github/issue-management/policy.mjs +++ b/.github/issue-management/policy.mjs @@ -12,6 +12,7 @@ const AUDIT_MARKER = '' const OWNER_LINE = /^Owner: @([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?)$/ const TYPES = new Set(['Idea', 'Feature', 'Bug', 'Research', 'Task']) const PRIORITIES = ['p0', 'p1', 'p2', 'p3'] +const ACTIVE_STATUS_ORDER = ['Inbox', 'Backlog', 'Ready', 'In progress', 'In review'] /** * Return Markdown outside balanced details elements. @@ -129,6 +130,22 @@ export function requiresPullRequestPolicy({ return !isDraft && !automated && (reviewRequestCount > 0 || reviewCount > 0) } +/** + * Derive a forward-only Issue status from the current PR phase. + * @param {string|null} currentStatus Current Project status. + * @param {{isDraft: boolean, reviewRequestCount: number, reviewCount: number}} pull PR phase. + * @returns {string|null} Status to write, or null when no forward transition exists. + */ +export function nextResolvingIssueStatus(currentStatus, pull) { + const target = + !pull.isDraft && (pull.reviewRequestCount > 0 || pull.reviewCount > 0) + ? 'In review' + : 'In progress' + const currentIndex = ACTIVE_STATUS_ORDER.indexOf(currentStatus) + const targetIndex = ACTIVE_STATUS_ORDER.indexOf(target) + return currentIndex >= 0 && currentIndex < targetIndex ? target : null +} + function stripIgnoredMarkdown(body) { const lines = body.replace(//g, '').split(/\r?\n/) const kept = [] @@ -491,11 +508,13 @@ async function pullRequestSnapshot(number) { } } -async function moveResolvingIssues(pull, from, to) { +async function advanceResolvingIssues(pull) { for (const number of pull.references.resolving) { const current = await issueSnapshot(number) - if (!current || current.status !== from) continue - await setStatus(number, to) + if (!current) continue + const target = nextResolvingIssueStatus(current.status, pull) + if (!target) continue + await setStatus(number, target) await auditIssue(number) } } @@ -530,12 +549,7 @@ async function runLifecycle(eventName, event) { if (eventName === 'pull_request' || eventName === 'pull_request_review') { const pull = await pullRequestSnapshot(event.pull_request.number) - const errors = validatePullRequest(pull) - if (errors.length > 0) return - await moveResolvingIssues(pull, 'Ready', 'In progress') - if (pull.reviewRequestCount > 0 || pull.reviewCount > 0) { - await moveResolvingIssues(pull, 'In progress', 'In review') - } + await advanceResolvingIssues(pull) } } diff --git a/.github/issue-management/policy.test.mjs b/.github/issue-management/policy.test.mjs index 8e0c253796..86750127a7 100644 --- a/.github/issue-management/policy.test.mjs +++ b/.github/issue-management/policy.test.mjs @@ -3,6 +3,7 @@ import test from 'node:test' import { countVisibleUnits, + nextResolvingIssueStatus, parseReferences, retainIssueReferences, requiresPullRequestPolicy, @@ -191,6 +192,49 @@ test('requires policy only after a human PR enters review', () => { ) }) +test('advances resolving Issues to the live PR phase', () => { + const draft = { isDraft: true, reviewRequestCount: 1, reviewCount: 4 } + const open = { isDraft: false, reviewRequestCount: 0, reviewCount: 0 } + const requestedReview = { isDraft: false, reviewRequestCount: 1, reviewCount: 0 } + const submittedReview = { isDraft: false, reviewRequestCount: 0, reviewCount: 1 } + + for (const status of ['Inbox', 'Backlog', 'Ready']) { + assert.equal(nextResolvingIssueStatus(status, draft), 'In progress') + assert.equal(nextResolvingIssueStatus(status, open), 'In progress') + assert.equal(nextResolvingIssueStatus(status, requestedReview), 'In review') + assert.equal(nextResolvingIssueStatus(status, submittedReview), 'In review') + } + assert.equal(nextResolvingIssueStatus('In progress', requestedReview), 'In review') + assert.equal(nextResolvingIssueStatus('In progress', submittedReview), 'In review') +}) + +test('never regresses or reopens a resolving Issue', () => { + const implementation = { isDraft: false, reviewRequestCount: 0, reviewCount: 0 } + const review = { isDraft: false, reviewRequestCount: 0, reviewCount: 1 } + + assert.equal(nextResolvingIssueStatus('In progress', implementation), null) + assert.equal(nextResolvingIssueStatus('In review', implementation), null) + assert.equal(nextResolvingIssueStatus('In review', review), null) + assert.equal(nextResolvingIssueStatus('Done', review), null) + assert.equal(nextResolvingIssueStatus('No action', review), null) + assert.equal(nextResolvingIssueStatus(null, review), null) +}) + +test('keeps lifecycle projection independent of PR metadata enforcement', () => { + const pull = { + isDraft: false, + authorType: 'User', + reviewRequestCount: 1, + reviewCount: 0, + labels: [], + references: { all: [2], resolving: [2], related: [] }, + issues: new Map([[2, { priority: null }]]), + } + + assert.ok(validatePullRequest(pull).length > 0) + assert.equal(nextResolvingIssueStatus('Inbox', pull), 'In review') +}) + test('exempts Draft, Bot, and App PRs', () => { const invalid = { isDraft: false, diff --git a/package.json b/package.json index fef0a1eb53..fd7f5447ff 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "test": "vitest run", "test:coverage": "vitest run --coverage", "test:e2e": "vitest run --config vitest.e2e.config.ts", + "test:issue-management": "node --test .github/issue-management/policy.test.mjs", "test:snapshot": "vitest run --config vitest.snapshot.config.ts", "test:snapshot:record": "DSH_SNAPSHOT=record vitest run --config vitest.snapshot.config.ts --update", "test:snapshot:refresh": "DSH_SNAPSHOT=refresh vitest run --config vitest.snapshot.config.ts", diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 74d90a547d..956617c024 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -224,6 +224,7 @@ export function gatesForMode(selected: Mode): Gate[] { pnpmScript('cordis-config', 'verify-cordis-config', { label: 'Cordis config' }), pnpmScript('client-domain-graph', 'verify-client-domain-graph', { label: 'client domain graph' }), pnpmScript('test', 'test'), + pnpmScript('issue-management', 'test:issue-management', { label: 'Issue management policy' }), pnpmScript('duplication', 'duplication'), snapshotGate(), pnpmScript('build', 'build'), @@ -246,6 +247,7 @@ function ciPrimaryGates(): Gate[] { pnpmScript('constraints', 'constraints'), pnpmScript('package-invariants', 'verify-package-invariants', { label: 'package invariants' }), pnpmScript('cordis-config', 'verify-cordis-config', { label: 'Cordis config' }), + pnpmScript('issue-management', 'test:issue-management', { label: 'Issue management policy' }), pnpmScript('typecheck', 'typecheck'), lintGate(), pnpmScript('duplication', 'duplication'), @@ -343,6 +345,7 @@ function ciStaticGates(options: { ownsBuild: boolean }): Gate[] { pnpmScript('constraints', 'constraints'), pnpmScript('package-invariants', 'verify-package-invariants', { label: 'package invariants' }), pnpmScript('cordis-config', 'verify-cordis-config', { label: 'Cordis config' }), + pnpmScript('issue-management', 'test:issue-management', { label: 'Issue management policy' }), ...options.ownsBuild ? [pnpmScript('build', 'build')] : [], ...docSyncLeafGates({ includeDocTypecheck: options.ownsBuild, From 7aa0ae34b372bb5b91571830c416c661da1ae33f Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 4 Aug 2026 18:12:55 +0800 Subject: [PATCH 02/63] fix: harden issue status projection --- .github/issue-management/policy.mjs | 23 ++++++++++++++++------- scripts/run-gates.ts | 14 ++++++++------ 2 files changed, 24 insertions(+), 13 deletions(-) diff --git a/.github/issue-management/policy.mjs b/.github/issue-management/policy.mjs index 73703bd199..608291c4f8 100644 --- a/.github/issue-management/policy.mjs +++ b/.github/issue-management/policy.mjs @@ -12,7 +12,12 @@ const AUDIT_MARKER = '' const OWNER_LINE = /^Owner: @([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?)$/ const TYPES = new Set(['Idea', 'Feature', 'Bug', 'Research', 'Task']) const PRIORITIES = ['p0', 'p1', 'p2', 'p3'] -const ACTIVE_STATUS_ORDER = ['Inbox', 'Backlog', 'Ready', 'In progress', 'In review'] +const TERMINAL_STATUSES = new Set(['Done', 'No action']) +const ACTIVE_STATUS_ORDER = config.statuses.filter((status) => !TERMINAL_STATUSES.has(status)) + +for (const status of ['In progress', 'In review']) { + if (!ACTIVE_STATUS_ORDER.includes(status)) throw new Error(`config.statuses 缺少 ${status}`) +} /** * Return Markdown outside balanced details elements. @@ -418,8 +423,7 @@ async function ensureProjectItem(number) { } } -async function setStatus(number, status) { - const context = await ensureProjectItem(number) +async function updateStatus(context, status) { const option = context.statusField.options.find((candidate) => candidate.name === status) if (!option) throw new Error(`Status 不存在:${status}`) if (context.item.fieldValueByName?.name === status) return @@ -441,6 +445,10 @@ async function setStatus(number, status) { ) } +async function setStatus(number, status) { + await updateStatus(await ensureProjectItem(number), status) +} + async function upsertAudit(number, errors) { const comments = await api( `/repos/${config.organization}/${config.repository}/issues/${number}/comments?per_page=100`, @@ -510,11 +518,12 @@ async function pullRequestSnapshot(number) { async function advanceResolvingIssues(pull) { for (const number of pull.references.resolving) { - const current = await issueSnapshot(number) - if (!current) continue - const target = nextResolvingIssueStatus(current.status, pull) + const context = await projectContext(number) + const target = nextResolvingIssueStatus(context.item?.fieldValueByName?.name ?? null, pull) if (!target) continue - await setStatus(number, target) + // TODO: Replace this latest-state guard with per-Issue serialization or a + // conditional ProjectV2 update; GraphQL currently has no compare-and-swap. + await updateStatus(context, target) await auditIssue(number) } } diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 956617c024..7503c77072 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -241,13 +241,19 @@ export function gatesForMode(selected: Mode): Gate[] { } } -function ciPrimaryGates(): Gate[] { +function ciSharedStaticGates(): Gate[] { return [ pnpmScript('runtime-closure', 'verify-runtime-closure', { label: 'runtime closure' }), pnpmScript('constraints', 'constraints'), pnpmScript('package-invariants', 'verify-package-invariants', { label: 'package invariants' }), pnpmScript('cordis-config', 'verify-cordis-config', { label: 'Cordis config' }), pnpmScript('issue-management', 'test:issue-management', { label: 'Issue management policy' }), + ] +} + +function ciPrimaryGates(): Gate[] { + return [ + ...ciSharedStaticGates(), pnpmScript('typecheck', 'typecheck'), lintGate(), pnpmScript('duplication', 'duplication'), @@ -341,11 +347,7 @@ function runningNodeMajor(): number { function ciStaticGates(options: { ownsBuild: boolean }): Gate[] { return [ - pnpmScript('runtime-closure', 'verify-runtime-closure', { label: 'runtime closure' }), - pnpmScript('constraints', 'constraints'), - pnpmScript('package-invariants', 'verify-package-invariants', { label: 'package invariants' }), - pnpmScript('cordis-config', 'verify-cordis-config', { label: 'Cordis config' }), - pnpmScript('issue-management', 'test:issue-management', { label: 'Issue management policy' }), + ...ciSharedStaticGates(), ...options.ownsBuild ? [pnpmScript('build', 'build')] : [], ...docSyncLeafGates({ includeDocTypecheck: options.ownsBuild, From c92a1da8135829d86e719e7defdb5f591601e81f Mon Sep 17 00:00:00 2001 From: Jiaying Ding Date: Thu, 6 Aug 2026 16:10:18 +0800 Subject: [PATCH 03/63] fix(ui): update hero headline copy --- packages/client/ui-conversation/src/client/locales.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index 9ba5ed3876..eec25939b3 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -44,7 +44,7 @@ export const zh = { 'access.confirm.acknowledge': '我已了解风险,并愿意继续', 'access.confirm.cancel': '取消', 'access.confirm.enable': '启用 Full access', - 'hero.headline': '开始构建吧', + 'hero.headline': '探索未知之境', 'hero.preview': '预览版', 'hero.chooseWorkspace': '选择工作区', 'session.hierarchy': '会话层级', @@ -184,7 +184,7 @@ export const en = { 'access.confirm.acknowledge': 'I understand the risks and want to continue', 'access.confirm.cancel': 'Cancel', 'access.confirm.enable': 'Enable Full access', - 'hero.headline': 'Let\'s start building', + 'hero.headline': 'Into the unknown', 'hero.preview': 'Preview', 'hero.chooseWorkspace': 'Choose workspace', 'session.hierarchy': 'Session hierarchy', From c6b581b4e8069de5cc5594427274f0468b58aaf4 Mon Sep 17 00:00:00 2001 From: Jiaying Ding Date: Thu, 6 Aug 2026 16:26:53 +0800 Subject: [PATCH 04/63] test(ui): update hero headline expectations --- .../client/ui-conversation/tests/skeleton.spec.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index b2828bcc80..858cbe5b7b 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -218,7 +218,7 @@ function mount( describe('Hero chrome', () => { it('renders the English preview badge through the hero locale seat', () => { const view = render() - expect(view.getByText('Let\'s start building')).toBeTruthy() + expect(view.getByText('Into the unknown')).toBeTruthy() expect(view.getByText('Preview')).toBeTruthy() }) }) @@ -282,7 +282,7 @@ describe('ConversationRoot resident composer', () => { const header = b.view.container.querySelector('header') expect(host).not.toBeNull() expect(header?.getAttribute('aria-hidden')).toBe('true') - expect(b.view.getByText('开始构建吧')).toBeTruthy() + expect(b.view.getByText('探索未知之境')).toBeTruthy() expect(b.view.getByText('预览版')).toBeTruthy() expect(b.view.queryByTestId('view-chat')).toBeNull() // The same machine-backed textarea is live in the hero, and the @@ -306,7 +306,7 @@ describe('ConversationRoot resident composer', () => { const b = mount(conversationSnapshot({ composerPhase: 'blank', blank: true, openState: 'loading' })) const root = b.view.container.querySelector('[data-phase]') expect(root?.getAttribute('data-phase')).toBe('settling') - expect(b.view.queryByText('开始构建吧')).toBeNull() + expect(b.view.queryByText('探索未知之境')).toBeNull() }) it('settling phase: a session the list has no row for settles conservatively', () => { @@ -331,7 +331,7 @@ describe('ConversationRoot resident composer', () => { // blank the column for the history round-trip. const root = b.view.container.querySelector('[data-phase]') expect(root?.getAttribute('data-phase')).toBe('hero') - expect(b.view.getByText('开始构建吧')).toBeTruthy() + expect(b.view.getByText('探索未知之境')).toBeTruthy() expect(b.view.getByRole('textbox')).toBeTruthy() }) @@ -349,7 +349,7 @@ describe('ConversationRoot resident composer', () => { expect(after.value).toBe('kept across flip') expect(b.chat.store.getSnapshot().draft).toBe('kept across flip') expect(b.view.container.querySelector('[data-conversation-scroll]')?.contains(after)).toBe(true) - expect(b.view.queryByText('开始构建吧')).toBeNull() + expect(b.view.queryByText('探索未知之境')).toBeNull() expect(b.view.getByTestId('view-chat')).toBeTruthy() }) From 6515988ec7264331dc89b5746dea7e7a7ae51059 Mon Sep 17 00:00:00 2001 From: Jiaying Ding Date: Thu, 6 Aug 2026 17:40:48 +0800 Subject: [PATCH 05/63] Update startup-auto-selection.e2e.ts --- apps/web/tests/startup-auto-selection.e2e.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/tests/startup-auto-selection.e2e.ts b/apps/web/tests/startup-auto-selection.e2e.ts index f3a953c1e6..141a5b427c 100644 --- a/apps/web/tests/startup-auto-selection.e2e.ts +++ b/apps/web/tests/startup-auto-selection.e2e.ts @@ -103,7 +103,7 @@ describe('web e2e: startup auto-selection', () => { // seat with `visibility:hidden`, which Playwright reports as not visible). await page.waitForSelector(ROOT_PHASE, { timeout: 15_000 }) expect(await page.locator(ROOT_PHASE).first().getAttribute('data-phase')).toBe('hero') - expect(await page.getByText("Let's start building").isVisible()).toBe(true) + expect(await page.getByText("Into the unknown").isVisible()).toBe(true) expect(await page.locator('textarea').first().isVisible()).toBe(true) releaseHistory() From 9bb0aecb92a22a2472b76e5eb551dd4906164ee9 Mon Sep 17 00:00:00 2001 From: Jiaying Ding Date: Thu, 6 Aug 2026 17:41:54 +0800 Subject: [PATCH 06/63] Update hmr-live.e2e.ts --- apps/web/tests/hmr-live.e2e.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/tests/hmr-live.e2e.ts b/apps/web/tests/hmr-live.e2e.ts index df81a10402..385a516e7d 100644 --- a/apps/web/tests/hmr-live.e2e.ts +++ b/apps/web/tests/hmr-live.e2e.ts @@ -75,8 +75,8 @@ it('hot-reloads a real client-plugin source edit without refreshing the page', a if (!existsSync(binPath)) throw new Error('HMR browser test needs the built dsh bin; run pnpm run build first') const originalSource = await readFile(sourcePath) const originalBundle = await readFile(bundlePath) - const oldText = "Let's start building" - const sourceNeedle = "'hero.headline': 'Let\\'s start building'" + const oldText = "Into the unknown" + const sourceNeedle = "'hero.headline': 'Into the unknown'" const newText = `HMR UPDATED ${'x'.repeat(80)}` const updatedSource = originalSource.toString().replace(sourceNeedle, `'hero.headline': '${newText}'`) if (updatedSource === originalSource.toString()) throw new Error(`HMR source lacks ${JSON.stringify(sourceNeedle)}`) From 9a9bfbf306bbf5f0c57cabf18c33f9a2d1ce7bb0 Mon Sep 17 00:00:00 2001 From: Jiaying Ding Date: Thu, 6 Aug 2026 17:51:23 +0800 Subject: [PATCH 07/63] Update lifecycle-chrome.e2e.ts --- apps/web/tests/lifecycle-chrome.e2e.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/tests/lifecycle-chrome.e2e.ts b/apps/web/tests/lifecycle-chrome.e2e.ts index 8c81f55810..f37f02b6af 100644 --- a/apps/web/tests/lifecycle-chrome.e2e.ts +++ b/apps/web/tests/lifecycle-chrome.e2e.ts @@ -152,7 +152,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () } // The blank frame renders the hero, not the resident composer: the // headline plus the guidance placeholder are the empty state's anchors. - await expect.poll(() => page.getByText("Let's start building", { exact: false }).count(), { timeout: 15_000 }).toBe(1) + await expect.poll(() => page.getByText("Into the unknown", { exact: false }).count(), { timeout: 15_000 }).toBe(1) const input = page.locator('textarea').first() await input.waitFor({ timeout: 10_000 }) if (MODE !== 'record') { From c22337a71e237b7ec617fbb65c3fe6d49c76f968 Mon Sep 17 00:00:00 2001 From: Jiaying Ding Date: Thu, 6 Aug 2026 17:59:42 +0800 Subject: [PATCH 08/63] Update hero.expected.md --- apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md index 728dc768f8..ad060c5d59 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md @@ -20,7 +20,7 @@ - button "Settings": - img - text: Settings -- text: Let's start building Preview +- text: Into the unknown Preview - button "Choose workspace": - img - text: workspace From 342229dc14fa90a418fd47b16312db4a051afdb5 Mon Sep 17 00:00:00 2001 From: Jiaying Ding Date: Thu, 6 Aug 2026 18:02:23 +0800 Subject: [PATCH 09/63] Update plan-active.expected.md --- .../tests/snapshots/lifecycle-chrome/plan-active.expected.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md index 6b4d7633e5..ce2ce36af0 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md @@ -20,7 +20,7 @@ - button "Settings": - img - text: Settings -- text: Let's start building Preview +- text: Into the unknown Preview - button "Choose workspace": - img - text: workspace From 2e943a16432e4572c87783efc43d0d7272daa64d Mon Sep 17 00:00:00 2001 From: Jiaying Ding Date: Thu, 6 Aug 2026 18:31:44 +0800 Subject: [PATCH 10/63] Update details-session-lifecycle.e2e.ts --- apps/web/tests/details-session-lifecycle.e2e.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/tests/details-session-lifecycle.e2e.ts b/apps/web/tests/details-session-lifecycle.e2e.ts index cb6c9ba914..5317c39009 100644 --- a/apps/web/tests/details-session-lifecycle.e2e.ts +++ b/apps/web/tests/details-session-lifecycle.e2e.ts @@ -121,7 +121,7 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S expect(await page.getByText('Details', { exact: true }).isVisible()).toBe(false) await page.getByRole('button', { name: /^(?:New session|新.*会话)$/ }).last().click() - await page.getByText("Let's start building", { exact: false }).waitFor({ timeout: 15_000 }) + await page.getByText("Into the unknown", { exact: false }).waitFor({ timeout: 15_000 }) await expect.poll(() => detailsTrack(page), { timeout: 5_000 }).toBe(0) expect(await page.getByText('Details', { exact: true }).isVisible()).toBe(false) From 8f2168303b246d2f4a988b29dbaae3e5794b2c5a Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 6 Aug 2026 23:00:01 +0800 Subject: [PATCH 11/63] feat(web): add install metadata --- ...06-resolved-theme-color-metadata.i18n.yaml | 6 +++ ...026-08-06-resolved-theme-color-metadata.md | 31 +++++++++++++++ ...-08-06-resolved-theme-color-metadata.zh.md | 31 +++++++++++++++ .../2026-08-06-web-install-manifest.i18n.yaml | 6 +++ .../2026-08-06-web-install-manifest.md | 39 +++++++++++++++++++ .../2026-08-06-web-install-manifest.zh.md | 39 +++++++++++++++++++ apps/web/index.html | 1 + apps/web/public/manifest.webmanifest | 16 ++++++++ apps/web/tests/pwa-manifest.e2e.ts | 27 +++++++++++++ apps/web/tests/settings-chrome.e2e.ts | 36 ++++++++++++++--- packages/client/ui-layout/README.i18n.yaml | 4 +- packages/client/ui-layout/README.md | 2 +- packages/client/ui-layout/README.zh.md | 2 +- .../ui-layout/src/client/theme-presenter.ts | 26 ++++++++++--- packages/client/ui-layout/tests/apply.spec.ts | 10 ++++- .../ui-layout/tests/theme-presenter.spec.ts | 36 +++++++++++++++-- .../host/frontend-static/README.i18n.yaml | 4 +- packages/host/frontend-static/README.md | 2 +- packages/host/frontend-static/README.zh.md | 2 +- packages/host/frontend-static/src/index.ts | 1 + .../tests/frontend-static.spec.ts | 8 +++- 21 files changed, 305 insertions(+), 24 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-06-resolved-theme-color-metadata.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-06-resolved-theme-color-metadata.md create mode 100644 .agents/notes/implemented/feature/2026-08-06-resolved-theme-color-metadata.zh.md create mode 100644 .agents/notes/implemented/feature/2026-08-06-web-install-manifest.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-06-web-install-manifest.md create mode 100644 .agents/notes/implemented/feature/2026-08-06-web-install-manifest.zh.md create mode 100644 apps/web/public/manifest.webmanifest create mode 100644 apps/web/tests/pwa-manifest.e2e.ts diff --git a/.agents/notes/implemented/feature/2026-08-06-resolved-theme-color-metadata.i18n.yaml b/.agents/notes/implemented/feature/2026-08-06-resolved-theme-color-metadata.i18n.yaml new file mode 100644 index 0000000000..7550af746a --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-06-resolved-theme-color-metadata.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-06-resolved-theme-color-metadata.md +2026-08-06-resolved-theme-color-metadata.md: 2f7a6f0bde5e75aeb6769939cae54d5319aa5bae +2026-08-06-resolved-theme-color-metadata.zh.md: a6d530f841d5c744fc88831f9cb685d1ab5027b6 diff --git a/.agents/notes/implemented/feature/2026-08-06-resolved-theme-color-metadata.md b/.agents/notes/implemented/feature/2026-08-06-resolved-theme-color-metadata.md new file mode 100644 index 0000000000..2f7a6f0bde --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-06-resolved-theme-color-metadata.md @@ -0,0 +1,31 @@ +# Agent Note: Resolved theme color metadata + +Status: implemented + +English | [中文](2026-08-06-resolved-theme-color-metadata.zh.md) + +## Problem + +The web client can resolve its theme independently of the operating-system preference, so a single manifest `theme_color` or media-qualified static metadata can disagree with an explicit Light or Dark selection. Browser chrome around an installed or ordinary page then need not match the app surface even though the layout presenter already owns the resolved document palette. + +## Decision + +The ui-layout `ThemePresenter` owns one `` alongside its root `color-scheme`, dark-palette attribute, and inline token writes. After applying a resolved snapshot's palette and token overrides, the presenter reads the body's computed `background-color` into the metadata element and inserts that single node into the document head. Subsequent snapshots update the same node, and disposal removes it. + +The rendered body background remains the color authority. The PWA manifest carries no static `theme_color` or `background_color`, and `ThemeDefinition` gains no second color field that could drift from the token palette. This also lets a registered theme's base-background token reach browser UI through the same application path as its page surface. + +## Verification + +The presenter unit contract covers light and dark computed colors, node reuse, and disposal. The ui-layout composition test covers initial insertion, event-driven reuse, and fiber cleanup. The Web browser settings scenario drives Light, Dark, System, operating-system changes, and reload through the shipped composition, asserting one metadata element whose content equals the computed body background with no console errors. The metadata change has no rendered accessibility-tree output, so the existing scenario golden remains unchanged. + +## Alternatives considered + +**Set `theme_color` in the manifest.** A manifest provides one app-wide value, so either built-in palette can disagree with it; the manifest deliberately omits the field. + +**Declare light and dark metadata with `prefers-color-scheme` media queries.** Media queries follow the operating system, not an explicit in-app selection, and therefore cannot represent the resolved preference. + +**Add a `themeColor` field to every `ThemeDefinition`.** A separate value gives custom themes an independent browser-chrome choice, but duplicates the base-background color and permits the page and surrounding UI to drift. A distinct field can be introduced if a supported theme needs that intentional difference. + +## Consequences + +Supporting browsers update surrounding UI after the client applies its initial resolved snapshot and after every theme change; browsers without `theme-color` support ignore the metadata. Because the value comes from computed presentation, the client must keep a concrete body background. The presenter creates and removes its own node, while unrelated head metadata remains untouched. diff --git a/.agents/notes/implemented/feature/2026-08-06-resolved-theme-color-metadata.zh.md b/.agents/notes/implemented/feature/2026-08-06-resolved-theme-color-metadata.zh.md new file mode 100644 index 0000000000..a6d530f841 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-06-resolved-theme-color-metadata.zh.md @@ -0,0 +1,31 @@ +# Agent Note: 基于解析后主题的颜色元数据 + +Status: implemented + +[English](2026-08-06-resolved-theme-color-metadata.md) | 中文 + +## 问题 + +Web 客户端可以独立于操作系统偏好解析主题,因此 manifest(元数据清单)中单一的 `theme_color` 值或带媒体条件的静态元数据可能与显式选择的 Light 或 Dark 不一致。此时,无论是已安装页面还是普通页面,其周围的浏览器界面都未必与应用界面一致,尽管布局呈现器已经拥有解析后的 document 调色板。 + +## 决策 + +ui-layout 的 `ThemePresenter` 拥有一个 ``,与根元素上的 `color-scheme`、深色调色板属性和内联 token 写入并列。在应用解析后快照的调色板与 token 覆盖值之后,呈现器读取 body 计算样式中的 `background-color`,写入该元数据元素,再将该节点插入 document head。后续快照会更新同一节点,资源释放时则移除它。 + +渲染后的 body 背景仍是颜色真源。PWA manifest 不包含静态 `theme_color` 或 `background_color`,`ThemeDefinition` 也不新增可能与 token 调色板偏离的第二个颜色字段。这样一来,注册主题的基础背景 token 也能通过页面界面使用的同一条应用路径作用于浏览器界面。 + +## 验证 + +呈现器的单元测试契约覆盖浅色和深色模式下的计算颜色、节点复用及资源释放。ui-layout 组合测试覆盖初始插入、事件驱动的复用和 fiber 清理。Web 浏览器设置场景通过实际交付的组合依次驱动 Light、Dark、System、操作系统偏好变化和重新加载,并断言页面始终只有一个元数据元素,其内容等于计算后的 body 背景且控制台无错误。这项元数据变更不会出现在渲染后的无障碍树输出中,因此场景现有的预期输出保持不变。 + +## 曾考虑的替代方案 + +**在 manifest 中设置 `theme_color`。** manifest 只能提供一个适用于整个应用的值,因此任一内置调色板都可能与之不一致;manifest 有意省略该字段。 + +**用 `prefers-color-scheme` 媒体查询声明浅色和深色元数据。** 媒体查询跟随操作系统,而非应用内显式选择,因此无法表示解析后的偏好。 + +**为每个 `ThemeDefinition` 添加 `themeColor` 字段。** 单独的值可让自定义主题独立选择浏览器界面配色,但会复制基础背景色,并允许页面与周围的浏览器界面发生偏离。如果受支持的主题需要这种有意差异,可以再引入独立字段。 + +## 后果 + +支持该元数据的浏览器会在客户端应用初始解析后快照及之后每次主题变化时更新周围界面;不支持 `theme-color` 的浏览器会忽略这项元数据。由于该值来自计算后的呈现结果,客户端必须确保 body 始终有明确的背景色。呈现器会创建并移除自己的节点,head 中无关的元数据则保持不变。 diff --git a/.agents/notes/implemented/feature/2026-08-06-web-install-manifest.i18n.yaml b/.agents/notes/implemented/feature/2026-08-06-web-install-manifest.i18n.yaml new file mode 100644 index 0000000000..d13ede02d2 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-06-web-install-manifest.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-06-web-install-manifest.md +2026-08-06-web-install-manifest.md: d400c6e586f4b735fa8e3dcc4899c97e45ac89c1 +2026-08-06-web-install-manifest.zh.md: a7fee0248261e8d0597bb773d4f390973147337b diff --git a/.agents/notes/implemented/feature/2026-08-06-web-install-manifest.md b/.agents/notes/implemented/feature/2026-08-06-web-install-manifest.md new file mode 100644 index 0000000000..d400c6e586 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-06-web-install-manifest.md @@ -0,0 +1,39 @@ +# Agent Note: Web install manifest metadata + +Status: implemented + +English | [中文](2026-08-06-web-install-manifest.zh.md) + +## Problem + +The Web build has a document title and favicon but no manifest from which a browser can discover a stable installed identity, launch boundary, or installed presentation. Adding that metadata can also imply capabilities the app does not provide: a service worker suggests an offline contract, while a single language or palette value misrepresents a bilingual UI with resolved light and dark themes. + +## Decision + +The Web entry links `/manifest.webmanifest`, which Vite copies from `apps/web/public/` into the production build. The manifest names the product `DeepSeek Harness`, gives installed chrome the compact name `DSH`, and fixes `id`, `start_url`, and `scope` at `/`. It requests `display: "fullscreen"` so supporting browsers can give the installed editor-like surface the available display area while leaving ordinary tabs unchanged; browsers may apply user overrides or fall back to another display mode. Its icon entry reuses `/favicon.svg` as an SVG of size `any` and purpose `any`. + +This follows code-server's fullscreen choice without copying its `window-controls-overlay` display override. DSH has no custom title bar or layout around native window controls, so such an override would supersede fullscreen without owning the required safe layout. + +The manifest deliberately has no `lang`, `theme_color`, or `background_color`. The product surface is bilingual rather than owned by one manifest language, and either static color can disagree with one of the resolved app palettes. Theme metadata therefore remains outside the install manifest. + +This feature adds no service worker, cache policy, or offline fallback. The manifest supplies install metadata only; browser eligibility and install affordances remain browser policy. The shipped [`dsh-frontend-static`](../../../../packages/host/frontend-static/README.md) fallback recognizes `.webmanifest` as `application/manifest+json` so the same asset is valid through the shipped HTTP composition rather than only in Vite's output directory. + +## Verification + +The built-Web test parses the emitted manifest and pins the complete metadata object, including the human-visible name, compact name, icon, root identity, launch boundary, and display mode, while also verifying that the production `index.html` retains the link. The `dsh-frontend-static` real Loader composition test serves a `.webmanifest` fixture and pins its `application/manifest+json` media type. + +## Alternatives considered + +**Add a service worker and call the app offline-capable.** Rejected because caching the shell without defining session transport, invalidation, failure behavior, and upgrade semantics would create a misleading partial offline contract. + +**Declare one `lang`.** Rejected because no single language describes the bilingual product surface; omission avoids claiming that one locale owns the installed experience. + +**Choose one static background and theme color.** Rejected because the app resolves light and dark palettes at runtime, so either fixed value is knowingly wrong for one supported state. + +**Ship raster and maskable icon variants immediately.** Rejected until a supported installation target demonstrates a requirement the existing scalable favicon cannot meet. New variants remain an additive manifest change rather than a prerequisite for exposing the current identity. + +**Assert only root and display fields in the built artifact.** Rejected because dropping or changing the product name, compact name, or icon is also a shipped install regression. The test intentionally requires an explicit edit whenever any manifest metadata changes. + +## Consequences + +Supporting browsers can discover a stable root-scoped installed identity and fullscreen preference without the application promising offline behavior. Deploying this build below a path prefix requires revisiting the absolute link, identity, launch, scope, and icon URLs together. Browser-specific icon requirements may add variants later, and every intentional metadata change updates the exact built-artifact contract. diff --git a/.agents/notes/implemented/feature/2026-08-06-web-install-manifest.zh.md b/.agents/notes/implemented/feature/2026-08-06-web-install-manifest.zh.md new file mode 100644 index 0000000000..a7fee02482 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-06-web-install-manifest.zh.md @@ -0,0 +1,39 @@ +# Agent Note: Web 安装 manifest 元数据 + +Status: implemented + +[English](2026-08-06-web-install-manifest.md) | 中文 + +## 问题 + +Web 构建产物已有文档标题和 favicon,却没有可供浏览器发现稳定安装身份、启动边界或安装后呈现方式的 manifest(元数据清单)。添加这类元数据也可能暗示应用并不具备的能力:service worker 会让人以为应用提供离线契约,而单一语言或调色板取值会错误描述这个能够解析浅色与深色主题的双语 UI。 + +## 决策 + +Web 入口链接 `/manifest.webmanifest`,Vite 会将其从 `apps/web/public/` 复制到生产构建产物。manifest 将产品命名为 `DeepSeek Harness`,为安装后的浏览器界面提供简称 `DSH`,并把 `id`、`start_url` 和 `scope` 固定为 `/`。它请求 `display: "fullscreen"`,使支持这一模式的浏览器能够把可用显示区域交给安装后的编辑器式界面,同时不改变普通标签页;浏览器可以应用用户覆盖设置,或回退到其他显示模式。其图标条目复用 `/favicon.svg`,将它作为尺寸为 `any`、用途为 `any` 的 SVG。 + +这一选择沿用了 code-server 的全屏方案,但没有照搬其 `window-controls-overlay` 显示覆盖项。DSH 没有自定义标题栏,也没有围绕原生窗口控件安排布局,因此使用这类覆盖项会在未落实所需安全布局的情况下取代全屏模式。 + +manifest 有意不包含 `lang`、`theme_color` 或 `background_color`。产品界面支持双语,并不由 manifest 中的单一语言定义;任一静态颜色值都可能与应用解析后的一套调色板不一致。因此,主题元数据仍放在安装 manifest 之外。 + +该功能不添加 service worker、缓存策略或离线回退。manifest 只提供安装元数据;是否具备安装资格、是否提供安装入口仍由浏览器策略决定。实际交付的 [`dsh-frontend-static`](../../../../packages/host/frontend-static/README.md) 回退将 `.webmanifest` 识别为 `application/manifest+json`,因此同一资产经实际交付的 HTTP 组合提供时同样有效,而不只在 Vite 输出目录中有效。 + +## 验证 + +Web 构建产物测试解析输出的 manifest,并固定完整的元数据对象,包括面向用户显示的名称、简称、图标、根路径身份、启动边界和显示模式,同时验证生产构建的 `index.html` 仍保留该链接。`dsh-frontend-static` 的真实 Loader 组合测试提供一个 `.webmanifest` fixture(测试前置数据),并固定其 `application/manifest+json` 媒体类型。 + +## 曾考虑的替代方案 + +**添加 service worker,并宣称应用支持离线。** 不予采纳,因为只缓存应用外壳,却不定义会话传输、失效策略、失败行为和升级语义,会形成具有误导性的不完整离线契约。 + +**声明单一的 `lang`。** 不予采纳,因为没有任何一种语言足以描述双语产品界面;省略该字段可避免声称安装后的体验由某一种区域设置独占。 + +**选择一组静态背景色和主题色。** 不予采纳,因为应用会在运行时解析浅色和深色调色板,因此选择任一固定值,都是明知它与其中一种受支持状态不符。 + +**立即交付光栅和可遮罩图标变体。** 在某个受支持的安装目标证明现有可缩放 favicon 无法满足其要求之前,不予采纳。新变体只是对 manifest 的增量扩展,并非公开当前身份的前提。 + +**只断言构建产物中的根路径字段和显示字段。** 不予采纳,因为产品名称、简称或图标被删除或更改,同样属于已交付安装体验的回归。任何 manifest 元数据发生变化时,测试都有意要求显式改动。 + +## 后果 + +支持这一机制的浏览器可以发现以根路径为作用域的稳定安装身份和全屏偏好,而应用无需承诺离线行为。在路径前缀下部署该构建产物时,必须同时重新审视绝对路径的 manifest 链接,以及身份、启动、作用域和图标 URL。日后可能因浏览器特有的图标要求而新增变体;每一项有意的元数据变更都会同步更新精确的构建产物契约。 diff --git a/apps/web/index.html b/apps/web/index.html index c9fc7d124c..a14de72d40 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -3,6 +3,7 @@ + DeepSeek Harness diff --git a/apps/web/public/manifest.webmanifest b/apps/web/public/manifest.webmanifest new file mode 100644 index 0000000000..20a428fee6 --- /dev/null +++ b/apps/web/public/manifest.webmanifest @@ -0,0 +1,16 @@ +{ + "id": "/", + "name": "DeepSeek Harness", + "short_name": "DSH", + "start_url": "/", + "scope": "/", + "display": "fullscreen", + "icons": [ + { + "src": "/favicon.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any" + } + ] +} diff --git a/apps/web/tests/pwa-manifest.e2e.ts b/apps/web/tests/pwa-manifest.e2e.ts new file mode 100644 index 0000000000..696e1c7797 --- /dev/null +++ b/apps/web/tests/pwa-manifest.e2e.ts @@ -0,0 +1,27 @@ +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import { join } from 'node:path' +import { expect, it } from 'vitest' + +const DIST_ROOT = fileURLToPath(new URL('../dist', import.meta.url)) + +it('ships install metadata with the built web application', async () => { + const index = await readFile(join(DIST_ROOT, 'index.html'), 'utf8') + expect(index).toContain('') + + const manifest: unknown = JSON.parse(await readFile(join(DIST_ROOT, 'manifest.webmanifest'), 'utf8')) + expect(manifest).toEqual({ + id: '/', + name: 'DeepSeek Harness', + short_name: 'DSH', + start_url: '/', + scope: '/', + display: 'fullscreen', + icons: [{ + src: '/favicon.svg', + sizes: 'any', + type: 'image/svg+xml', + purpose: 'any', + }], + }) +}) diff --git a/apps/web/tests/settings-chrome.e2e.ts b/apps/web/tests/settings-chrome.e2e.ts index 43500585d8..e6b10664af 100644 --- a/apps/web/tests/settings-chrome.e2e.ts +++ b/apps/web/tests/settings-chrome.e2e.ts @@ -1,7 +1,8 @@ // Web e2e scenarios: the settings surface — the modal shell (trigger, nav, // section switching, both close paths), the Appearance preference row (the // real theme gesture — click 深色 and the whole cascade runs: ThemeService preference -> localStorage dsh.theme -// -> theme/change -> ui-layout's presenter -> body attribute -> alias token) +// -> theme/change -> ui-layout's presenter -> body attribute -> alias token + +// browser theme-color metadata) // the Language row (settings-scoped localization + persisted dsh.locale), // the busy-state Enter preference, plus Permission as the persisted default // for subsequently created sessions. @@ -154,17 +155,37 @@ describe('web e2e: settings modal and General preferences', () => { it('flips the theme through the Appearance cubes and persists across reload', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-appearance')) - const readState = async (): Promise<{ attr: boolean; token: string; stored: string | null }> => - await page.evaluate(() => ({ + interface ThemeState { + attr: boolean + background: string + stored: string | null + themeColor: string | null + themeColorCount: number + token: string + } + const readState = async (): Promise => await page.evaluate(() => { + const metas = document.head.querySelectorAll('meta[name="theme-color"]') + const computed = getComputedStyle(document.body) + return { attr: document.body.hasAttribute('data-ds-dark-theme'), - token: getComputedStyle(document.body).getPropertyValue('--dsw-alias-bg-base').trim(), + background: computed.backgroundColor, stored: localStorage.getItem('dsh.theme'), - })) + themeColor: metas[0]?.content ?? null, + themeColorCount: metas.length, + token: computed.getPropertyValue('--dsw-alias-bg-base').trim(), + } + }) + const expectThemeColorSynchronized = (state: ThemeState): void => { + expect(state.themeColorCount).toBe(1) + expect(state.background).not.toBe('rgba(0, 0, 0, 0)') + expect(state.themeColor).toBe(state.background) + } // Pin the OS scheme to light so the default `system` preference resolves // light and the dark flip below is unambiguously the gesture's doing. await page.emulateMedia({ colorScheme: 'light' }) const light = await readState() expect(light.attr).toBe(false) + expectThemeColorSynchronized(light) await page.getByRole('button', { name: '设置', exact: true }).click() const dialog = page.getByRole('dialog', { name: '设置' }) @@ -179,6 +200,7 @@ describe('web e2e: settings modal and General preferences', () => { expect(dark.attr).toBe(true) expect(dark.stored).toBe('dark') expect(dark.token).not.toBe(light.token) + expectThemeColorSynchronized(dark) await page.keyboard.press('Escape') // Reload: the preference survives boot (restore + presenter initial apply). @@ -190,6 +212,7 @@ describe('web e2e: settings modal and General preferences', () => { const reloaded = await readState() expect(reloaded.attr).toBe(true) expect(reloaded.stored).toBe('dark') + expectThemeColorSynchronized(reloaded) // `system` follows the emulated OS scheme (dark stays dark, light clears). await page.getByRole('button', { name: '设置', exact: true }).click() @@ -197,12 +220,15 @@ describe('web e2e: settings modal and General preferences', () => { await systemCube.click() await expect.poll(() => systemCube.getAttribute('aria-pressed'), { timeout: 5_000 }).toBe('true') await expect.poll(async () => (await readState()).attr, { timeout: 5_000 }).toBe(false) + expectThemeColorSynchronized(await readState()) await page.emulateMedia({ colorScheme: 'dark' }) await expect.poll(async () => (await readState()).attr, { timeout: 5_000 }).toBe(true) + expectThemeColorSynchronized(await readState()) // Restore for the specs that follow: light preference beats the emulated // dark OS scheme, leaving the shared page in the light default. await page.getByRole('dialog', { name: '设置' }).getByRole('button', { name: '浅色' }).click() await expect.poll(async () => (await readState()).attr, { timeout: 5_000 }).toBe(false) + expectThemeColorSynchronized(await readState()) await page.keyboard.press('Escape') expect(tripwire.pageErrors).toEqual([]) }, 90_000) diff --git a/packages/client/ui-layout/README.i18n.yaml b/packages/client/ui-layout/README.i18n.yaml index 8b5aff5db5..d04c06b3da 100644 --- a/packages/client/ui-layout/README.i18n.yaml +++ b/packages/client/ui-layout/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-layout/README.md -README.md: 5cb8f01efb2e18109e917225dbce088ea77394af -README.zh.md: 6559fe595a6219b139fe46cf046906fa63636f64 +README.md: fa60520a20ac8a7f25d494879c68efb06a28998f +README.zh.md: 6ca04c56c29a55f84fc7a6399a7feeb81d249899 diff --git a/packages/client/ui-layout/README.md b/packages/client/ui-layout/README.md index 5cb8f01efb..fa60520a20 100644 --- a/packages/client/ui-layout/README.md +++ b/packages/client/ui-layout/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Shell plugin: three-column AppFrame (drag handles and concession chain) plus the `ctx.layout` panel-geometry service; it registers into the runtime-owned `root` slot and declares `sidebar`, `conversation`, `details`, and `conversation.empty`. The sidebar resize boundary is an invisible hit strip, while the details boundary retains its floating pill; only details shrinks during concession and then auto-closes. A closed sidebar retains a 56px control rail while details closes to zero width. The package also seats the theme presenter: it consumes resolved `ctx.theme` snapshots and projects them onto the document (`html { color-scheme }` for native UA chrome, `body[data-ds-dark-theme]` from the active color scheme, plus the theme's alias tokens as inline variables on body). +Shell plugin: three-column AppFrame (drag handles and concession chain) plus the `ctx.layout` panel-geometry service; it registers into the runtime-owned `root` slot and declares `sidebar`, `conversation`, `details`, and `conversation.empty`. The sidebar resize boundary is an invisible hit strip, while the details boundary retains its floating pill; only details shrinks during concession and then auto-closes. A closed sidebar retains a 56px control rail while details closes to zero width. The package also seats the theme presenter: it consumes resolved `ctx.theme` snapshots and projects them onto the document (`html { color-scheme }` for native UA chrome, `body[data-ds-dark-theme]` from the active color scheme, the theme's alias tokens as inline variables on body, and one owned `` whose content follows the computed body background). Measuring after palette and token application keeps the rendered background as the single color authority; disposing the presenter removes its metadata node with its other global writes. AppFrame always mounts the conversation and details columns; a connected Session renders through `SessionProvider`. The transient layout store starts the sidebar at its default width and details closed, and it never reads or writes `localStorage`. Hero and other unselected states also derive a zero rendered details width without changing that stored preference. AppFrame retains the last non-blank Session id across those states: the first Session remains closed, an explicit details action opens the contract default width, returning to the same Session restores its unchanged width, and selecting a different Session closes details before paint. The conversation owner share is empty, while the sidebar owner share contains only `collapsed` and `width`; registrants obtain business data from standard hooks and actions from their own inject faces. diff --git a/packages/client/ui-layout/README.zh.md b/packages/client/ui-layout/README.zh.md index 6559fe595a..6ca04c56c2 100644 --- a/packages/client/ui-layout/README.zh.md +++ b/packages/client/ui-layout/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -外壳插件:三栏 AppFrame(拖动手柄与让步链)加 `ctx.layout` 面板几何服务;它注册到运行时拥有的 `root` slot,并声明 `sidebar`、`conversation`、`details` 和 `conversation.empty`。侧边栏的缩放边界是不可见命中条带,详情栏边界则保留其浮动胶囊;让步期间只有详情栏会收缩并随后自动关闭。关闭的侧边栏仍保留 56px 控制栏,详情栏则关闭到零宽度。该包还提供主题呈现器:它消费解析后的 `ctx.theme` 快照,并将其投影到 document(用 `html { color-scheme }` 驱动原生 UA 控件,依据当前配色方案设置 `body[data-ds-dark-theme]`,并将主题的别名 token 设为 body 上的内联变量)。 +外壳插件:三栏 AppFrame(拖动手柄与让步链)加 `ctx.layout` 面板几何服务;它注册到运行时拥有的 `root` slot,并声明 `sidebar`、`conversation`、`details` 和 `conversation.empty`。侧边栏的缩放边界是不可见命中条带,详情栏边界则保留其浮动胶囊;让步期间只有详情栏会收缩并随后自动关闭。关闭的侧边栏仍保留 56px 控制栏,详情栏则关闭到零宽度。该包还提供主题呈现器:它消费解析后的 `ctx.theme` 快照,并将其投影到 document(用 `html { color-scheme }` 驱动原生 UA 控件,依据当前配色方案设置 `body[data-ds-dark-theme]`,并将主题的别名 token 设为 body 上的内联变量,同时拥有一个 ``,其内容随计算后的 body 背景色更新)。在应用调色板和 token 后进行测量,可确保渲染后的背景保持为唯一颜色真源;呈现器在资源释放时会移除其自有的元数据节点,并一并清除其写入的其他全局状态。 AppFrame 始终挂载会话栏和详情栏;已连接 Session 通过 `SessionProvider` 渲染。布局 store 是瞬时状态,侧边栏以默认宽度启动,详情栏则保持关闭,且该 store 从不读写 `localStorage`。hero 和其他未选中状态也会将详情栏的渲染宽度派生为零,但不会改变存储的宽度偏好。AppFrame 会跨越这些状态保留最后一个非 blank 会话 id:首个会话保持关闭;显式打开详情栏的操作会使用契约默认宽度;返回同一会话时恢复其未改变的宽度;选择不同会话时,详情栏会在绘制前关闭。会话 owner share 为空,侧边栏 owner share 只包含 `collapsed` 和 `width`;注册方通过标准钩子获取业务数据,并从各自的 inject 接口获取操作。 diff --git a/packages/client/ui-layout/src/client/theme-presenter.ts b/packages/client/ui-layout/src/client/theme-presenter.ts index 07dc663c54..87e3592798 100644 --- a/packages/client/ui-layout/src/client/theme-presenter.ts +++ b/packages/client/ui-layout/src/client/theme-presenter.ts @@ -1,10 +1,11 @@ /** * Global theme DOM applier: projects the resolved ThemeSnapshot onto the * document — `html { color-scheme }` for native UA chrome (scrollbars, form - * controls), `body[data-ds-dark-theme]` for the token palette, and the active - * theme's alias-token overrides as inline CSS variables on body. Pure DOM - * writes, no React involvement; the presenter only ever retracts what it wrote - * itself, so foreign attributes and inline styles survive apply/dispose. + * controls), `body[data-ds-dark-theme]` for the token palette, the active + * theme's alias-token overrides as inline CSS variables on body, and one + * presenter-owned `meta[name="theme-color"]` for surrounding browser UI. Pure + * DOM writes, no React involvement; the presenter only ever retracts what it + * wrote itself, so foreign attributes, metadata, and inline styles survive. */ import type { ThemeSnapshot } from '@deepseek-ai/dsh-client-ui-theme/client' @@ -15,12 +16,22 @@ export const DARK_ATTRIBUTE = 'data-ds-dark-theme' export class ThemePresenter { /** Token names this presenter wrote in the last apply (its retraction set). */ private appliedTokens: string[] = [] + /** The single metadata node this presenter inserts and removes. */ + private readonly themeColorMeta: HTMLMetaElement + + /** Create the presenter-owned metadata node before the first snapshot arrives. */ + constructor() { + this.themeColorMeta = document.createElement('meta') + this.themeColorMeta.name = 'theme-color' + } /** * Project a snapshot onto the document: set root `color-scheme` and the body * palette attribute from `active.colorScheme` (never the id — `system` is * resolved upstream), then replace the previously applied token variables - * with `active.tokens`. + * with `active.tokens`. Browser theme-color metadata follows the computed + * body background after those writes, so the rendered palette remains the + * color authority. * @param snapshot - resolved theme snapshot from ctx.theme. */ apply(snapshot: ThemeSnapshot): void { @@ -35,14 +46,17 @@ export class ThemePresenter { body.style.setProperty(name, value) this.appliedTokens.push(name) } + this.themeColorMeta.content = getComputedStyle(body).backgroundColor + if (!this.themeColorMeta.isConnected) document.head.append(this.themeColorMeta) } - /** Retract everything this presenter wrote: root color-scheme, the palette attribute, and all applied token variables. */ + /** Retract root color-scheme, the palette attribute, token variables, and the owned metadata node. */ dispose(): void { document.documentElement.style.removeProperty('color-scheme') const body = document.body body.removeAttribute(DARK_ATTRIBUTE) for (const name of this.appliedTokens) body.style.removeProperty(name) this.appliedTokens = [] + this.themeColorMeta.remove() } } diff --git a/packages/client/ui-layout/tests/apply.spec.ts b/packages/client/ui-layout/tests/apply.spec.ts index 903591163c..af85c1c5ae 100644 --- a/packages/client/ui-layout/tests/apply.spec.ts +++ b/packages/client/ui-layout/tests/apply.spec.ts @@ -7,7 +7,7 @@ // coverage gate still requires exercised. import { Context } from 'cordis' -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' import { apply as themeApply, inject as themeInject, ThemeService } from '@deepseek-ai/dsh-client-ui-theme/client' @@ -15,6 +15,10 @@ import { apply, inject, LayoutService } from '@deepseek-ai/dsh-client-ui-layout/ import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-layout' import * as invariant from '@deepseek-ai/dsh-client-ui-layout/invariant' +beforeEach(() => { + document.head.querySelectorAll('meta[name="theme-color"]').forEach((node) => { node.remove() }) +}) + async function bench() { const ctx = new Context() const slotsFiber = ctx.plugin(SlotsService) @@ -65,13 +69,17 @@ describe('ui-layout client apply', () => { // Initial getter application: jsdom has no matchMedia, system resolves light. expect(document.documentElement.style.colorScheme).toBe('light') expect(document.body.hasAttribute('data-ds-dark-theme')).toBe(false) + const themeColorMeta = document.head.querySelector('meta[name="theme-color"]') + expect(themeColorMeta).not.toBeNull() const theme = ctx.get('theme') as ThemeService theme.setTheme('dark') expect(document.documentElement.style.colorScheme).toBe('dark') expect(document.body.hasAttribute('data-ds-dark-theme')).toBe(true) + expect(document.head.querySelector('meta[name="theme-color"]')).toBe(themeColorMeta) await fiber.dispose() expect(document.documentElement.style.colorScheme).toBe('') expect(document.body.hasAttribute('data-ds-dark-theme')).toBe(false) + expect(themeColorMeta?.isConnected).toBe(false) // Listener is off: further theme changes no longer reach the document. theme.setTheme('light') theme.setTheme('dark') diff --git a/packages/client/ui-layout/tests/theme-presenter.spec.ts b/packages/client/ui-layout/tests/theme-presenter.spec.ts index a14d781e5f..36a4975fc9 100644 --- a/packages/client/ui-layout/tests/theme-presenter.spec.ts +++ b/packages/client/ui-layout/tests/theme-presenter.spec.ts @@ -1,40 +1,68 @@ // @vitest-environment jsdom // ThemePresenter behavior account: root color-scheme and the palette attribute // follow active.colorScheme only, token variables replace the previous apply's -// set, and dispose retracts everything the presenter wrote. +// set, theme-color metadata follows the rendered body background, and dispose +// retracts everything the presenter wrote. -import { beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' import type { ThemeSnapshot } from '@deepseek-ai/dsh-client-ui-theme/client' import { DARK_ATTRIBUTE, ThemePresenter } from '@deepseek-ai/dsh-client-ui-layout/src/client/theme-presenter.ts' +const LIGHT_THEME_COLOR = 'rgb(255, 255, 255)' +const DARK_THEME_COLOR = 'rgb(21, 21, 23)' + function snapshot(colorScheme: 'light' | 'dark', tokens: Record = {}): ThemeSnapshot { // The presenter must key off colorScheme, not the id — keep them distinct. const active = { id: `${colorScheme}-test`, colorScheme, tokens } return { preference: colorScheme, active, themes: [active], revision: 1 } } +function clearThemePresentation(): void { + document.head.querySelectorAll('meta[name="theme-color"], style[data-theme-presenter-test]').forEach((node) => { node.remove() }) +} + +function themeColorMeta(): HTMLMetaElement | null { + return document.head.querySelector('meta[name="theme-color"]') +} + beforeEach(() => { + clearThemePresentation() document.documentElement.style.removeProperty('color-scheme') document.body.removeAttribute(DARK_ATTRIBUTE) document.body.removeAttribute('style') + const style = document.createElement('style') + style.dataset.themePresenterTest = '' + style.textContent = ` + body { background-color: ${LIGHT_THEME_COLOR}; } + body[${DARK_ATTRIBUTE}] { background-color: ${DARK_THEME_COLOR}; } + ` + document.head.append(style) }) +afterEach(clearThemePresentation) + describe('ThemePresenter', () => { it('light scheme sets root color-scheme and leaves the dark attribute absent', () => { const presenter = new ThemePresenter() presenter.apply(snapshot('light')) expect(document.documentElement.style.colorScheme).toBe('light') expect(document.body.hasAttribute(DARK_ATTRIBUTE)).toBe(false) + expect(themeColorMeta()?.content).toBe(LIGHT_THEME_COLOR) }) - it('dark scheme sets root color-scheme and the attribute; switching to light clears both', () => { + it('dark scheme sets root color-scheme, the attribute, and metadata; switching to light updates one node', () => { const presenter = new ThemePresenter() presenter.apply(snapshot('dark')) + const meta = themeColorMeta() expect(document.documentElement.style.colorScheme).toBe('dark') expect(document.body.hasAttribute(DARK_ATTRIBUTE)).toBe(true) + expect(meta?.content).toBe(DARK_THEME_COLOR) presenter.apply(snapshot('light')) expect(document.documentElement.style.colorScheme).toBe('light') expect(document.body.hasAttribute(DARK_ATTRIBUTE)).toBe(false) + expect(themeColorMeta()).toBe(meta) + expect(meta?.content).toBe(LIGHT_THEME_COLOR) + expect(document.head.querySelectorAll('meta[name="theme-color"]')).toHaveLength(1) }) it('applies tokens as inline variables and clears the previous set on theme change', () => { @@ -52,10 +80,12 @@ describe('ThemePresenter', () => { document.body.style.setProperty('--foreign', 'kept') const presenter = new ThemePresenter() presenter.apply(snapshot('dark', { '--dsw-alias-bg': '#111' })) + const meta = themeColorMeta() presenter.dispose() expect(document.documentElement.style.colorScheme).toBe('') expect(document.body.hasAttribute(DARK_ATTRIBUTE)).toBe(false) expect(document.body.style.getPropertyValue('--dsw-alias-bg')).toBe('') expect(document.body.style.getPropertyValue('--foreign')).toBe('kept') + expect(meta?.isConnected).toBe(false) }) }) diff --git a/packages/host/frontend-static/README.i18n.yaml b/packages/host/frontend-static/README.i18n.yaml index 07d337775e..9d757aaf6c 100644 --- a/packages/host/frontend-static/README.i18n.yaml +++ b/packages/host/frontend-static/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/frontend-static/README.md -README.md: c3a831abb1060b59e1802d38d5407a29d24e3bb3 -README.zh.md: d4dc71763280a3c88c73de50f63f2615570c7182 +README.md: 82ba5a2cd0937e2c24505648aa1e3daec6bf2ece +README.zh.md: 1130aa7cc241ee5245fceaba0ef66fcf95871e67 diff --git a/packages/host/frontend-static/README.md b/packages/host/frontend-static/README.md index c3a831abb1..82ba5a2cd0 100644 --- a/packages/host/frontend-static/README.md +++ b/packages/host/frontend-static/README.md @@ -16,4 +16,4 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work -- **The starter MIME table is minimal** — extensions beyond the vite-emitted set fall back to `application/octet-stream`; extend the table when an asset class actually ships. +- **The starter MIME table is minimal** — it covers the Vite-emitted asset set plus the shipped PWA manifest; other extensions fall back to `application/octet-stream` until an asset class actually ships. diff --git a/packages/host/frontend-static/README.zh.md b/packages/host/frontend-static/README.zh.md index d4dc717632..1130aa7cc2 100644 --- a/packages/host/frontend-static/README.zh.md +++ b/packages/host/frontend-static/README.zh.md @@ -16,4 +16,4 @@ Web 壳的 SPA dist 服务器:一个函数插件(配置为 `{distIndex}`) ## 已知限制与延期工作 -- **初始 MIME 表很精简**:vite 输出集合以外的扩展名会回退到 `application/octet-stream`;实际发布新的资产类别时再扩展该表。 +- **初始 MIME 表很精简**:它覆盖 Vite 输出的资产集合及实际交付的 PWA manifest;其他扩展名在相应资产类别实际发布前都会回退到 `application/octet-stream`。 diff --git a/packages/host/frontend-static/src/index.ts b/packages/host/frontend-static/src/index.ts index 4d5032c2d2..8bd5b829c1 100644 --- a/packages/host/frontend-static/src/index.ts +++ b/packages/host/frontend-static/src/index.ts @@ -41,6 +41,7 @@ const MIME: Record = { '.svg': 'image/svg+xml', '.json': 'application/json', '.map': 'application/json', + '.webmanifest': 'application/manifest+json', } /** diff --git a/packages/host/frontend-static/tests/frontend-static.spec.ts b/packages/host/frontend-static/tests/frontend-static.spec.ts index e35e54bb05..4f5fa0d2c7 100644 --- a/packages/host/frontend-static/tests/frontend-static.spec.ts +++ b/packages/host/frontend-static/tests/frontend-static.spec.ts @@ -36,6 +36,7 @@ async function loadComposition(): Promise { await writeFile(distIndex, 'shell') await writeFile(join(dist, 'app.js'), 'export {}') await writeFile(join(dist, 'blob.bin'), 'BLOB') + await writeFile(join(dist, 'manifest.webmanifest'), '{}') const configPath = join(root, 'cordis.yml') await writeFile(configPath, [ "- name: '@deepseek-ai/dsh-host-webserver'", @@ -92,8 +93,13 @@ describe('real Loader composition', () => { const server = loaded.httpServer const port = server.port - // Real asset with its MIME type; a live rebuild is served on the next read. + // Real assets with their MIME types; a live rebuild is served on the next read. expect(await request(port, '/app.js')).toMatchObject({ status: 200, type: 'text/javascript; charset=utf-8', body: 'export {}' }) + expect(await request(port, '/manifest.webmanifest')).toMatchObject({ + status: 200, + type: 'application/manifest+json', + body: '{}', + }) await writeFile(join(root!, 'dist', 'app.js'), 'export const rebuilt = true') expect(await request(port, '/app.js')).toMatchObject({ status: 200, body: 'export const rebuilt = true' }) From 716361844c8616a24f38373d9d8d4e80e44752c1 Mon Sep 17 00:00:00 2001 From: Jiaying Ding Date: Fri, 7 Aug 2026 11:54:45 +0800 Subject: [PATCH 12/63] style(web): use single quotes in hero expectations --- apps/web/tests/details-session-lifecycle.e2e.ts | 2 +- apps/web/tests/hmr-live.e2e.ts | 2 +- apps/web/tests/lifecycle-chrome.e2e.ts | 2 +- apps/web/tests/startup-auto-selection.e2e.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/web/tests/details-session-lifecycle.e2e.ts b/apps/web/tests/details-session-lifecycle.e2e.ts index 5317c39009..3bd781a5ee 100644 --- a/apps/web/tests/details-session-lifecycle.e2e.ts +++ b/apps/web/tests/details-session-lifecycle.e2e.ts @@ -121,7 +121,7 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S expect(await page.getByText('Details', { exact: true }).isVisible()).toBe(false) await page.getByRole('button', { name: /^(?:New session|新.*会话)$/ }).last().click() - await page.getByText("Into the unknown", { exact: false }).waitFor({ timeout: 15_000 }) + await page.getByText('Into the unknown', { exact: false }).waitFor({ timeout: 15_000 }) await expect.poll(() => detailsTrack(page), { timeout: 5_000 }).toBe(0) expect(await page.getByText('Details', { exact: true }).isVisible()).toBe(false) diff --git a/apps/web/tests/hmr-live.e2e.ts b/apps/web/tests/hmr-live.e2e.ts index 385a516e7d..1e8e81909f 100644 --- a/apps/web/tests/hmr-live.e2e.ts +++ b/apps/web/tests/hmr-live.e2e.ts @@ -75,7 +75,7 @@ it('hot-reloads a real client-plugin source edit without refreshing the page', a if (!existsSync(binPath)) throw new Error('HMR browser test needs the built dsh bin; run pnpm run build first') const originalSource = await readFile(sourcePath) const originalBundle = await readFile(bundlePath) - const oldText = "Into the unknown" + const oldText = 'Into the unknown' const sourceNeedle = "'hero.headline': 'Into the unknown'" const newText = `HMR UPDATED ${'x'.repeat(80)}` const updatedSource = originalSource.toString().replace(sourceNeedle, `'hero.headline': '${newText}'`) diff --git a/apps/web/tests/lifecycle-chrome.e2e.ts b/apps/web/tests/lifecycle-chrome.e2e.ts index e9afa95872..90587e9e4c 100644 --- a/apps/web/tests/lifecycle-chrome.e2e.ts +++ b/apps/web/tests/lifecycle-chrome.e2e.ts @@ -159,7 +159,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () } // The blank frame renders the hero, not the resident composer: the // headline plus the guidance placeholder are the empty state's anchors. - await expect.poll(() => page.getByText("Into the unknown", { exact: false }).count(), { timeout: 15_000 }).toBe(1) + await expect.poll(() => page.getByText('Into the unknown', { exact: false }).count(), { timeout: 15_000 }).toBe(1) const input = page.locator('textarea').first() await input.waitFor({ timeout: 10_000 }) if (MODE !== 'record') { diff --git a/apps/web/tests/startup-auto-selection.e2e.ts b/apps/web/tests/startup-auto-selection.e2e.ts index 7ef43d2861..c93ed04a40 100644 --- a/apps/web/tests/startup-auto-selection.e2e.ts +++ b/apps/web/tests/startup-auto-selection.e2e.ts @@ -145,7 +145,7 @@ describe('web e2e: startup auto-selection', () => { // seat with `visibility:hidden`, which Playwright reports as not visible). await page.waitForSelector(ROOT_PHASE, { timeout: 15_000 }) expect(await page.locator(ROOT_PHASE).first().getAttribute('data-phase')).toBe('hero') - expect(await page.getByText("Into the unknown").isVisible()).toBe(true) + expect(await page.getByText('Into the unknown').isVisible()).toBe(true) expect(await page.locator('textarea').first().isVisible()).toBe(true) releaseHistory() From 2da1309836e36320cd2a6818e576feaeb01f5558 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 6 Aug 2026 23:46:43 +0800 Subject: [PATCH 13/63] fix: npm publish for profile --- scripts/publish-npm-baseline.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/scripts/publish-npm-baseline.ts b/scripts/publish-npm-baseline.ts index 92df711f3e..20b9eeaea0 100644 --- a/scripts/publish-npm-baseline.ts +++ b/scripts/publish-npm-baseline.ts @@ -463,13 +463,6 @@ class InstalledBundleSmoke { + `expected ${this.bundle.manifest.version}`, ) } - const config = this.runner.capture( - process.execPath, - [bin, '--dump-default-config'], - consumerRoot, - environment, - ) - if (config === '') throw new Error('installed dsh --dump-default-config returned no output') this.probeWeb(bin, consumerRoot, environment) console.log('publish-npm-baseline: installed dsh entry and Web startup probes passed') } finally { From effd8e1ebd5b2146759d80c81bfa8f27b1cfcb3a Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 5 Aug 2026 11:13:52 +0800 Subject: [PATCH 14/63] docs: add TypeRT remote gateway RFC --- ...08-02-typert-remote-method-calls.i18n.yaml | 6 + .../2026-08-02-typert-remote-method-calls.md | 489 ++++++++++++++++++ ...026-08-02-typert-remote-method-calls.zh.md | 489 ++++++++++++++++++ 3 files changed, 984 insertions(+) create mode 100644 .agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml create mode 100644 .agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.md create mode 100644 .agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.zh.md diff --git a/.agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml b/.agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml new file mode 100644 index 0000000000..cc2f0736d4 --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.md +2026-08-02-typert-remote-method-calls.md: c3a7a77c583720c3f967de185a089d374f017d81 +2026-08-02-typert-remote-method-calls.zh.md: 9b2fbbd69f1c054cbf6c86f177b743c583be3e8a diff --git a/.agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.md b/.agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.md new file mode 100644 index 0000000000..c3a7a77c58 --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.md @@ -0,0 +1,489 @@ +# Agent Note: TypeRT Gateway Targeted Method Calls + +Status: proposed + +English | [中文](2026-08-02-typert-remote-method-calls.zh.md) + +## Problem + +The Host API Proxy handles direct method calls, stateful interactions, and Session event streams. These concerns have different lifecycles, routing semantics, and client programming interfaces. Continuing to export all business operations through one package would couple business Services, transport protocols, state machines, and client types. + +This proposal addresses only targeted method calls in which one request produces one result. Stateful interactions such as Permission and Approval, as well as Session event streams, do not use this design and will be designed separately. + +The contract for a direct method call belongs to the business Service that implements it. Business developers should declare only which methods are remotely callable, without also maintaining a central API interface, routing table, parameter conversion table, client stub, and Zod schema. + +The Host and Browser Client use separate TypeScript Programs because each side augments the Cordis `Context` type differently. A Remote projection must not import the complete Host declarations into a consumer or depend on Browser-specific types. If the TUI later reuses this programming interface, it must likewise see only methods marked Remote. TUI integration is outside the current scope, but the implementation boundary must preserve this isomorphic reuse. + +## Proposal + +A business Service declares callable methods with `@Remote` or `@RemoteContext()` and explicitly joins the Gateway through `bindTypeRTGateway()`. TypeRT generates the Host-local reflection artifact and a platform-independent Remote consumer projection from the Host Program. The Client Program continues to generate its own local reflection artifact independently. + +The Remote consumer projection contains `.d.ts`, `.d.ts.map`, and `.js` files. The `.d.ts` exposes only methods marked with a Remote decorator and refers to the business package's single public type symbols. The `.d.ts.map` navigates consumer API methods back to their Host business method implementations. The `.js` carries endpoint, parameter, Context, and Zod information for the same contract. At the assembly layer, the Browser Client mounts the required Remote JS contributions onto the Client API Service. The projection and API abstraction remain platform-independent so that a future TUI can reuse them. + +`@deepseek-ai/dsh-host-api-gateway`, located at `packages/host/api-gateway`, provides two symmetric faces: its default entry provides Host `ctx.typertGateway`, while its `/client` entry provides consumer-side `ctx.api`. Each side consumes a locally generated `InvocationDescriptor` from the same model; descriptors are not sent over the wire. The Remote data protocol runs over the single Connection/RPC mechanism through an isolated `/api2` channel. The business calling interface does not change when Connection migrates from HTTP to WebSocket. + +## Components and Cordis services + +| Component | Cordis service | Responsibility in this proposal | +|---|---|---| +| `@deepseek-ai/dsh-type-meta` | Declares only the minimal `ctx.typert` protocol | Decorators, bindings, descriptors, lookup/Context, and the Remote map; no dependency on the compiler, Zod, Connection, or Browser | +| TypeRT registry | `ctx.typert` | Separately stores reflection for the current environment, imported Remote contributions, lookup providers, and Context providers | +| TypeRT generator/loader | No new business service | Generates three kinds of `lib` artifacts from the Host/Client Programs and registers the current environment's artifacts with `ctx.typert` | +| Host API Gateway's Host face | `ctx.typertGateway` | Associates Host definitions with live Services, decodes parameters, resolves receivers, invokes methods, and encodes results | +| Connection | `ctx.connection` | Exclusively owns the HTTP Server/future WebSocket, RPC envelope, rpcId, serialization, trust, and error transport, while carrying the isolated `/api` and `/api2` channels | +| Host API Gateway's Client face | `ctx.api` | Mounts Remote contributions, materializes root and scoped APIs, and delegates canonical calls to `ctx.connection.rpc` | +| Client Remotes | No new service | Serves as the only Remote facade for Client business code, selecting and mounting `/remote` contributions while exposing the Gateway Client face and the selected API declarations | +| Agent/Session owning packages | Existing domain services | Provide both static interface merges and runtime lookup/Context providers | +| Business packages such as Goal | Existing business Services | Declare only bindings, Remote methods, and canonical DTOs, and export the generated `/remote` subpath | + +The Host Gateway does not depend on concrete implementations of `ctx.agents`, `ctx.sessions`, `ctx.goals`, or `ctx.httpServer`. The Client API does not understand the physical carrier, and Connection does not understand Goal, Agent, lookup, `InvocationDescriptor`, or Client API namespaces. + +## Business declarations + +Ordinary direct calls use `@Remote`. When migrating to an existing Service or Registry, do not rename or alter existing methods. Add `remoteExport*` entry points at the end of the class and use decorator arguments to declare their short API names. A method explicitly declares every required business object in a top-level parameter position: + +```text +export class GoalService extends Service { + readonly typertGateway = bindTypeRTGateway(this, 'goals') + + create(agent: Agent, request: CreateGoalRequest): CreateGoalResult { + // Existing business method remains unchanged. + } + + @Remote('create') + remoteExportCreate(agent: Agent, request: CreateGoalRequest): CreateGoalResult { + return this.create(agent, request) + } +} +``` + +`goals` is an explicit Cordis service key and is the default wire namespace. Override it through an option to `bindTypeRTGateway()` only when the protocol namespace genuinely needs to differ from the service key. + +Use `@RemoteContext()` when the Service receiver must be resolved within an isolated kind of Context. Context identity does not enter the business method's parameters: + +```text +export class ScopedGoalService extends Service { + readonly typertGateway = bindTypeRTGateway(this, 'goals') + + @RemoteContext('agent', 'create') + remoteExportCreate(request: CreateGoalRequest): Promise { + // Runs against the goals service resolved from the Agent Context. + } +} +``` + +An endpoint selects exactly one invocation mode. A flow that needs an explicit `Agent` parameter uses `@Remote`. A flow that first switches to an Agent Context and then resolves a scoped receiver uses `@RemoteContext('agent')`. TypeRT does not infer either mode from the method body or from a missing parameter. + +Business packages depend only on the lightweight `@deepseek-ai/dsh-type-meta`. It provides declaration protocols for decorators, `bindTypeRTGateway()`, lookup, Remote Context, and descriptors, without depending on the TypeScript compiler, Zod, HTTP, or the Client runtime. + +## Decorators and the explicit Gateway facet + +A decorator only states that a method participates in the Remote contract. It performs no runtime type reflection and injects no hidden symbol into a Service constructor. The arguments to `@Remote('create')` and `@RemoteContext('agent', 'create')` are external method names, while the actual member remains named `remoteExportCreate`. The member name becomes the external method name only when no alias is provided. `typertGateway` is the sole explicit marker that a Service has joined the Gateway, making this capability visible on both the business class and its runtime instance. + +In SRC mode, the decorator may record the prototype, method name, and invocation mode in a `WeakMap` internal to `dsh-type-meta`. It writes no custom properties to a Service instance, prototype, constructor, or method function. + +In LIB mode, the TypeRT compiler performs strict method discovery, type resolution, and descriptor generation. Generation neither rewrites business source nor secretly supplies generated arguments to `bindTypeRTGateway()`. + +## Lookup and Remote Context registration + +The Gateway has no built-in branches for Agent, Session, or other business objects. Each object-owning package provides both a static declaration and a runtime provider: + +```text +declare module '@deepseek-ai/dsh-type-meta' { + interface TypeRTLookupMap { + agent: TypeRTLookup + } +} + +ctx.typert.lookups.register('agent', { + parameter: 'agent', + wire: 'agentId', + resolve: sessionId => resolveAgent(sessionId), +}) +``` + +The static declaration tells TypeRT that `Agent` corresponds to `SessionId` on the wire. The runtime provider resolves an `agentId` in a request to the currently live `Agent` object. If either side is missing, the LIB build or the earliest resolvable runtime registration fails immediately. + +Lookup objects such as Agent and Session may each occupy only one top-level parameter position. An ordinary JSON request may be passed as another complete parameter, but this proposal does not support `request.agent`, object destructuring, arrays of objects, nested lookups, or searching arbitrary complex structures for IDs. + +Remote Context uses a separate merge-extensible map and provider. The Agent package registers an `agent` Context provider that locates the Agent Context from its wire identity and resolves the Service key named by the descriptor from that Context. The Gateway does not know the internal structure of an Agent Context. + +The Client also registers an `agent` Context binder. The binder only retrieves a `SessionId` from the Context in which a call occurs; it neither enumerates Scopes nor copies methods into each one. A Cordis Service tracker automatically rebinds a scoped namespace to the current Agent Context. + +## InvocationDescriptor + +TypeRT, the permissive SRC parser, Host Gateway, and Client API exchange one canonical description: + +```text +InvocationDescriptor { + id: '@deepseek-ai/dsh-goal#goals/create' + service: 'goals' + namespace: 'goals' + method: 'create' + implementation: 'remoteExportCreate' + invocation: direct | { context: 'agent', wire: 'agentId' } + scope?: { context: 'agent', wire: 'agentId' } + parameters: [ + { name, wire, source: json | lookup, lookup?, codec } + ] + result: codec + sourceLocation +} +``` + +`method` is the external short name used by the endpoint and Client API; `implementation` is the actual member name on the Host receiver. `implementation` may be omitted when the two names match. A `direct` descriptor retains the original Service instance as the receiver. A Context descriptor first uses the corresponding Context provider to find the scoped Context, then resolves the receiver by the descriptor's service key. + +The strict generator writes `scope` only when a direct method has exactly one lookup parameter, a `TypeRTContextMap` declaration with the same name exists, and both use the same wire type symbol. `scope.wire` must identify that lookup parameter. It declares that a consumer may fill this parameter from the Context in which the call occurs, without changing the Host receiver or endpoint. No scoped projection is generated when there are multiple lookups, no Context declaration, or mismatched wire types; a type mismatch is a build error. + +Parameter order comes from the method signature. HTTP fields come from parameter names or lookup declarations. The Gateway does not infer optional fields, Context types, lookup types, or missing arguments from request contents, and it does not synthesize business defaults. + +A LIB codec contains a Zod schema and a canonical `typeSymbol` consisting of "package + public subpath + export name." An SRC codec is marked only as `src-json`. When the Host and consumer run in different JavaScript realms, each holds its own Zod instances, but both sets are generated from the same TypeRT model and symbol keys. + +Descriptors exist only in the local registry on each side. The wire carries only the `/api2` channel, endpoint, and `{ args }` payload. The Host uses its descriptor to decode and invoke the method, while the Client uses its corresponding descriptor to encode arguments and validate the result. + +## TypeRT runtime registry + +```text +ctx.typert.local 当前进程自己的 Host 或 Client reflection +ctx.typert.remotes 消费端显式 mount 的对端 Remote contribution +ctx.typert.lookups wire ID 到 Host 活对象的 provider +ctx.typert.contexts Host Context resolver 与 Client Context binder +``` + +Every registration returns a disposer owned by the caller's Cordis fiber. The Gateway and API Service read the current snapshot before subscribing to changes, so business Services, generated contributions, providers, and consumers can load in any order. When any dependency is disposed, its related endpoints or methods become unavailable immediately. + +The registry's Host root entry has the complete `TypeRTService` interface merge. The registry implementation shared by Host and Client lives in a separate module without environment declarations. The registry's `/client` entry imports only that shared implementation and does not pass through the Host root entry, so it cannot bring Host Cordis declarations into the Client Program. + +## Canonical types, symbols, and Zod + +Remote Client DTS does not copy business DTOs or redeclare structurally identical shadow types. It imports original symbols only from public, type-only subpaths that do not carry Host Cordis merges: + +```text +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { CreateGoalRequest, CreateGoalResult } from '@deepseek-ai/dsh-goal/types' +``` + +Consequently, `SessionId`, the Agent wire ID, the request, and the result all refer to the same TypeScript declaration in the Host and Browser Client. A future TUI can reuse them without a second set of types. Go to Definition, renames, and Find References for a DTO return to the one source location for the business type instead of stopping at a copy in a generated file. + +Remote API methods themselves use declaration-map navigation. TypeRT anchors `InvocationModel.location` to the method-name token of the Host `remoteExport*` method and emits a source-map segment on the corresponding property of the namespace interface. After the TypeScript editor resolves `ctx.api.models.list` to its generated declaration, `typert.remote-client.d.ts.map` takes it to the Host Service's `remoteExportList` entry point. That entry point explicitly calls the existing, unrenamed `list()` method; the map does not misidentify the decorator, class, or full signature as the method definition. + +TypeRT generates a wire Zod codec for the same symbol key. The Host Gateway uses it to validate input and encode results, while the Client API may use it to encode arguments and validate responses. If a complex type cannot produce a strict codec, the LIB build fails instead of degrading to `unknown` or unchecked JSON. + +Named business types referenced by Remote methods must be exported from public, type-only subpaths. If the only reachable entry also imports Host Services, Cordis `Context` merges, or Host-only implementations, the build fails and requires the business package to provide a safe type entry. Primitives, literals, and simple compositions explicitly supported by TypeRT need no additional names. + +A lookup parameter does not expose the `Agent` class to consumers. The Remote projection refers to the canonical ID type in the lookup declaration, such as `SessionId`, while the Host continues to resolve objects through the canonical `Agent` class symbol. + +## Three artifact kinds and two TypeScript Programs + +The Host and Client still use only two independent TypeScript Programs, but TypeRT generates three semantically distinct kinds of artifacts: + +```text +Host Program +├─ typert.host.js / typert.host.d.ts +│ Host 自身的 Service、Event、Object、schema 和 inbound Gateway 信息 +└─ typert.remote-client.js / typert.remote-client.d.ts / typert.remote-client.d.ts.map + Host Remote 对任意消费环境的 wire 投影 + +Client Program +└─ typert.client.js / typert.client.d.ts + Client 自身的 Service、Event、Object 和 schema 信息 +``` + +`remote-client` is the Host Program's second emitter, not a third Program or the Client's local face. It contains no Host Cordis merge, Service class, Context class, or implementation code, and it does not enter the Host-local reflection registry. + +The Host lib build performs strict Host analysis and emits both the Host-local and Remote consumer artifacts. The Client lib then consumes the Remote DTS. The complete order is: + +```text +Host lib build +→ 生成 typert.host.{js,d.ts} +→ 生成各业务包 lib/typert.remote-client.{js,d.ts,d.ts.map} +→ 完成 Client lib 和 typert.client 产物 +→ Vite 构建 Web +``` + +The existing top-level `build` still runs `build:lib` before `build:web`, but `build:lib` must complete the Host and Remote artifacts before starting Client TypeScript compilation. A clean build must not depend on stale `.d.ts` files from an earlier build. + +## The `/remote` package entry + +Every business package that provides Remote methods exports a generated `/remote` subpath: + +```text +"./remote": { + "types": "./lib/typert.remote-client.d.ts", + "default": "./lib/typert.remote-client.js" +} +``` + +Consumer code selects a capability through the business package itself: + +```text +import goalsRemote from '@deepseek-ai/dsh-goal/remote' +``` + +This import brings the `.d.ts` map augmentation into the current TypeScript project while supplying the JS descriptor for the same contract as a value to the runtime. A business package that is not imported does not extend the current project's Remote API types. + +The business package's published files must include both `lib/typert.remote-client.d.ts.map` and the `src` file referenced by that map. The generated DTS refers to its adjacent map with `//# sourceMappingURL=typert.remote-client.d.ts.map`; the map source points from `lib` to the business source by a relative path such as `../src/index.ts`. The `/remote` export does not list the map separately; the package `files` field publishes it together with the source. + +Code that needs only static types may use `import type {} from '@deepseek-ai/dsh-goal/remote'`. This import is erased at runtime, loads no JS, and cannot trigger runtime registration. An environment that makes real calls must pass the contribution from a normal value import to the API Service. + +Workspace resolution for `/remote` must explicitly target generated `lib` artifacts and must not let a general package-to-`src` paths rule redirect it to Host source. Ordinary business imports may continue resolving to SRC or LIB according to each environment's existing rules. + +## Strict consumer API types + +Remote DTS extends the flat endpoint map, direct namespace interface, namespace map, and scoped map without augmenting the global Cordis `Context`: + +```text +interface TypeRTRemoteNamespace$676f616c73 { + create: ( + agentId: SessionId, + request: CreateGoalRequest, + ) => Promise +} + +interface TypeRTRemoteMap { + 'goals/create': ( + agentId: SessionId, + request: CreateGoalRequest, + ) => Promise +} + +interface TypeRTRemoteNamespaceMap { + goals: TypeRTRemoteNamespace$676f616c73 +} + +interface TypeRTRemoteContextMap { + 'agent:goals/create': ( + request: CreateGoalRequest, + ) => Promise +} +``` + +`TypeRTRemoteMap` preserves canonical endpoint signatures for protocol typing and reflection. The root API type reads `TypeRTRemoteNamespaceMap` directly instead of deriving methods indirectly through a key-remapped mapped type; the TypeScript Language Service cannot reliably navigate such indirect properties through a declaration map. A namespace interface name encodes the namespace's UTF-8 bytes as hexadecimal, so `goals` deterministically becomes `TypeRTRemoteNamespace$676f616c73`. Different packages generate the same interface name for the same namespace and use module augmentation to merge their methods, while `TypeRTRemoteNamespaceMap.goals` always refers to that one type. + +TypeRT projects `TypeRTRemoteContextMap` onto a dedicated Scope type according to its Context key. The final programming interface remains: + +```text +api.goals.create(agentId, request) +agent.goals.create(request) +``` + +The Agent Scope supplies its own `SessionId` automatically. A `@Remote` method with an `agent` lookup can therefore generate both root and scoped consumer signatures. A `@RemoteContext('agent')` method also omits a separate Context identity, but generates only the scoped signature. In this phase, only the Client Agent Context gains `goals`; the Root Context does not. A future TUI must preserve the same Scope restriction. + +`RemoteApi` remains platform-independent, and the Browser Client uses it as its `ClientApi`. If a future TUI reuses this type, it must likewise access it through a dedicated API object and Agent Scope rather than treating the Host `Context` as a broader Service collection. Public Service methods without Remote markers do not enter the Remote maps. + +## Client TypeRT and the API Gateway Client face + +TypeRT in a consumer environment maintains both local information and Remote information imported from other environments, but stores them in separate registries: + +```text +TypeRT.local 当前环境自己的反射模型 +TypeRT.remotes 已导入的 Remote contribution +``` + +`@deepseek-ai/dsh-client-remotes/client` centrally loads the required Remote contributions: + +```text +import goalsRemote from '@deepseek-ai/dsh-goal/remote' +import sessionsRemote from '@deepseek-ai/dsh-session/remote' + +ctx.api.mount(goalsRemote) +ctx.api.mount(sessionsRemote) +``` + +Client business packages depend only on `@deepseek-ai/dsh-client-remotes/client`, not directly on the Host API Gateway or the runtime entry of each business `/remote`. Client Remotes itself depends on the Gateway Client face and re-exports declarations so the selected Remote map reaches business compilation. Adding or removing a complete Client capability changes only this assembly point. + +`ctx.api.mount()` registers a contribution with `TypeRT.remotes`, and its disposer is owned by the Cordis fiber that called the method. Duplicate endpoints, conflicting invocation modes for the same namespace and method, or conflicts between a descriptor and an existing type identity fail immediately. + +The API Service materializes each `@Remote` descriptor as a real function on the root `api`. The function constructs named `args` in descriptor parameter order, applies the Client's strict codec, and then calls `ctx.connection.rpc.call('/api2', endpoint, { args })`. + +Neither a direct descriptor with `scope` nor a `@RemoteContext` descriptor copies functions into every Agent Scope. The API Service creates one root singleton Cordis Service for each scoped namespace and materializes methods on that Service. When `agent.goals.create()` is called, the Cordis tracker rebinds the Service's `this.ctx` to the current Agent Context. The method then asks the corresponding Context binder for identity from `this.ctx`. A direct scoped projection substitutes this identity at the lookup position named by `scope.wire`; a Context descriptor writes the identity into the receiver's separate wire field. Both issue the same kind of `/api2` call. + +```text +root ctx.api.goals.create(agentId, request) + → direct descriptor + → ctx.connection.rpc.call('/api2', 'goals/create', { args }) + +agent.goals.create(request) + → tracker 将 namespace Service rebind 到 agent Context + → agent binder 从 caller Context 取得 agentId + → 用 agentId 补入同一 direct descriptor 的 lookup 参数 + → ctx.connection.rpc.call('/api2', 'goals/create', { args }) +``` + +The Root `Context` does not merge the scoped `goals` type; only `AgentContext` gains that property through `RemoteContextApi<'agent'>`. If a caller bypasses the type system and dynamically calls a scoped method from Root, the binder reports an explicit error. If the Client already has a Cordis service with the same name, or two contributions claim the same namespace and method incompatibly, mounting fails instead of overwriting the existing service. + +Generated Remote JS contains only descriptors, symbol keys, and codecs; it does not bundle Host Service implementations. The API Service can create real functions from that data, so this proposal does not depend on a JavaScript Proxy. A Proxy remains an implementation option but is not a source of types or reflection. + +## Cross-environment isomorphism constraints + +Remote API is a consumer capability, not a synonym for Browser API. This phase implements only Browser Client contribution mounting, Connection RPC calls, and Agent Scope association. + +Remote DTS, Remote JS, `RemoteApi`, `InvocationDescriptor`, the Remote RPC data protocol, and Context binders must not depend on the DOM, Browser module loaders, or HTTP. Through Connection, the Browser Client encodes descriptor-materialized methods as `/api2` RPC calls. + +A future TUI can join the same call abstraction without changing business decorators, Remote maps, or the shape of API calls. The TUI-visible API must still be generated exclusively from `@Remote` and `@RemoteContext`; sharing a process with the Host must not allow it to bypass Remote restrictions and expose Service methods directly. + +TUI runtime mounting, carriers, Agent Scope association, and SRC startup wiring are outside this phase. + +The Web already depends on build artifacts such as `lib/client.js`, so it requires a complete `build:lib` before startup. After the Host Remote contract changes, developers must rebuild the lib and then start or restart the Web. The first phase does not implement incremental watching of the Remote contract. + +## SRC and LIB operating modes + +SRC supports local source startup. The `WeakMap` records created by `@Remote` and `@RemoteContext()` provide method names and invocation modes. At runtime, the system reads ordered parameter names from the JavaScript function signature and combines them with registered lookup/Context providers to produce a permissive descriptor. + +For example, `@Remote('create') remoteExportCreate(agent, request)` resolves to the external method `create`, implementation member `remoteExportCreate`, and two top-level parameters. Lookup registration rewrites `agent` to the wire field `agentId`, while `request` is passed as a same-named JSON parameter. SRC does not start a `ts.Program`, use a preload or loader hook, generate or rewrite source, or inspect the internal structure of an ordinary JSON object. + +A signature that SRC cannot resolve unambiguously fails when the Service mounts. It does not guess at object destructuring, ambiguity caused by default parameters, rest parameters, nested lookups, or complex types. + +LIB supports CI, releases, and the prerequisite Web build. TypeRT scans the complete Host project and checks Remote decorators, explicit bindings, service keys, endpoint conflicts, lookup/Context declarations, public-symbol reachability, JSON codecs, and result codecs, then generates strict descriptors. + +At runtime, LIB only loads definitions from `lib`; it does not start the TypeScript compiler. The subsequent association of Services, lookup, Context resolution, invocation, and response encoding in the Host Gateway does not depend on whether a descriptor came from permissive SRC parsing or strict LIB generation. + +CI and releases use LIB. Moving all repository coverage to LIB is separate follow-up work and does not block this direct-method-call implementation. + +## Host Gateway registration + +The Host Gateway observes both TypeRT Remote definitions and the Cordis Service lifecycle. When a Service carrying the `typertGateway` facet and a definition with the same service key are both available, the Gateway registers the definition's endpoints. Their arrival order does not matter. + +At startup, the Gateway reads the current snapshots of TypeRT definitions and the Cordis reflection store before subscribing to registry changes and `internal/service`. It reconciles definitions, live Services, and bindings by service key, and unregisters endpoints when a Service is replaced or disposed. If a definition, lookup provider, or Context provider is removed, dependent endpoints immediately become unavailable; the Gateway neither retains invalid objects nor degrades to invoking methods with raw IDs. + +An ordinary `@Remote` call retains the original Service instance as receiver. After lookups succeed, the Gateway calls the member identified by `implementation ?? method` with parameters in descriptor order. + +A `@RemoteContext('agent')` call first asks the Agent Context provider to resolve the wire identity, then reads the descriptor's service key from that Context and invokes the scoped receiver. The business method receives neither a hidden Context parameter nor an Agent ID. + +```text +ctx.typertGateway.invoke({ namespace, method, args }) +→ 查找本地 InvocationDescriptor 与 live receiver +→ 按参数 descriptor 读取具名 wire 字段 +→ codec 解码普通值或 lookup ID +→ lookup provider 把 ID 解析为活对象 +→ direct 使用原 Service;context 先解析 scoped Context 和 Service +→ Reflect.apply(receiver[implementation ?? method], receiver, orderedArgs) +→ result codec 编码业务结果 +``` + +`ctx.typertGateway.invoke()` is the carrier-independent Host entry point. It neither creates an rpcId, RPC envelope, nor HTTP response. It returns only the encoded result or raises a Gateway error that the Connection RPC adapter maps for transport. + +## The `/api2` call chain + +`/api2` is an isolated protocol channel on the single Connection/RPC mechanism, not a transport created by the Gateway. The Gateway registers one local handler with Connection. This phase adds the following general channel capability to the existing HTTP Connection: + +```text +ctx.connection.rpc.handle('/api2', (endpoint, payload) => { + const { namespace, method } = parseEndpoint(endpoint) + const { args } = parsePayload(payload) + return ctx.typertGateway.invoke({ namespace, method, args }) +}) +``` + +The Connection Host half obtains a handle from the single HTTP Server and reuses the same RPC bridge, request/response envelope, rpcId, serialization, trust, transport errors, and `RpcError`. Its current physical mapping is: + +```text +POST /api2// +``` + +The Remote payload is a named JSON object, not a positional array, and does not carry an `InvocationDescriptor`. A normal Goal call has this payload slot: + +```json +{ + "args": { + "agentId": "session-1", + "request": { + "objective": "finish the migration" + } + } +} +``` + +The complete path is: + +```text +ctx.api.goals.create(sessionId, request) +→ Client InvocationDescriptor 编码 { args: { agentId, request } } +→ ctx.connection.rpc.call('/api2', 'goals/create', { args }) +→ Connection 创建 rpcId 和既有 client-request envelope +→ 当前 carrier 发送 POST /api2/goals/create +→ Connection Host half 执行 trust、反序列化和 RPC 分发 +→ /api2 handler 调用 ctx.typertGateway.invoke(...) +→ Host InvocationDescriptor 解码、lookup、receiver 解析和 Reflect.apply +→ result codec 编码 +→ Connection 写入既有 RPC result 并回送相同 rpcId +→ Client result codec 验证并返回 CreateGoalResult +``` + +Remote does not define a second-layer `{ ok, value/error }` response. Successful values and Gateway errors use the existing RPC response's `result` directly. The Gateway adapter maps endpoint, schema, lookup, Context, Service, and business-invocation failures to `RpcError`; Connection transports that error. + +The Gateway does not handle per-method permissions, caller identity, cancellation, idempotency, or long-lived connection state. This work only extends Connection with general channel registration and invocation capabilities. It does not change existing `/api`, trusted connection, trusted-host, or privileged-method semantics. Connection's WebSocket migration remains separate follow-up work. + +## Connection and protocol boundaries + +The API Service owns Remote contributions, method materialization, Scope binding, and the correspondence between positional parameters and descriptors. The Gateway owns Host descriptors, lookup, Context, and business invocation. Connection only sends `/api2`, the endpoint, and `{ args }` as one RPC call to the target and returns the existing RPC result; it does not understand Goal, Agent, lookup, descriptors, or Client API types. + +`/api` and `/api2` share one Connection, Server, RPC envelope, and connection lifecycle while remaining separate protocols. When Connection migrates from HTTP to WebSocket, `/api2` naturally changes from a physical path to a logical channel. The Remote payload, business decorators, generated DTS, Remote API types, and Agent Scope programming interface remain unchanged. + +## Package boundaries + +- `@deepseek-ai/dsh-type-meta`: lightweight protocols for decorators, bindings, lookup, Remote Context, and descriptors. +- TypeRT generator: analyzes Host/Client Programs, generates local faces and Remote consumer projections, and emits canonical symbol/Zod information. +- TypeRT runtime: separately stores the current environment's local reflection and imported Remote contributions. +- `@deepseek-ai/dsh-host-api-gateway`: its default entry associates Host definitions with Services, performs lookup, resolves Context receivers, invokes methods, encodes results, and registers an `/api2` handler with Connection; its `/client` entry mounts Remote contributions, creates strict API methods, and delegates calls to `ctx.connection.rpc`. The entries share the Remote protocol but do not import each other's Cordis interface merges. +- `@deepseek-ai/dsh-client-remotes`: the only Remote facade depended on by Client business code; directly depends on the Gateway Client face, selects `/remote` contributions, and exposes the merged API types to business packages. +- Connection: owns the single HTTP Server/future WebSocket carrier, RPC envelope, rpcId, serialization, trust, and error transport while carrying the isolated `/api` and `/api2` channels. +- Business-object packages such as Agent/Session: own lookup, Context providers, canonical ID types, and public type-only entries. +- Business Service packages: declare bindings, Remote methods, and their request/result types, and export the generated `/remote` subpath. + +## Initial implementation scope + +The first vertical path implements `@deepseek-ai/dsh-goal/remote → Browser Client API → Connection RPC /api2 → Host Gateway → GoalService.remoteExportCreate()` and proves that the same direct descriptor with an Agent lookup supports both `ctx.api.goals.create(agentId, request)` and `agentCtx.goals.create(request)`. The scoped-receiver semantics of `@RemoteContext('agent')` remain a separate mode. + +This phase implements Connection's general second-channel API and its current HTTP carrier mapping, but not WebSocket migration, the TUI runtime, a TUI carrier, or TUI Agent Scope wiring. This RFC also does not design Permission/Approval state machines, Session event streams, call authorization, cancellation, retries, idempotency, or cross-version protocol compatibility. + +## Alternatives considered + +**Continue using the central API Proxy package.** This would require business methods, Host routes, and Client interfaces to be declared repeatedly in several locations. It would also keep direct calls, stateful interactions, and event streams tied to the same lifecycle, so this alternative is rejected. + +**Perform strict reflection through decorators at runtime.** JavaScript decorators cannot recover erased TypeScript types, public symbol identity, or complete Zod codecs. Injecting a compiler-private symbol into a constructor would also hide the business class's real dependencies, so TypeRT generates strict information at compile time. + +**Use a preload, loader hook, or complete `ts.Program` during SRC startup.** This could reuse LIB analysis but would add requirements to every source startup entry. SRC needs only a usable permissive descriptor, so it uses decorator markers, function parameter names, and explicit providers; strict checks remain in the LIB contract pass. + +**Hand-write the Client interface.** A hand-written interface cannot guarantee that it contains only Remote-marked methods and can drift from Host signatures, lookup IDs, and Zod schemas. Client types are therefore projected automatically from the Host Program. + +**Use a TypeScript language-service/compiler plugin to make the Client understand decorators directly.** This would require editors, Vite, tsc, tsx, and published consumers to install an additional plugin, making integration too invasive. The design instead generates ordinary `.d.ts` files and standard declaration maps. + +**Import complete Host DTS into the Client or TUI.** This would pull in Host Services and Cordis interface merges while exposing unmarked methods to consumers. Remote DTS refers only to public, type-only symbols and augments dedicated Remote maps. + +**Generate only Remote DTS, without JS.** Types would work, but the runtime could not enumerate endpoints, codecs, and Context modes without a Proxy or another hand-written registry. The same Host projection therefore emits a Remote JS contribution as well. + +**Let a top-level `/remote` import register global state implicitly.** The target Cordis Context may not exist when ESM evaluation occurs, and ownership becomes ambiguous across multiple Contexts, HMR, and disposal. A normal value import therefore returns only a contribution, which the environment assembly explicitly mounts through the API Service. + +**Create a separate transport, HTTP route, and response envelope for Remote.** This would duplicate the existing Connection's Server ownership, rpcId, serialization, trust, errors, and future WebSocket lifecycle, requiring two RPC stacks to migrate separately. `/api2` instead reuses the single Connection/RPC mechanism as an isolated protocol channel. + +## Acceptance criteria + +- Goal Service retains its existing business method and adds a remote entry point at the end of the class through an explicit `typertGateway` and `@Remote('create') remoteExportCreate(...)`, without maintaining a second route, codec, or Client method list. +- One clean `build:lib` generates the Host Remote contract before compiling Host and Client consumers and produces JS, DTS, and a DTS map under the business package's `lib`, importable through `/remote`. +- After importing `@deepseek-ai/dsh-goal/remote`, a consumer project gets a strict `api.goals.create(...)` type; without the import, that namespace does not enter its types. Go to Definition on `create` follows the declaration map to the Host Service's `remoteExportCreate` implementation. +- After the Client assembly mounts the JS contribution obtained from the same import, TypeRT can reflect endpoint, parameter, result, lookup, Context, and Zod information, and the API Service creates the calling method without a hand-written stub. +- Remote DTS, Remote JS, `RemoteApi`, and the descriptor protocol do not depend on Browser-specific capabilities, and the type model cannot expose unmarked Goal Service methods, preserving the boundary required for future isomorphic TUI integration. +- `agent.goals.*` obtains its call Scope through the Cordis tracker and Context binder. The Root Context has no Agent-only type, and functions are not copied into each Scope. +- `/api2/goals/create` resolves `agentId` to the canonical Agent object, invokes the original Goal Service receiver, and returns the result through the existing RPC result/error mechanism. +- `/api2` and `/api` share the single Connection/RPC carrier while remaining protocol-isolated. Remote neither registers an HTTP Server handle directly nor defines a second response envelope. +- Connection provides general channel registration and invocation capabilities and maps `/api2` to the current HTTP carrier. Existing `/api` behavior and trust semantics remain unchanged. +- This implementation does not change existing `/api`, Connection/trusted connection, Permission/Approval, or Session event stream behavior. + +## Risks + +Remote API types depend on generated `lib` declarations. Build orchestration must finish the Host contract pass before compiling Host and Client consumers; an incorrect order makes a clean build depend on stale artifacts. + +Source navigation requires a Remote package to publish both its declaration map and the `src` file referenced by the map. If package `files` omits either side, types still compile but consumer navigation stops at the generated DTS. The workspace manifest check must therefore treat both as one publication contract. + +The permissive SRC descriptor does not validate the internal structure of ordinary JSON. After a Host Remote signature changes, the Web and strict type consumers must rebuild the lib; the first phase has no incremental contract watcher. + +Canonical public types require business DTOs to have type-only entries, which may expose packages whose Host types and implementation entries are currently mixed. The build rejects those boundaries instead of copying types to conceal them. + +Type imports and runtime contributions have different effects. `import type {}` extends only the static API. If a real calling environment omits the value contribution, the API Service must fail with an explicit "Remote not mounted" error. + +Browser and Host each hold their own Zod instances and cannot compare object identities across realms. Consistency is guaranteed only by canonical symbol keys, the same generated model, and wire behavior. + +A consumer may import a Remote contract that is not currently mounted on the Host. The types mean "this protocol capability was selected by the consumer," not that a corresponding Service currently exists in the target process; an unavailable endpoint must fail explicitly at runtime. + +Connection's general channel API must suit both the current HTTP carrier and a future WebSocket carrier. If the API exposes `fetch`, an HTTP request, or a route handle to the Gateway/API Service, WebSocket migration will pierce the Remote layer again. Those physical objects must therefore remain internal to Connection. diff --git a/.agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.zh.md b/.agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.zh.md new file mode 100644 index 0000000000..9b2fbbd69f --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.zh.md @@ -0,0 +1,489 @@ +# Agent Note: TypeRT Gateway 定向方法调用 + +Status: proposed + +[English](2026-08-02-typert-remote-method-calls.md) | 中文 + +## Problem + +Host API Proxy 同时承担直接方法调用、带状态交互和 Session 事件流。三者的生命周期、路由语义和客户端编程界面不同,继续共用一个业务导出包会让业务 Service、传输协议、状态机和客户端类型彼此耦合。 + +本方案只解决一次请求对应一次结果的定向方法调用。Permission、Approval 等带状态交互以及 Session 事件流不使用本方案,后续分别设计。 + +直接方法调用的契约属于实现该行为的业务 Service。业务开发者应只声明哪些方法可以远程调用,而不应再同步维护中央 API 接口、路由表、参数转换表、客户端 stub 和 Zod schema。 + +Host 与 Browser Client 使用独立的 TypeScript Program,因为两边会以不同类型合并同名 Cordis `Context`。Remote 投影不能把完整 Host 声明导入消费端,也不能依赖 Browser 专属类型;未来 TUI 若复用这套编程界面,也只能看到 Remote 标记的方法。本期不实现 TUI 接入,但实现边界不得阻断这种同构复用。 + +## Proposal + +业务 Service 通过 `@Remote` 或 `@RemoteContext()` 声明可调用方法,并通过 `bindTypeRTGateway()` 显式加入 Gateway。TypeRT 从 Host Program 生成 Host 本地反射产物和平台无关的 Remote 消费端投影;Client Program 继续独立生成自己的本地反射产物。 + +Remote 消费端投影同时包含 `.d.ts`、`.d.ts.map` 和 `.js`。`.d.ts` 只暴露被 Remote decorator 标记的方法,并引用业务包唯一的公共类型符号;`.d.ts.map` 把消费端 API 方法导航回 Host 业务方法实现;`.js` 携带同一契约的 endpoint、参数、Context 和 Zod 信息。Browser Client 在 assembly 层把需要的 Remote JS 贡献集中挂到 Client API Service;该投影和 API 抽象保持平台无关,以便未来 TUI 复用。 + +`@deepseek-ai/dsh-host-api-gateway` 在 `packages/host/api-gateway` 内提供对称的两个 face:默认入口提供 Host `ctx.typertGateway`,`/client` 入口提供消费端 `ctx.api`。两边各自在本地消费由同一模型生成的 `InvocationDescriptor`,descriptor 不通过 wire 发送。Remote 数据协议运行在唯一 Connection/RPC 机制之上,使用独立 `/api2` channel;业务调用界面不随 Connection 从 HTTP 迁移到 WebSocket 而改变。 + +## 组件和 Cordis 服务 + +| 组件 | Cordis 服务 | 本方案中的职责 | +|---|---|---| +| `@deepseek-ai/dsh-type-meta` | 只声明 `ctx.typert` 的最小协议 | decorator、binding、descriptor、lookup/Context 和 Remote map;不依赖 compiler、Zod、Connection 或 Browser | +| TypeRT registry | `ctx.typert` | 分开保存当前环境 reflection、导入的 Remote contribution、lookup provider 和 Context provider | +| TypeRT generator/loader | 无新增业务服务 | 从 Host/Client Program 生成三类 `lib` 产物,并把当前环境产物注册到 `ctx.typert` | +| Host API Gateway 的 Host face | `ctx.typertGateway` | 关联 Host definition 与活 Service,解码参数、解析 receiver、调用方法和编码结果 | +| Connection | `ctx.connection` | 独占 HTTP Server/未来 WebSocket、RPC envelope、rpcId、序列化、trust 和错误传输,并承载 `/api` 与 `/api2` 两个隔离 channel | +| Host API Gateway 的 Client face | `ctx.api` | mount Remote contribution,实体化根 API 和 scoped API,把规范调用交给 `ctx.connection.rpc` | +| Client Remotes | 无新增服务 | 作为 Client 业务的唯一 Remote facade,选择并挂载 `/remote` contribution,同时传递 Gateway Client face 和所选 API 的类型声明 | +| Agent/Session owning 包 | 既有领域服务 | 同时提供静态 interface merge 与运行时 lookup/Context provider | +| Goal 等业务包 | 既有业务 Service | 只声明 binding、Remote 方法和唯一 DTO,并导出生成的 `/remote` 子路径 | + +Host Gateway 不依赖 `ctx.agents`、`ctx.sessions`、`ctx.goals` 或 `ctx.httpServer` 的具体实现。Client API 不理解物理 carrier,Connection 也不理解 Goal、Agent、lookup、`InvocationDescriptor` 或 Client API namespace。 + +## 业务声明 + +普通直接调用使用 `@Remote`。迁移到现存 Service 或 Registry 时不重命名、不改变存量方法;类末尾新增 `remoteExport*` 出口,并由 decorator 参数声明短 API 名。方法需要哪个业务对象,就在顶层参数位置显式声明该对象: + +```text +export class GoalService extends Service { + readonly typertGateway = bindTypeRTGateway(this, 'goals') + + create(agent: Agent, request: CreateGoalRequest): CreateGoalResult { + // Existing business method remains unchanged. + } + + @Remote('create') + remoteExportCreate(agent: Agent, request: CreateGoalRequest): CreateGoalResult { + return this.create(agent, request) + } +} +``` + +`goals` 是明确的 Cordis service key,并默认作为 wire namespace。只有协议 namespace 确实需要与 service key 不同时,才通过 `bindTypeRTGateway()` 的选项覆盖。 + +需要在某类隔离 Context 中查找 Service receiver 时使用 `@RemoteContext()`。Context identity 不进入业务方法参数: + +```text +export class ScopedGoalService extends Service { + readonly typertGateway = bindTypeRTGateway(this, 'goals') + + @RemoteContext('agent', 'create') + remoteExportCreate(request: CreateGoalRequest): Promise { + // Runs against the goals service resolved from the Agent Context. + } +} +``` + +同一个 endpoint 只能选择一种调用模式。需要显式 `Agent` 参数的流程使用 `@Remote`;需要切换到 Agent Context 再解析 scoped receiver 的流程使用 `@RemoteContext('agent')`,两者不会由 TypeRT 根据方法体或参数缺失自动猜测。 + +业务包只依赖轻量的 `@deepseek-ai/dsh-type-meta`。它提供 decorator、`bindTypeRTGateway()`、lookup、Remote Context 和 descriptor 的声明协议,不依赖 TypeScript compiler、Zod、HTTP 或 Client runtime。 + +## Decorator 与显式 Gateway facet + +Decorator 只表达“该方法参与 Remote 契约”,不负责运行时类型反射,也不向 Service constructor 注入隐藏 symbol。`@Remote('create')` 和 `@RemoteContext('agent', 'create')` 的参数是外部方法名,实际成员名保持 `remoteExportCreate`;未给别名时才使用成员名作为外部方法名。`typertGateway` 是 Service 加入 Gateway 的唯一显式标志,使业务类和运行时实例都能直接看出这项能力。 + +SRC 运行时允许 decorator 在 `dsh-type-meta` 内部的 `WeakMap` 记录 prototype、方法名和调用模式。它不向 Service 实例、prototype、constructor 或方法函数写入自定义属性。 + +LIB 的严格方法发现、类型解析和 descriptor 生成由 TypeRT compiler 完成。生成过程不改写业务源码,也不向 `bindTypeRTGateway()` 偷注生成参数。 + +## Lookup 与 Remote Context 注册 + +Gateway 不内置 Agent、Session 或其他业务对象分支。对象所属包同时提供静态声明和运行时 provider: + +```text +declare module '@deepseek-ai/dsh-type-meta' { + interface TypeRTLookupMap { + agent: TypeRTLookup + } +} + +ctx.typert.lookups.register('agent', { + parameter: 'agent', + wire: 'agentId', + resolve: sessionId => resolveAgent(sessionId), +}) +``` + +静态声明让 TypeRT 知道 `Agent` 在 wire 上对应 `SessionId`;运行时 provider 负责把请求中的 `agentId` 解析为当前活的 `Agent` 对象。缺少任一侧时,LIB 构建或最早可解析的运行时注册直接失败。 + +Agent、Session 等 lookup 对象只能各自占据一个顶层参数位置。普通 JSON request 可以作为另一个完整参数传入,但本方案不支持 `request.agent`、对象解构、对象数组、嵌套 lookup 或从任意复杂结构中搜索 ID。 + +Remote Context 使用独立的 merge-extensible map 和 provider。Agent 包注册 `agent` Context provider,负责用 wire identity 找到 Agent Context,并从该 Context 解析 descriptor 指定的 service key;Gateway 不知道 Agent Context 的内部结构。 + +Client 侧也注册 `agent` Context binder。binder 只负责从一次调用所在的 Context 取得 `SessionId`;它不枚举 Scope,也不逐个复制方法。scoped namespace 由 Cordis Service tracker 自动 rebind 到当前 Agent Context。 + +## InvocationDescriptor + +TypeRT、SRC 弱解析器、Host Gateway 和 Client API 之间只交换一种规范描述: + +```text +InvocationDescriptor { + id: '@deepseek-ai/dsh-goal#goals/create' + service: 'goals' + namespace: 'goals' + method: 'create' + implementation: 'remoteExportCreate' + invocation: direct | { context: 'agent', wire: 'agentId' } + scope?: { context: 'agent', wire: 'agentId' } + parameters: [ + { name, wire, source: json | lookup, lookup?, codec } + ] + result: codec + sourceLocation +} +``` + +`method` 是 endpoint 和 Client API 使用的外部短名,`implementation` 是 Host receiver 上的真实成员名;两者相同时可省略 `implementation`。`direct` descriptor 保留原始 Service 实例作为 receiver。Context descriptor 先通过对应 Context provider 找到 scoped Context,再以 descriptor 的 service key 解析 receiver。 + +严格生成器只在 direct 方法恰好包含一个 lookup 参数、同名 `TypeRTContextMap` 声明存在且两者使用同一 wire 类型 symbol 时写入 `scope`。`scope.wire` 必须指向该 lookup 参数;它声明消费端可以从调用所在 Context 补入这个参数,不改变 Host receiver 或 endpoint。多个 lookup、缺少 Context 声明或 wire 类型不一致时不生成 scoped 投影,其中类型不一致属于构建错误。 + +参数顺序来自方法签名,HTTP 字段来自参数名或 lookup 声明。Gateway 不根据请求内容推断可选字段、Context 类型、lookup 类型或缺失参数,也不会合成业务默认值。 + +LIB codec 带有 Zod schema 和“package + 公共 subpath + export name”的规范 `typeSymbol`;SRC codec 只标记 `src-json`。Host 和消费端运行在不同 JavaScript realm 时会各自持有 Zod 实例,但这些实例由同一 TypeRT 模型和 symbol key 生成。 + +descriptor 只存在于两端本地 registry。wire 上只有 `/api2` channel、endpoint 和 `{ args }` payload;Host 用自己的 descriptor 解码和调用,Client 用自己的对应 descriptor 编码参数和验证结果。 + +## TypeRT 运行时 registry + +```text +ctx.typert.local 当前进程自己的 Host 或 Client reflection +ctx.typert.remotes 消费端显式 mount 的对端 Remote contribution +ctx.typert.lookups wire ID 到 Host 活对象的 provider +ctx.typert.contexts Host Context resolver 与 Client Context binder +``` + +每次注册都返回由调用方 Cordis fiber 持有的 disposer。Gateway 和 API Service 先读取当前快照再订阅变化,因此业务 Service、generated contribution、provider 和消费者可以按任意顺序加载;任一依赖 dispose 后,相关 endpoint 或方法立即失效。 + +Registry 的 Host 根入口拥有完整 `TypeRTService` interface merge;Host 与 Client 共用的 registry 实现位于无环境声明的独立模块。Registry `/client` 入口只引用该共享实现,不经过 Host 根入口,因此不会把 Host Cordis 声明带入 Client Program。 + +## 唯一类型、符号与 Zod + +Remote Client DTS 不复制业务 DTO,也不重新声明一个结构相同的影子类型。它只从不携带 Host Cordis merge 的公共纯类型 subpath 引用原始符号: + +```text +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { CreateGoalRequest, CreateGoalResult } from '@deepseek-ai/dsh-goal/types' +``` + +因此 `SessionId`、Agent wire ID、request 和 result 在 Host 与 Browser Client 中都指向同一 TypeScript declaration,未来 TUI 复用时也不需要第二份类型。DTO 的跳转定义、重命名和引用查找回到业务类型的唯一源码位置,而不是停在生成文件中的副本。 + +Remote API 方法本身使用 declaration map 导航。TypeRT 把 `InvocationModel.location` 固定在 Host 的 `remoteExport*` 方法名 token,并在 namespace interface 的对应属性上写入 source-map segment;TypeScript editor 从 `ctx.api.models.list` 取得生成 declaration 后,再沿 `typert.remote-client.d.ts.map` 跳到 Host Service 的 `remoteExportList` 远程出口。该出口继续显式调用不改名的存量 `list()`,map 不把 decorator、class 或整个签名误当成方法定义位置。 + +TypeRT 为同一 symbol key 生成 wire Zod codec。Host Gateway 用它校验输入和编码结果,Client API 可以用它编码参数并校验响应;复杂类型无法生成严格 codec 时,LIB 构建失败,不降级为 `unknown` 或无校验 JSON。 + +Remote 方法引用的命名业务类型必须从纯类型公共 subpath 导出。如果唯一可达入口会带入 Host Service、Cordis `Context` merge 或 Host-only 实现,构建失败并要求业务包提供安全的类型出口。原始值、字面量和 TypeRT 明确支持的简单组合不需要额外命名。 + +lookup 参数不会把 `Agent` class 暴露给消费端。Remote 投影引用 lookup 声明中的唯一 ID 类型,例如 `SessionId`;Host 内部仍以唯一的 `Agent` class symbol 完成对象解析。 + +## 三种产物与两个 TypeScript Program + +Host 与 Client 仍然只有两个独立 TypeScript Program,但 TypeRT 生成三种性质不同的产物: + +```text +Host Program +├─ typert.host.js / typert.host.d.ts +│ Host 自身的 Service、Event、Object、schema 和 inbound Gateway 信息 +└─ typert.remote-client.js / typert.remote-client.d.ts / typert.remote-client.d.ts.map + Host Remote 对任意消费环境的 wire 投影 + +Client Program +└─ typert.client.js / typert.client.d.ts + Client 自身的 Service、Event、Object 和 schema 信息 +``` + +`remote-client` 是 Host Program 的第二个 emitter,不是第三个 Program,也不是 Client 本地 face。它不包含 Host Cordis merge、Service class、Context class 或实现代码,不进入 Host 本地 reflection registry。 + +Host lib 构建负责完成严格 Host 分析并产出 Host 本地 artifact 与 Remote 消费端 artifact;Client lib 随后消费 Remote DTS。完整顺序为: + +```text +Host lib build +→ 生成 typert.host.{js,d.ts} +→ 生成各业务包 lib/typert.remote-client.{js,d.ts,d.ts.map} +→ 完成 Client lib 和 typert.client 产物 +→ Vite 构建 Web +``` + +现有顶层 `build` 仍表现为先 `build:lib`、再 `build:web`,但 `build:lib` 内部必须先完成 Host 与 Remote artifact,再启动 Client TypeScript 编译。一次干净构建不能依赖上次残留的 `.d.ts`。 + +## `/remote` 包入口 + +每个提供 Remote 方法的业务包导出生成的 `/remote` 子路径: + +```text +"./remote": { + "types": "./lib/typert.remote-client.d.ts", + "default": "./lib/typert.remote-client.js" +} +``` + +消费代码通过业务包本身选择能力: + +```text +import goalsRemote from '@deepseek-ai/dsh-goal/remote' +``` + +该 import 让 `.d.ts` 的 map augmentation 进入当前 TypeScript project,同时把同一契约的 JS descriptor 作为值交给运行时。未 import 的业务包不会扩展当前 project 的 Remote API 类型。 + +业务 package 的发布文件必须同时包含 `lib/typert.remote-client.d.ts.map` 和 map 指向的 `src` 文件。生成 DTS 以 `//# sourceMappingURL=typert.remote-client.d.ts.map` 引用相邻 map;map 中的 source 从 `lib` 相对指向业务源码,例如 `../src/index.ts`。`/remote` export 不单独列出 map,package `files` 负责把它与源码一起发布。 + +仅需要静态类型时可以使用 `import type {} from '@deepseek-ai/dsh-goal/remote'`;这种 import 在运行时会被擦除,不会加载 JS,也不能触发任何运行时注册。需要真实调用的环境必须把普通 value import 得到的 contribution 交给 API Service。 + +workspace 对 `/remote` 的解析必须明确指向 `lib` 生成物,不能被通用 package-to-`src` paths 规则带回 Host 源码。普通业务 import 仍可按各环境既有规则解析到 SRC 或 LIB。 + +## 消费端严格 API 类型 + +Remote DTS 同时扩展平面 endpoint map、direct namespace interface、namespace map 和 scoped map,而不扩展全局 Cordis `Context`: + +```text +interface TypeRTRemoteNamespace$676f616c73 { + create: ( + agentId: SessionId, + request: CreateGoalRequest, + ) => Promise +} + +interface TypeRTRemoteMap { + 'goals/create': ( + agentId: SessionId, + request: CreateGoalRequest, + ) => Promise +} + +interface TypeRTRemoteNamespaceMap { + goals: TypeRTRemoteNamespace$676f616c73 +} + +interface TypeRTRemoteContextMap { + 'agent:goals/create': ( + request: CreateGoalRequest, + ) => Promise +} +``` + +`TypeRTRemoteMap` 保留规范 endpoint 签名,供协议类型和反射使用。根 API 类型直接读取 `TypeRTRemoteNamespaceMap`,不通过 key-remapped mapped type 间接推导方法;TypeScript Language Service 无法把这种间接属性稳定导航到 declaration map。namespace interface 名由 namespace 的 UTF-8 bytes 编成 hex,`goals` 因而稳定得到 `TypeRTRemoteNamespace$676f616c73`。不同 package 对同一 namespace 生成同名 interface,依靠 module augmentation 合并各自方法,且 `TypeRTRemoteNamespaceMap.goals` 始终引用同一类型。 + +TypeRT 把 `TypeRTRemoteContextMap` 按 Context key 投影到专用 Scope 类型。最终编程界面保持: + +```text +api.goals.create(agentId, request) +agent.goals.create(request) +``` + +Agent Scope 自动提供自己的 `SessionId`。因此带 `agent` lookup 的 `@Remote` 方法可以同时生成 root 和 scoped 两种消费端签名;`@RemoteContext('agent')` 方法也省略独立的 Context identity,但只生成 scoped 签名。本期只有 Client Agent Context 获得 `goals`,Root Context 不获得该属性;未来 TUI 复用时必须维持相同的 Scope 限制。 + +`RemoteApi` 保持平台无关,Browser Client 把它作为自己的 `ClientApi`。未来 TUI 若复用该类型,也必须通过专用 API 对象和 Agent Scope 使用它,不能把 Host `Context` 当成更宽的 Service 集合;未标记的 public Service 方法不会进入 Remote maps。 + +## Client TypeRT 与 API Gateway Client face + +一个消费环境的 TypeRT 同时维护本地信息和从其他环境导入的 Remote 信息,但两者存放在不同 registry: + +```text +TypeRT.local 当前环境自己的反射模型 +TypeRT.remotes 已导入的 Remote contribution +``` + +`@deepseek-ai/dsh-client-remotes/client` 集中加载需要的 Remote contribution: + +```text +import goalsRemote from '@deepseek-ai/dsh-goal/remote' +import sessionsRemote from '@deepseek-ai/dsh-session/remote' + +ctx.api.mount(goalsRemote) +ctx.api.mount(sessionsRemote) +``` + +Client 业务包只引用 `@deepseek-ai/dsh-client-remotes/client`,不直接依赖 Host API Gateway 或各业务 `/remote` 运行时入口。Client Remotes 自己依赖 Gateway Client face,并通过声明 re-export 把所选 Remote map 传给业务编译;新增或移除整套 Client 能力只修改这一处 assembly。 + +`ctx.api.mount()` 把 contribution 注册到 `TypeRT.remotes`,并由调用该方法的 Cordis fiber 持有 disposer。endpoint 重复、同一 namespace/method 模式冲突或 descriptor 与现有类型身份冲突时直接失败。 + +API Service 把 `@Remote` descriptor 实体化为根 `api` 上的真实函数。函数按 descriptor 的位置参数顺序构造具名 `args`,执行 Client strict codec,然后调用 `ctx.connection.rpc.call('/api2', endpoint, { args })`。 + +带 `scope` 的 direct descriptor 和 `@RemoteContext` descriptor 都不为每个 Agent Scope 复制函数。API Service 为每个 scoped namespace 建立一个 root singleton Cordis Service,并在该 Service 上实体化方法;Cordis tracker 在 `agent.goals.create()` 调用时把 Service 的 `this.ctx` rebind 到当前 Agent Context。方法再通过对应 Context binder 从 `this.ctx` 取得 identity。direct scoped 投影用 identity 替代 `scope.wire` 指定的 lookup 位置,Context descriptor 则把 identity 写入 receiver 的独立 wire 字段;两者都发起同一种 `/api2` 调用。 + +```text +root ctx.api.goals.create(agentId, request) + → direct descriptor + → ctx.connection.rpc.call('/api2', 'goals/create', { args }) + +agent.goals.create(request) + → tracker 将 namespace Service rebind 到 agent Context + → agent binder 从 caller Context 取得 agentId + → 用 agentId 补入同一 direct descriptor 的 lookup 参数 + → ctx.connection.rpc.call('/api2', 'goals/create', { args }) +``` + +Root `Context` 不 merge scoped `goals` 类型;只有 `AgentContext` 通过 `RemoteContextApi<'agent'>` 获得该属性。若调用方绕过类型从 Root 动态调用 scoped 方法,binder 明确报错。若 Client 已有同名 Cordis service,或两个 contribution 冲突占用同一 namespace/method,mount 直接失败,不覆盖现有服务。 + +生成的 Remote JS 只包含 descriptor、symbol key 和 codec,不打包 Host Service 实现。API Service 可以据此创建真实函数,因此本方案不依赖 JavaScript Proxy;Proxy 可以作为实现选择,但不会成为类型或反射来源。 + +## 跨环境同构约束 + +Remote API 是消费端能力,不等同于 Browser API。本期只实现 Browser Client 的 contribution 挂载、Connection RPC 调用和 Agent Scope 关联。 + +Remote DTS、Remote JS、`RemoteApi`、`InvocationDescriptor`、Remote RPC 数据协议和 Context binder 不得依赖 DOM、Browser module loader 或 HTTP。Browser Client 通过 Connection 把 descriptor 实体化的方法编码为 `/api2` RPC 调用。 + +未来 TUI 可以在不改变业务 decorator、Remote maps 和 API 调用形状的前提下接入同一调用抽象。届时 TUI 可见的 API 仍只能由 `@Remote` 和 `@RemoteContext` 生成,不能因为它与 Host 同进程就绕过 Remote 限制直接暴露 Service 方法。 + +TUI 的 runtime 挂载、carrier、Agent Scope 关联和 SRC 启动接线均不属于本期实现。 + +Web 本身依赖 `lib/client.js` 等构建产物,因此启动 Web 前要求完整 `build:lib`。Host Remote 契约变化后必须重新执行 lib build,再启动或重启 Web;本方案不在第一阶段实现 Remote contract 的增量 watch。 + +## SRC 与 LIB 运行模式 + +SRC 面向本地源码启动。`@Remote` 和 `@RemoteContext()` 的 WeakMap 记录给出方法名和调用模式,运行时从 JavaScript 函数签名读取顺序参数名,并结合已注册 lookup/Context provider 生成弱 descriptor。 + +例如 `@Remote('create') remoteExportCreate(agent, request)` 解析为外部方法 `create`、实现成员 `remoteExportCreate` 和两个顶层参数;lookup 注册把 `agent` 改写为 wire 字段 `agentId`,`request` 按同名 JSON 参数传递。SRC 不启动 `ts.Program`,不使用 preload、loader hook、源码生成或模块改写,也不检查普通 JSON 对象的内部结构。 + +SRC 无法明确解析的签名在 Service 挂载时失败。对象解构、默认参数造成的歧义、rest 参数、嵌套 lookup 和复杂类型不做猜测。 + +LIB 面向 CI、发布和 Web 前置构建。TypeRT 扫描完整 Host project,检查 Remote decorator、显式 binding、service key、endpoint 冲突、lookup/Context 声明、公共符号可达性、JSON codec 和结果 codec,并生成严格 descriptor。 + +LIB 运行时只加载 `lib` 中的 definition,不启动 TypeScript compiler。Host Gateway 后续的 Service 关联、lookup、Context 解析、调用和响应编码不区分 descriptor 来自 SRC 弱解析还是 LIB 严格生成。 + +CI 和发布运行 LIB。全仓 coverage 全部切换到 LIB 是独立后续工作,不阻塞本次直接方法调用实现。 + +## Host Gateway 注册 + +Host Gateway 同时观察 TypeRT Remote definition 和 Cordis Service 生命周期。当某个带 `typertGateway` facet 的 Service 与同 service key 的 definition 都可用时,Gateway 注册其 endpoint;两者到达顺序不影响结果。 + +Gateway 启动时先读取 TypeRT definition 和 Cordis reflection store 的当前快照,再订阅 registry change 与 `internal/service`。它按 service key reconcile definition、活 Service 和 binding;Service 被替换或 dispose 时撤销对应 endpoint。definition、lookup provider 或 Context provider 撤销时,依赖它们的 endpoint 立即不可调用,不保留失效对象或降级为原始 ID 调用。 + +普通 `@Remote` 调用保留原始 Service 实例作为 receiver。lookup 成功后,Gateway 按 descriptor 的参数顺序调用 `implementation ?? method` 指定的成员。 + +`@RemoteContext('agent')` 调用先由 Agent Context provider 解析 wire identity,再从该 Context 读取 descriptor 的 service key 并调用 scoped receiver。业务方法不会收到隐藏 Context 参数或 Agent ID。 + +```text +ctx.typertGateway.invoke({ namespace, method, args }) +→ 查找本地 InvocationDescriptor 与 live receiver +→ 按参数 descriptor 读取具名 wire 字段 +→ codec 解码普通值或 lookup ID +→ lookup provider 把 ID 解析为活对象 +→ direct 使用原 Service;context 先解析 scoped Context 和 Service +→ Reflect.apply(receiver[implementation ?? method], receiver, orderedArgs) +→ result codec 编码业务结果 +``` + +`ctx.typertGateway.invoke()` 是 carrier-independent 的 Host 入口。它不创建 rpcId、RPC envelope 或 HTTP response;它只返回编码结果,或产生由 Connection RPC adapter 映射的 Gateway 错误。 + +## `/api2` 调用链 + +`/api2` 是唯一 Connection/RPC 机制上的独立协议 channel,不是 Gateway 自建的 transport。Gateway 只向 Connection 注册一个本地 handler;本期在现有 HTTP Connection 中增加这项通用 channel 能力: + +```text +ctx.connection.rpc.handle('/api2', (endpoint, payload) => { + const { namespace, method } = parseEndpoint(endpoint) + const { args } = parsePayload(payload) + return ctx.typertGateway.invoke({ namespace, method, args }) +}) +``` + +Connection Host half 从唯一 HTTP Server 取得 handle,复用同一 RPC bridge、request/response envelope、rpcId、序列化、trust、transport error 和 `RpcError`。当前物理映射是: + +```text +POST /api2// +``` + +Remote payload 使用具名 JSON 对象,不使用位置数组,也不发送 `InvocationDescriptor`。普通 Goal 调用的 payload slot 是: + +```json +{ + "args": { + "agentId": "session-1", + "request": { + "objective": "finish the migration" + } + } +} +``` + +完整链路为: + +```text +ctx.api.goals.create(sessionId, request) +→ Client InvocationDescriptor 编码 { args: { agentId, request } } +→ ctx.connection.rpc.call('/api2', 'goals/create', { args }) +→ Connection 创建 rpcId 和既有 client-request envelope +→ 当前 carrier 发送 POST /api2/goals/create +→ Connection Host half 执行 trust、反序列化和 RPC 分发 +→ /api2 handler 调用 ctx.typertGateway.invoke(...) +→ Host InvocationDescriptor 解码、lookup、receiver 解析和 Reflect.apply +→ result codec 编码 +→ Connection 写入既有 RPC result 并回送相同 rpcId +→ Client result codec 验证并返回 CreateGoalResult +``` + +Remote 不定义第二层 `{ ok, value/error }` response。成功值和 Gateway 错误直接使用既有 RPC response 的 `result`;Gateway adapter 负责把 endpoint、schema、lookup、Context、Service 和业务调用失败映射为 `RpcError`,Connection 负责传输该错误。 + +Gateway 不处理逐方法权限、调用者身份、取消、幂等或长连接状态。本工作只扩展 Connection 的通用 channel 注册和调用能力,不改变现有 `/api`、trusted connection、trusted-host 或 privileged method 语义;Connection/WebSocket 迁移后续独立完成。 + +## Connection 与协议边界 + +API Service 负责 Remote contribution、方法实体化、Scope 绑定以及位置参数与 descriptor 的对应。Gateway 负责 Host descriptor、lookup、Context 和业务调用。Connection 只负责把 `/api2`、endpoint 和 `{ args }` 作为一个 RPC 调用发送到目标并返回既有 RPC result;它不理解 Goal、Agent、lookup、descriptor 或 Client API 类型。 + +`/api` 与 `/api2` 共享唯一 Connection、Server、RPC envelope 和连接生命周期,但保持协议隔离。Connection 从 HTTP 迁移到 WebSocket 时,`/api2` 从物理路径自然变成逻辑 channel;Remote payload、业务 decorator、生成的 DTS、Remote API 类型和 Agent Scope 编程界面都不变化。 + +## 包边界 + +- `@deepseek-ai/dsh-type-meta`:轻量 decorator、binding、lookup、Remote Context 和 descriptor 协议。 +- TypeRT generator:分析 Host/Client Program,生成本地 face 和 Remote 消费端投影,并生成规范 symbol/Zod 信息。 +- TypeRT runtime:分别保存当前环境的 local reflection 与导入的 Remote contribution。 +- `@deepseek-ai/dsh-host-api-gateway`:默认入口关联 Host definition 与 Service,执行 lookup、Context receiver 解析、调用和结果编码,并向 Connection 注册 `/api2` handler;`/client` 入口挂载 Remote contribution,创建严格 API 方法,并把调用交给 `ctx.connection.rpc`。两个入口共享 Remote 协议,但不互相导入各自的 Cordis interface merge。 +- `@deepseek-ai/dsh-client-remotes`:Client 业务唯一依赖的 Remote facade;直接依赖 Gateway Client face,选择 `/remote` contributions,并向业务包传递合并后的 API 类型。 +- Connection:拥有唯一 HTTP Server/未来 WebSocket carrier、RPC envelope、rpcId、序列化、trust 和错误传输,同时承载隔离的 `/api` 与 `/api2` channel。 +- Agent/Session 等业务对象包:拥有 lookup、Context provider、唯一 ID 类型和纯类型公共出口。 +- 业务 Service 包:声明 binding、Remote 方法及其 request/result 类型,并导出生成的 `/remote` 子路径。 + +## 首期实现范围 + +第一条纵向链路实现 `@deepseek-ai/dsh-goal/remote → Browser Client API → Connection RPC /api2 → Host Gateway → GoalService.remoteExportCreate()`,并证明同一个带 Agent lookup 的 direct descriptor 同时支持 `ctx.api.goals.create(agentId, request)` 与 `agentCtx.goals.create(request)`。`@RemoteContext('agent')` 的 scoped receiver 语义继续保留为独立模式。 + +本期实现 Connection 的通用第二 channel API 及当前 HTTP carrier 映射,但不实现 WebSocket 迁移、TUI runtime、TUI carrier 或 TUI Agent Scope 接线。本 RFC 也不设计 Permission/Approval 状态机、Session 事件流、调用授权、取消、重试、幂等和跨版本协议兼容。 + +## Alternatives considered + +**继续使用中央 API Proxy 包。** 该方案要求业务方法、Host 路由和 Client 接口在多个位置重复声明,也会继续把直接调用、带状态交互和事件流绑在同一生命周期中,因此不采用。 + +**让 decorator 在运行时完成严格反射。** JavaScript decorator 无法恢复擦除后的 TypeScript 类型、公共符号身份和完整 Zod codec;向 constructor 注入 compiler 私有 symbol 又会隐藏业务类的真实依赖,因此严格信息由 TypeRT compiler 生成。 + +**SRC 启动时使用 preload、loader hook 或完整 `ts.Program`。** 这能复用 LIB 分析,但增加所有源码启动入口的要求。SRC 只需要可用的弱 descriptor,因此采用 decorator 标记、函数参数名和显式 provider;严格检查留给 LIB contract pass。 + +**手写 Client interface。** 手写接口不能保证只包含 Remote 标记的方法,也会与 Host 签名、lookup ID 和 Zod schema 漂移,因此 Client 类型从 Host Program 自动投影。 + +**使用 TypeScript language-service/compiler plugin 让 Client 直接理解 decorator。** 这会让编辑器、Vite、tsc、tsx 和发布消费者都依赖额外插件,接入面过大,因此生成普通 `.d.ts` 和标准 declaration map。 + +**把完整 Host DTS 导入 Client 或 TUI。** 该方案会带入 Host Service 和 Cordis interface merge,并向消费端暴露未标记方法。Remote DTS 只引用纯类型公共符号并扩展专用 Remote maps。 + +**只生成 Remote DTS,不生成 JS。** 类型可以成立,但运行时无法枚举 endpoint、codec 和 Context 模式,只能依赖 Proxy 或另一份手写注册表,因此同一次 Host 投影同时生成 Remote JS contribution。 + +**让 `/remote` 的顶层 import 偷偷注册全局状态。** ESM 求值时未必已有目标 Cordis Context,多个 Context、HMR 和 dispose 也无法明确归属,因此普通 value import 只返回 contribution,由环境 assembly 的 API Service 显式挂载。 + +**为 Remote 新建独立 transport、HTTP route 和响应信封。** 这会复制现有 Connection 的 Server ownership、rpcId、序列化、trust、错误和未来 WebSocket 生命周期,并让两个 RPC 栈分别迁移,因此 `/api2` 作为独立协议 channel 复用唯一 Connection/RPC 机制。 + +## Acceptance criteria + +- Goal Service 保留既有业务方法,在类末尾通过显式 `typertGateway` 和 `@Remote('create') remoteExportCreate(...)` 新增远程出口,不维护第二份路由、codec 或 Client 方法清单。 +- 一次干净 `build:lib` 先生成 Host Remote contract,再完成 Host 和 Client 消费端编译,并在业务包 `lib` 下产生可通过 `/remote` 导入的 JS、DTS 和 DTS map。 +- 导入 `@deepseek-ai/dsh-goal/remote` 后,消费 project 获得严格的 `api.goals.create(...)` 类型;不导入时该 namespace 不进入类型;从 `create` 跳转定义会通过 declaration map 到达 Host Service 的 `remoteExportCreate` 实现。 +- Client assembly 挂载同一个 import 得到的 JS contribution 后,TypeRT 能反射 endpoint、参数、结果、lookup、Context 和 Zod 信息,API Service 无需手写 stub 即可创建调用方法。 +- Remote DTS、Remote JS、`RemoteApi` 和 descriptor 协议不依赖 Browser 专属能力,且类型模型无法暴露未标记的 Goal Service 方法,为未来 TUI 同构接入保留边界。 +- `agent.goals.*` 通过 Cordis tracker 和 Context binder 取得调用 Scope,Root Context 不获得 Agent-only 类型,且不为每个 Scope 复制函数。 +- `/api2/goals/create` 能把 `agentId` 解析为唯一 Agent 对象,调用原始 Goal Service receiver,并通过既有 RPC result/error 返回结果。 +- `/api2` 与 `/api` 共享唯一 Connection/RPC carrier,但保持协议隔离;Remote 不直接注册 HTTP Server handle,也不定义第二套 response envelope。 +- Connection 提供通用 channel 注册和调用能力,并把 `/api2` 映射到当前 HTTP carrier;现有 `/api` 行为与 trust 语义保持不变。 +- 现有 `/api`、Connection/trusted connection、Permission/Approval 和 Session 事件流行为不因本实现改变。 + +## Risks + +Remote API 类型依赖生成的 `lib` 声明,构建编排必须在 Host 和 Client 消费端编译前完成 contract pass;顺序错误会让干净构建依赖陈旧产物。 + +源码导航依赖 Remote package 同时发布 declaration map 和 map 指向的 `src`。package `files` 漏掉任一侧时类型仍可编译,但消费端跳转会停在生成 DTS,因此 workspace manifest 校验必须把两者作为同一发布契约。 + +SRC 弱 descriptor 不验证普通 JSON 内部结构。Host Remote 签名变化后,Web 和严格类型消费者必须重新执行 lib build;第一阶段没有增量 contract watch。 + +公共类型唯一性要求业务 DTO 具有纯类型出口,可能暴露现有包中 Host 类型与实现入口混杂的问题。构建会拒绝这些边界,而不是复制类型掩盖问题。 + +类型 import 与运行时 contribution 是两种不同效果。`import type {}` 只扩展静态 API;真实调用环境遗漏 value contribution 时,API Service 必须以明确的“Remote 未挂载”错误失败。 + +Browser 与 Host 各自持有 Zod 实例,不能依赖对象 identity 跨 realm 比较;一致性只由规范 symbol key、同一生成模型和 wire 行为保证。 + +消费端可以导入 Host 当前未挂载的 Remote contract。类型表示“该协议能力已被消费端选择”,不保证目标进程当前存在对应 Service;运行时 endpoint 不可用必须明确失败。 + +Connection 的通用 channel API 必须同时适合当前 HTTP carrier 和后续 WebSocket carrier。若接口把 `fetch`、HTTP request 或 route handle 暴露给 Gateway/API Service,WebSocket 迁移会再次穿透 Remote 层,因此这些物理对象必须留在 Connection 内部。 From 64a963da0b42f9cd389d133656f73b1936760c41 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 5 Aug 2026 11:17:47 +0800 Subject: [PATCH 15/63] feat: add TypeRT remote gateway infrastructure --- apps/cli/composition.md | 9 + docs/capability-seams.md | 7 +- docs/config-catalog.md | 8 +- docs/cordis-catalog/events.md | 8 +- docs/cordis-catalog/services.md | 31 +- docs/module-graph.md | 14 +- package.json | 5 +- packages/bundle/base/cordis.patch.yml | 9 + packages/bundle/base/package.json | 3 + .../client/connection/src/client/index.ts | 7 + packages/client/connection/src/client/rpc.ts | 75 ++ packages/client/connection/src/index.ts | 101 +- packages/client/connection/src/rpc-host.ts | 150 +++ packages/client/connection/src/rpc.ts | 59 ++ .../connection/tests/client-apply.spec.ts | 37 + .../client/connection/tests/node-half.spec.ts | 92 +- .../client/runtime/tests/client-apply.spec.ts | 3 + .../client/runtime/tests/wire-events.spec.ts | 3 + packages/client/tsdown.client.ts | 7 +- .../cordis/tool-cordis/src/api-catalog.ts | 46 +- packages/core/agent/package.json | 8 + packages/core/agent/src/index.ts | 25 + packages/core/agent/tests/agent.spec.ts | 26 + packages/core/agent/tsconfig.json | 3 + packages/core/session/package.json | 3 + packages/core/session/src/index.ts | 16 + packages/core/session/tests/typert.spec.ts | 26 + packages/core/session/tsconfig.json | 3 + packages/host/api-gateway/README.i18n.yaml | 6 + packages/host/api-gateway/README.md | 36 + packages/host/api-gateway/README.zh.md | 36 + packages/host/api-gateway/package.json | 68 ++ packages/host/api-gateway/src/client/index.ts | 370 ++++++++ packages/host/api-gateway/src/index.ts | 604 ++++++++++++ packages/host/api-gateway/src/invariant.ts | 30 + packages/host/api-gateway/src/types.ts | 52 ++ .../host/api-gateway/tests/client.spec.ts | 222 +++++ .../host/api-gateway/tests/gateway.spec.ts | 795 ++++++++++++++++ packages/host/api-gateway/tsconfig.json | 27 + packages/host/api-gateway/tsdown.config.ts | 3 + packages/host/apiproxy/src/api/index.ts | 5 + packages/typert/generator/package.json | 1 + packages/typert/generator/src/analyzer.ts | 869 +++++++++++++++++- .../typert/generator/src/cordis-catalog.ts | 2 +- packages/typert/generator/src/emitter.ts | 538 ++++++++++- packages/typert/generator/src/model.ts | 55 ++ packages/typert/generator/src/renderer.ts | 101 +- .../typert/generator/src/tsdown-plugin.ts | 79 +- packages/typert/generator/src/workspace.ts | 42 +- .../__snapshots__/type-model.spec.ts.snap | 5 + .../tests/fixtures/remote-model/package.json | 5 + .../remote-model/packages/domain/package.json | 9 + .../remote-model/packages/domain/src/index.ts | 19 + .../remote-model/packages/domain/src/types.ts | 2 + .../packages/domain/tsconfig.json | 11 + .../remote-model/packages/remote/package.json | 24 + .../remote-model/packages/remote/src/index.ts | 30 + .../remote-model/packages/remote/src/types.ts | 20 + .../packages/remote/tsconfig.json | 14 + .../fixtures/remote-model/tsconfig.base.json | 20 + .../fixtures/remote-model/tsconfig.host.json | 8 + .../fixtures/remote-model/type-meta.d.ts | 45 + .../generator/tests/remote-model.spec.ts | 486 ++++++++++ .../generator/tests/schema-emitter.spec.ts | 238 ++++- .../generator/tests/tools-catalog.spec.ts | 2 +- .../generator/tests/tsdown-plugin.spec.ts | 81 ++ .../typert/generator/tests/type-model.spec.ts | 98 ++ packages/typert/loader/src/index.ts | 91 +- packages/typert/loader/tests/loader.spec.ts | 210 +++++ packages/typert/registry/package.json | 15 + packages/typert/registry/src/client/index.ts | 15 + packages/typert/registry/src/index.ts | 220 +---- packages/typert/registry/src/service.ts | 584 ++++++++++++ packages/typert/registry/src/types.ts | 8 + packages/typert/registry/tests/typert.spec.ts | 184 +++- packages/typert/registry/tsconfig.json | 3 + packages/typert/registry/tsdown.config.ts | 26 +- packages/typert/type-meta/README.i18n.yaml | 6 + packages/typert/type-meta/README.md | 33 + packages/typert/type-meta/README.zh.md | 33 + packages/typert/type-meta/package.json | 42 + packages/typert/type-meta/src/index.ts | 223 +++++ packages/typert/type-meta/src/invariant.ts | 30 + packages/typert/type-meta/src/types.ts | 358 ++++++++ .../type-meta/tests/fixtures/source-launch.ts | 29 + .../typert/type-meta/tests/type-meta.spec.ts | 132 +++ packages/typert/type-meta/tsconfig.json | 21 + pnpm-lock.yaml | 61 ++ scripts/client-bundle-purity.spec.ts | 7 + scripts/gen-cordis-catalog.ts | 2 + scripts/gen-doc-graphs.ts | 11 +- .../verify-package-readme-model-experience.ts | 2 + tsconfig.base.json | 10 +- tsconfig.client.json | 2 + tsconfig.host.json | 2 + tsdown.config.ts | 4 + tsdown.typert-host.config.ts | 20 + vitest.config.ts | 30 +- 98 files changed, 7812 insertions(+), 444 deletions(-) create mode 100644 packages/client/connection/src/client/rpc.ts create mode 100644 packages/client/connection/src/rpc-host.ts create mode 100644 packages/client/connection/src/rpc.ts create mode 100644 packages/core/session/tests/typert.spec.ts create mode 100644 packages/host/api-gateway/README.i18n.yaml create mode 100644 packages/host/api-gateway/README.md create mode 100644 packages/host/api-gateway/README.zh.md create mode 100644 packages/host/api-gateway/package.json create mode 100644 packages/host/api-gateway/src/client/index.ts create mode 100644 packages/host/api-gateway/src/index.ts create mode 100644 packages/host/api-gateway/src/invariant.ts create mode 100644 packages/host/api-gateway/src/types.ts create mode 100644 packages/host/api-gateway/tests/client.spec.ts create mode 100644 packages/host/api-gateway/tests/gateway.spec.ts create mode 100644 packages/host/api-gateway/tsconfig.json create mode 100644 packages/host/api-gateway/tsdown.config.ts create mode 100644 packages/typert/generator/tests/fixtures/remote-model/package.json create mode 100644 packages/typert/generator/tests/fixtures/remote-model/packages/domain/package.json create mode 100644 packages/typert/generator/tests/fixtures/remote-model/packages/domain/src/index.ts create mode 100644 packages/typert/generator/tests/fixtures/remote-model/packages/domain/src/types.ts create mode 100644 packages/typert/generator/tests/fixtures/remote-model/packages/domain/tsconfig.json create mode 100644 packages/typert/generator/tests/fixtures/remote-model/packages/remote/package.json create mode 100644 packages/typert/generator/tests/fixtures/remote-model/packages/remote/src/index.ts create mode 100644 packages/typert/generator/tests/fixtures/remote-model/packages/remote/src/types.ts create mode 100644 packages/typert/generator/tests/fixtures/remote-model/packages/remote/tsconfig.json create mode 100644 packages/typert/generator/tests/fixtures/remote-model/tsconfig.base.json create mode 100644 packages/typert/generator/tests/fixtures/remote-model/tsconfig.host.json create mode 100644 packages/typert/generator/tests/fixtures/remote-model/type-meta.d.ts create mode 100644 packages/typert/generator/tests/remote-model.spec.ts create mode 100644 packages/typert/registry/src/client/index.ts create mode 100644 packages/typert/registry/src/service.ts create mode 100644 packages/typert/type-meta/README.i18n.yaml create mode 100644 packages/typert/type-meta/README.md create mode 100644 packages/typert/type-meta/README.zh.md create mode 100644 packages/typert/type-meta/package.json create mode 100644 packages/typert/type-meta/src/index.ts create mode 100644 packages/typert/type-meta/src/invariant.ts create mode 100644 packages/typert/type-meta/src/types.ts create mode 100644 packages/typert/type-meta/tests/fixtures/source-launch.ts create mode 100644 packages/typert/type-meta/tests/type-meta.spec.ts create mode 100644 packages/typert/type-meta/tsconfig.json create mode 100644 tsdown.typert-host.config.ts diff --git a/apps/cli/composition.md b/apps/cli/composition.md index 1da393bd06..0246f6163f 100644 --- a/apps/cli/composition.md +++ b/apps/cli/composition.md @@ -18,6 +18,12 @@ flowchart LR cfg --> plugin_dsh_base_llm plugin_dsh_base_session["session
@deepseek-ai/dsh-session"] cfg --> plugin_dsh_base_session + plugin_dsh_base_typert["typert
@deepseek-ai/dsh-typert-registry"] + cfg --> plugin_dsh_base_typert + plugin_dsh_base_typert_loader["typert-loader
@deepseek-ai/dsh-typert-loader"] + cfg --> plugin_dsh_base_typert_loader + plugin_dsh_base_typert_gateway["typert-gateway
@deepseek-ai/dsh-host-api-gateway"] + cfg --> plugin_dsh_base_typert_gateway 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-message-llm"] @@ -159,6 +165,9 @@ flowchart LR | `repository-plugins` | `@deepseek-ai/dsh-repository-plugin` | | `llm` | `@deepseek-ai/dsh-llm` | | `session` | `@deepseek-ai/dsh-session` | +| `typert` | `@deepseek-ai/dsh-typert-registry` | +| `typert-loader` | `@deepseek-ai/dsh-typert-loader` | +| `typert-gateway` | `@deepseek-ai/dsh-host-api-gateway` | | `session-title` | `@deepseek-ai/dsh-session-title` | | `session-title-llm` | `@deepseek-ai/dsh-session-title-first-message-llm` | | `user-interaction` | `@deepseek-ai/dsh-user-interaction` | diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 6a8f7943c2..18839bf3c2 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -32,6 +32,8 @@ flowchart LR pkg_typert_registry["typert-registry"] svc_typert["ctx.typert
Runtime type registry"] pkg_typert_loader["typert-loader"] + pkg_api_gateway["api-gateway"] + svc_typertGateway["ctx.typertGateway
TypeRT Host invocation gateway"] svc_sessionPersistence["ctx.sessionPersistence
Durable session persistence seam"] pkg_session_persistence_jsonl["session-persistence-jsonl"] pkg_session_persistence_sqlite["session-persistence-sqlite"] @@ -171,6 +173,7 @@ flowchart LR pkg_acp --> svc_approval pkg_agent --> svc_agents pkg_agent_loop --> svc_agentLoop + pkg_api_gateway --> svc_typertGateway pkg_approval --> svc_approval pkg_bash --> svc_bash pkg_bash_env --> svc_bashEnv @@ -347,6 +350,7 @@ flowchart LR svc_tools --> pkg_tool_subagent svc_tools --> pkg_tool_todo svc_tools --> pkg_tool_web + svc_typert --> pkg_api_gateway svc_typert --> pkg_typert_loader svc_userInteraction --> pkg_tool_ask_user svc_web --> pkg_tool_web @@ -363,7 +367,8 @@ flowchart LR | `ctx.toolResultPrune` | `core` | [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune) | - | [`compact-basic`](../packages/compact/compact-basic) | - | Rewrites oversized current tool results through replayable single-node surface replacements before summary compaction. | | `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`cli-demo`](../packages/examples/cli-demo), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`invariants`](../packages/support/invariants) | - | Owns append-only Session instances and emits the durable session event feed. | | `ctx.invariants` | `core` | [`invariants`](../packages/support/invariants) | - | [`session`](../packages/core/session), [`agent`](../packages/core/agent), [`scope`](../packages/core/scope), [`agent-loop`](../packages/core/agent-loop) | - | Companion subpaths register owner-local checks; the service owns selection, uniqueness, child fibers, and package-attributed failures. | -| `ctx.typert` | `core` | [`typert-registry`](../packages/typert/registry) | - | [`typert-loader`](../packages/typert/loader) | - | Plugins register live zod contributions directly or through dsh-typert-loader; runtime consumers query schemas and reflection metadata at their own edges. | +| `ctx.typert` | `core` | [`typert-registry`](../packages/typert/registry) | - | [`typert-loader`](../packages/typert/loader), `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` | - | - | - | Associates generated Remote descriptors with live Cordis services, resolves registered identities, and exposes unary calls through the shared Connection RPC carrier. | | `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session-persistence/session-persistence) | [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop), [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | - | Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time. | | `ctx.settings` | `seam` | [`settings`](../packages/settings/settings) | [`settings-local`](../packages/settings/settings-local) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), `apiproxy` | - | Plugins register namespace schemas and resolve layered values; providers store the raw document. The LLM adapters register their entry config as the composition base under the user section; the web gateway serves redacted layered descriptors and writes the user layer. | | `ctx.credentials` | `seam` | [`credentials`](../packages/credentials/credentials) | [`credentials-local`](../packages/credentials/credentials-local) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), `apiproxy` | - | Configuration carries references to secrets; providers own the values. Consumers resolve per operation, so a rotated credential reaches the very next request; the web gateway exposes value-free views and write-only storage. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index f9fa6e8bb2..5728ac4bed 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -291,7 +291,7 @@ Source: [`packages/examples/cli-demo/src/index.ts:26`](../packages/examples/cli- ## `@deepseek-ai/dsh-client-connection` -Requires: `httpServer` · `apiProxy` +Requires: `httpServer` ```ts config-catalog /** Plugin config: the deployment's non-loopback serving authorities. */ @@ -308,7 +308,7 @@ export interface ConnectionConfig { } ``` -Source: [`packages/client/connection/src/index.ts:21`](../packages/client/connection/src/index.ts) +Source: [`packages/client/connection/src/index.ts:31`](../packages/client/connection/src/index.ts) ## `@deepseek-ai/dsh-client-hmr` @@ -2548,6 +2548,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-commands` ([`packages/ui/commands/src/index.ts`](../packages/ui/commands/src/index.ts)) - `@deepseek-ai/dsh-fs-policy` ([`packages/fs/fs-policy/src/index.ts`](../packages/fs/fs-policy/src/index.ts)) - `@deepseek-ai/dsh-goal-session` — requires `agents` · `goals` · `sessions` ([`packages/goal/goal-session/src/index.ts`](../packages/goal/goal-session/src/index.ts)) +- `@deepseek-ai/dsh-host-api-gateway` — requires `typert` ([`packages/host/api-gateway/src/index.ts`](../packages/host/api-gateway/src/index.ts)) - `@deepseek-ai/dsh-host-directory-picker-auto` — requires `httpServer` · `loader` ([`packages/host/directory-picker-auto/src/index.ts`](../packages/host/directory-picker-auto/src/index.ts)) - `@deepseek-ai/dsh-host-directory-picker-native` ([`packages/host/directory-picker-native/src/index.ts`](../packages/host/directory-picker-native/src/index.ts)) - `@deepseek-ai/dsh-llm` ([`packages/llm/llm/src/index.ts`](../packages/llm/llm/src/index.ts)) @@ -2563,6 +2564,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-timeout-policy` — requires `tools` ([`packages/timeout/timeout-policy/src/index.ts`](../packages/timeout/timeout-policy/src/index.ts)) - `@deepseek-ai/dsh-tool-ask-user` — requires `tools` · `userInteraction` ([`packages/ui/tool-ask-user/src/index.ts`](../packages/ui/tool-ask-user/src/index.ts)) - `@deepseek-ai/dsh-tool-subagent-control` — requires `tools` · `subagents` ([`packages/subagent/tool-subagent-control/src/index.ts`](../packages/subagent/tool-subagent-control/src/index.ts)) +- `@deepseek-ai/dsh-tool-todo` — requires `tools` ([`packages/todo/tool-todo/src/index.ts`](../packages/todo/tool-todo/src/index.ts)) - `@deepseek-ai/dsh-typert-registry` ([`packages/typert/registry/src/index.ts`](../packages/typert/registry/src/index.ts)) - `@deepseek-ai/dsh-user-interaction` ([`packages/ui/user-interaction/src/index.ts`](../packages/ui/user-interaction/src/index.ts)) - `@deepseek-ai/dsh-workspace` — requires `storageDomain` · `sessionPersistence` ([`packages/workspace/workspace/src/index.ts`](../packages/workspace/workspace/src/index.ts)) @@ -2620,4 +2622,6 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. - `@deepseek-ai/dsh-subagent-inprocess` ([`packages/subagent/subagent-inprocess/src/index.ts`](../packages/subagent/subagent-inprocess/src/index.ts)) - `@deepseek-ai/dsh-telemetry` ([`packages/sdk/telemetry/src/index.ts`](../packages/sdk/telemetry/src/index.ts)) - `@deepseek-ai/dsh-timeout` ([`packages/util/timeout/src/index.ts`](../packages/util/timeout/src/index.ts)) +- `@deepseek-ai/dsh-type-meta` ([`packages/typert/type-meta/src/index.ts`](../packages/typert/type-meta/src/index.ts)) - `@deepseek-ai/dsh-typert-generator` ([`packages/typert/generator/src/index.ts`](../packages/typert/generator/src/index.ts)) +- `@deepseek-ai/dsh-typert-registry` ([`packages/typert/registry/src/index.ts`](../packages/typert/registry/src/index.ts)) diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 955adbe234..348d334e9f 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -542,7 +542,7 @@ Creation announcement during session publication. A synchronous throw vetoes and Types: [Scoped](../core-data-structures/scope.md) · [Session](../core-data-structures/session.md) -Source: [`packages/core/session/src/index.ts:73`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:74`](../../packages/core/session/src/index.ts) ### `session/disposed` — emit @@ -563,7 +563,7 @@ Emitted once when an announced session leaves the store, including publication r Types: [Scoped](../core-data-structures/scope.md) · [Session](../core-data-structures/session.md) -Source: [`packages/core/session/src/index.ts:83`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:84`](../../packages/core/session/src/index.ts) ### `session/event` — emit @@ -586,7 +586,7 @@ Post-commit, fire-and-forget append feed. The listener snapshot resolves before Types: [Scoped](../core-data-structures/scope.md) · [Session](../core-data-structures/session.md) · [SessionEvent](../core-data-structures/core.md) -Source: [`packages/core/session/src/index.ts:95`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:96`](../../packages/core/session/src/index.ts) ### `session/flush` — parallel @@ -606,7 +606,7 @@ Awaited parallel durability checkpoint: every listener runs and the caller await Types: [Scoped](../core-data-structures/scope.md) · [Session](../core-data-structures/session.md) -Source: [`packages/core/session/src/index.ts:104`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:105`](../../packages/core/session/src/index.ts) ## `settings/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index b2f667681d..0a9af0bae5 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -216,7 +216,7 @@ roots(): Agent[] Types: [Agent](../core-data-structures/core.md) · [SessionId](../core-data-structures/core.md) -Source: [`packages/core/agent/src/index.ts:242`](../../packages/core/agent/src/index.ts) +Source: [`packages/core/agent/src/index.ts:253`](../../packages/core/agent/src/index.ts) ## `ctx.approval` — `ApprovalService` @@ -1748,7 +1748,7 @@ fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Types: [CreateSessionOptions](../core-data-structures/persistence.md) · [PrepareSessionOptions](../core-data-structures/persistence.md) · [Session](../core-data-structures/session.md) · [SessionId](../core-data-structures/core.md) -Source: [`packages/core/session/src/index.ts:800`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:807`](../../packages/core/session/src/index.ts) ## `ctx.sessionTitle` — `SessionTitleService` @@ -2527,16 +2527,17 @@ Source: [`packages/core/tools/src/index.ts:739`](../../packages/core/tools/src/i ## `ctx.typert` — `TypertRegistry` -Registry of generated schemas and package reflection. +Registry of generated schemas, package reflection, invocations, and Remote dependency providers. ```ts cordis-catalog /** * Register one generated contribution atomically for the calling fiber. - * Duplicate package-face identities or schema keys reject the whole batch. - * @param contribution - generated schemas and package metadata. + * Duplicate package-face identities, schemas, invocation ids, or endpoints + * reject the whole batch. + * @param contribution - generated schemas, reflection, and Host invocations. * @returns the exact effect disposer that removes this contribution. */ -register(contribution: TypertContribution): () => void +register(contribution: TypertContribution): TypeRTDisposer /** * Look up one schema by `#`. @@ -2584,7 +2585,23 @@ listPackages(filter: TypertPackageFilter = {}): TypertPackageRecord[] toJSONSchema(key: string, params?: z.core.ToJSONSchemaParams): z.core.JSONSchema.BaseSchema ``` -Source: [`packages/typert/registry/src/index.ts:67`](../../packages/typert/registry/src/index.ts) +Source: [`packages/typert/registry/src/service.ts:319`](../../packages/typert/registry/src/service.ts) + +## `ctx.typertGateway` — `TypertGatewayService` + +Resolve strict generated definitions or conservative SRC markers against current Cordis Services and TypeRT providers. + +```ts cordis-catalog +/** + * Invoke one live Remote method through strict generated reflection or SRC markers. + * @param request - decoded endpoint and exact named wire arguments. + * @returns the validated business result. + * @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; business errors retain their identity. + */ +async invoke(request: InvokeRemoteRequest): Promise +``` + +Source: [`packages/host/api-gateway/src/index.ts:94`](../../packages/host/api-gateway/src/index.ts) ## `ctx.userInteraction` — `UserInteractionService` diff --git a/docs/module-graph.md b/docs/module-graph.md index 1a7ae5c8d2..fd9ac036a0 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -211,6 +211,7 @@ flowchart TD end subgraph group_host["packages/host"] pkg_frontend_static["frontend-static"] + pkg_host_api_gateway["host-api-gateway"] pkg_host_apiproxy["host-apiproxy"] pkg_host_directory_picker["host-directory-picker"] pkg_host_directory_picker_auto["host-directory-picker-auto"] @@ -272,6 +273,7 @@ flowchart TD pkg_session_telemetry_otel["session-telemetry-otel"] end subgraph group_typert["packages/typert"] + pkg_type_meta["type-meta"] pkg_typert_generator["typert-generator"] pkg_typert_loader["typert-loader"] pkg_typert_registry["typert-registry"] @@ -311,6 +313,7 @@ flowchart TD pkg_host_webserver --> pkg_invariants pkg_storage --> pkg_invariants pkg_subprocess --> pkg_invariants + pkg_type_meta --> pkg_invariants pkg_typert_generator --> pkg_invariants pkg_typert_registry --> pkg_invariants pkg_llm --> pkg_brand @@ -374,6 +377,7 @@ flowchart TD pkg_session --> pkg_invariants pkg_session --> pkg_llm pkg_session --> pkg_scope + pkg_session --> pkg_type_meta pkg_system_prompt --> pkg_invariants pkg_system_prompt --> pkg_llm pkg_system_prompt --> pkg_scope @@ -420,6 +424,9 @@ flowchart TD pkg_credentials_local --> pkg_credentials pkg_credentials_local --> pkg_invariants pkg_credentials_local --> pkg_paths + pkg_host_api_gateway --> pkg_client_connection + pkg_host_api_gateway --> pkg_invariants + pkg_host_api_gateway --> pkg_typert_registry pkg_lsp --> pkg_brand pkg_lsp --> pkg_invariants pkg_lsp --> pkg_llm @@ -434,6 +441,7 @@ flowchart TD pkg_agent --> pkg_scope pkg_agent --> pkg_session pkg_agent --> pkg_system_prompt + pkg_agent --> pkg_type_meta pkg_bash --> pkg_invariants pkg_bash --> pkg_sandbox pkg_bash --> pkg_subprocess @@ -1154,6 +1162,7 @@ flowchart TD | [`host-webserver`](../packages/host/webserver) | `host` | [`invariants`](../packages/support/invariants) | | [`storage`](../packages/storage/storage) | `storage` | [`invariants`](../packages/support/invariants) | | [`subprocess`](../packages/subprocess/subprocess) | `subprocess` | [`invariants`](../packages/support/invariants) | +| [`type-meta`](../packages/typert/type-meta) | `typert` | [`invariants`](../packages/support/invariants) | | [`typert-generator`](../packages/typert/generator) | `typert` | [`invariants`](../packages/support/invariants) | | [`typert-registry`](../packages/typert/registry) | `typert` | [`invariants`](../packages/support/invariants) | | [`llm`](../packages/llm/llm) | `llm` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`timeout`](../packages/util/timeout) | @@ -1175,7 +1184,7 @@ flowchart TD | [`typert-loader`](../packages/typert/loader) | `typert` | [`invariants`](../packages/support/invariants), [`typert-registry`](../packages/typert/registry) | | [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | -| [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | +| [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`type-meta`](../packages/typert/type-meta) | | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`web`](../packages/web/web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`client-ui-models`](../packages/client/ui-models) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-schema-form`](../packages/client/schema-form), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) | @@ -1186,10 +1195,11 @@ flowchart TD | [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | +| [`host-api-gateway`](../packages/host/api-gateway) | `host` | [`client-connection`](../packages/client/connection), [`invariants`](../packages/support/invariants), [`typert-registry`](../packages/typert/registry) | | [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`settings-local`](../packages/settings/settings-local) | `settings` | [`atomic-write`](../packages/util/atomic-write), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`settings`](../packages/settings/settings) | -| [`agent`](../packages/core/agent) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | +| [`agent`](../packages/core/agent) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`type-meta`](../packages/typert/type-meta) | | [`bash`](../packages/bash/bash) | `bash` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`subprocess`](../packages/subprocess/subprocess) | | [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`compact`](../packages/compact/compact) | `compact` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | diff --git a/package.json b/package.json index 7bd84db93a..9d0cac6d5e 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,10 @@ ], "scripts": { "build": "npm run build:lib && npm run build:web", - "build:lib": "tsc -b && tsdown", + "build:lib": "npm run build:lib:host && npm run build:lib:client", + "build:lib:host": "npm run build:lib:contracts && tsc -b tsconfig.host.json", + "build:lib:contracts": "tsc -b packages/typert/generator && tsdown --config tsdown.typert-host.config.ts", + "build:lib:client": "tsc -b tsconfig.client.json && tsdown", "build:web": "pnpm --filter @deepseek-ai/dsh-frontend run build", "clean": "tsx scripts/clean.ts", "change-scope": "tsx scripts/change-scope.ts", diff --git a/packages/bundle/base/cordis.patch.yml b/packages/bundle/base/cordis.patch.yml index f0577552cc..0b1cc43a50 100644 --- a/packages/bundle/base/cordis.patch.yml +++ b/packages/bundle/base/cordis.patch.yml @@ -34,6 +34,15 @@ - id: session name: '@deepseek-ai/dsh-session' + - id: typert + name: '@deepseek-ai/dsh-typert-registry' + + - id: typert-loader + name: '@deepseek-ai/dsh-typert-loader' + + - id: typert-gateway + name: '@deepseek-ai/dsh-host-api-gateway' + - 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 c6519171ca..2ec17d9c66 100644 --- a/packages/bundle/base/package.json +++ b/packages/bundle/base/package.json @@ -49,6 +49,7 @@ "@deepseek-ai/dsh-fs-sandbox": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-goal-session": "workspace:^", + "@deepseek-ai/dsh-host-api-gateway": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", "@deepseek-ai/dsh-llm-pi-ai": "workspace:^", @@ -95,6 +96,8 @@ "@deepseek-ai/dsh-tool-web": "workspace:^", "@deepseek-ai/dsh-tool-workflow": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/dsh-typert-loader": "workspace:^", + "@deepseek-ai/dsh-typert-registry": "workspace:^", "@deepseek-ai/dsh-user-approval": "workspace:^", "@deepseek-ai/dsh-user-interaction": "workspace:^", "@deepseek-ai/dsh-web": "workspace:^", diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index 67b47b06c6..521e54160e 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -8,7 +8,9 @@ import type { IApiClient } from './api.ts' import { ConnectionController, type ConnectionConfig, type ConnectionSinks, type ConnectionState } from './connection.ts' import { FixtureApiClient } from './fixture.ts' import { WebApiClient } from './web-api-client.ts' +import { createUnavailableConnectionRpc, createWebConnectionRpc } from './rpc.ts' import { isLoopbackHostname } from '../loopback-hostname.ts' +import type { ClientConnectionRpc } from '../rpc.ts' // ---- Contract re-exports (browser-safe apiproxy channels + core types) ---- export type { @@ -36,6 +38,7 @@ export { // Connection loop types are public through ConnectionHandle.start; the // controller remains package-internal. export type { ConnectionConfig, ConnectionSinks, ConnectionState } +export type { ClientConnectionRpc } from '../rpc.ts' /** Required services (none — this is the wire root). */ @@ -51,6 +54,8 @@ export interface ConnectionHandle { readonly api: IApiClient /** Whether the current page authority is loopback; non-browser contexts default to true. */ readonly isLoopback: boolean + /** Generic logical RPC channels over the same Connection transport. */ + readonly rpc: ClientConnectionRpc /** * Start the connect/pump/reconnect loop with the consumer's frame sinks. * One consumer owns the streams (the runtime object layer); a second call @@ -70,10 +75,12 @@ export function apply(ctx: Context): void { const pageLocation = typeof location === 'undefined' ? undefined : location const fixture = pageLocation !== undefined && new URLSearchParams(pageLocation.search).has('fixture') const api: IApiClient = fixture ? new FixtureApiClient() : new WebApiClient() + const rpc = fixture ? createUnavailableConnectionRpc() : createWebConnectionRpc() let started = false const handle: ConnectionHandle = { api, isLoopback: pageLocation === undefined || isLoopbackHostname(pageLocation.hostname), + rpc, start(sinks, config) { if (started) throw new Error('connection: the stream loop is already owned by another consumer') started = true diff --git a/packages/client/connection/src/client/rpc.ts b/packages/client/connection/src/client/rpc.ts new file mode 100644 index 0000000000..36e16426b2 --- /dev/null +++ b/packages/client/connection/src/client/rpc.ts @@ -0,0 +1,75 @@ +/** Browser caller for generic Connection unary RPC channels. */ + +import { + RpcId, + serverResponseSchema, + type ClientRequest, +} from '@deepseek-ai/dsh-host-apiproxy/api' +import type { ClientConnectionRpc } from '../rpc.ts' + +const INTERNAL_BASE = 'http://dsh.internal' +const CHANNEL_PATTERN = /^\/[A-Za-z0-9._~-]+$/ +const ENDPOINT_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/ + +/** + * Create the browser-backed generic RPC caller. + * @returns caller that owns request correlation and response-envelope validation. + */ +export function createWebConnectionRpc(): ClientConnectionRpc { + return { + async call(channel, endpoint, payload, signal) { + assertTarget(channel, endpoint) + const rpcId = RpcId(crypto.randomUUID()) + const message: ClientRequest = { + type: 'client-request', + rpcId, + method: endpoint, + payload, + } + const response = await globalThis.fetch( + new URL(`${channel}/${endpoint}`, resolveBase()), + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(message), + ...signal === undefined ? {} : { signal }, + }, + ) + if (!response.ok) { + throw new Error(`transport failure for ${channel}/${endpoint}: HTTP ${response.status}`) + } + const full = serverResponseSchema.parse(await response.json()) + if (full.rpcId !== rpcId) { + throw new Error(`rpcId mismatch for ${endpoint}: sent ${rpcId}, got ${full.rpcId}`) + } + return full.result + }, + } +} + +/** + * Create the fixture-mode caller, where no Host Remote registry exists. + * @returns caller that rejects every generic Remote invocation. + */ +export function createUnavailableConnectionRpc(): ClientConnectionRpc { + return { + call(channel, endpoint) { + return Promise.reject(new Error(`connection RPC ${channel}/${endpoint} is unavailable in fixture mode`)) + }, + } +} + +function resolveBase(): string { + const location = (globalThis as { location?: { origin?: string } }).location + return location?.origin !== undefined && location.origin !== 'null' ? location.origin : INTERNAL_BASE +} + +function assertTarget(channel: string, endpoint: string): void { + const segments = endpoint.split('/') + if (!CHANNEL_PATTERN.test(channel) + || segments.length === 0 + || segments.some(segment => + segment === '' || segment === '.' || segment === '..' || !ENDPOINT_SEGMENT_PATTERN.test(segment))) { + throw new Error(`connection: invalid RPC target ${JSON.stringify(`${channel}/${endpoint}`)}`) + } +} diff --git a/packages/client/connection/src/index.ts b/packages/client/connection/src/index.ts index 2e27a78d70..d8b6ef8846 100644 --- a/packages/client/connection/src/index.ts +++ b/packages/client/connection/src/index.ts @@ -7,15 +7,25 @@ import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy' import { API_PATH, HOST_EVENTS_PATH, MUX_EVENTS_PATH } from './api-path.ts' import { bridge } from './http-bridge.ts' import { assertTrustedAuthority, isTrustedApiRequest } from './api-request-trust.ts' +import { HostConnectionService } from './rpc-host.ts' import { rejectWebSocketUpgrade, WebSocketDownlinks } from './websocket-downlink.ts' +export type { + ConnectionRpcAuthority, + ConnectionRpcHandler, + ConnectionRpcHandlerOptions, + HostConnectionHandle, + HostConnectionRpc, +} from './rpc.ts' +export { HostConnectionService } from './rpc-host.ts' + export { API_PATH, HOST_EVENTS_PATH, MUX_EVENTS_PATH } from './api-path.ts' /** Stable Cordis plugin name. */ export const name = 'client-connection' -/** Services required before mounting the route. */ -export const inject = ['httpServer', 'apiProxy'] +/** Services required before providing Connection; legacy `/api` attaches when apiProxy is present. */ +export const inject = ['httpServer'] /** Plugin config: the deployment's non-loopback serving authorities. */ export interface ConnectionConfig { @@ -83,49 +93,52 @@ export function apply(ctx: Context, config?: ConnectionConfig): void { // Config boundary: a malformed entry fails the load loudly here rather than // silently authorizing its hostname prefix at request time. for (const entry of trustedHosts) assertTrustedAuthority(entry) - const apiHandler = toFetchHandler(ctx.apiProxy) - const downlinks = new WebSocketDownlinks(ctx.apiProxy) - const route: WebRoute = { - kind: 'prefix', - path: API_PATH, - handler: async (req, res) => { - const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname - const method = pathname.startsWith(`${API_PATH}/`) - ? pathname.slice(API_PATH.length + 1) - : undefined - const allowed = method !== undefined && PRIVILEGED_METHODS.has(method) - ? isTrustedApiRequest(req, []) - : isTrustedApiRequest(req, trustedHosts) - if (!allowed) { - res.writeHead(403) - res.end('forbidden') - return - } - if (req.method === 'GET' && (pathname === MUX_EVENTS_PATH || pathname === HOST_EVENTS_PATH)) { - res.writeHead(426, { connection: 'Upgrade', upgrade: 'websocket' }) - res.end('upgrade required') - return - } - await bridge(req, res, apiHandler) - }, - } - ctx.effect(() => ctx.httpServer.register(route), 'client-connection: /api route') - const registerDownlink = ( - path: string, - handle: WebUpgradeRoute['handler'], - ): void => { - ctx.effect(() => ctx.httpServer.registerUpgrade({ - path, - handler: (req, socket, head) => { - if (!isTrustedApiRequest(req, trustedHosts)) { - rejectWebSocketUpgrade(socket) + new HostConnectionService(ctx, trustedHosts) + ctx.inject(['apiProxy'], (apiCtx) => { + const apiHandler = toFetchHandler(apiCtx.apiProxy) + const downlinks = new WebSocketDownlinks(apiCtx.apiProxy) + const route: WebRoute = { + kind: 'prefix', + path: API_PATH, + handler: async (req, res) => { + const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname + const method = pathname.startsWith(`${API_PATH}/`) + ? pathname.slice(API_PATH.length + 1) + : undefined + const allowed = method !== undefined && PRIVILEGED_METHODS.has(method) + ? isTrustedApiRequest(req, []) + : isTrustedApiRequest(req, trustedHosts) + if (!allowed) { + res.writeHead(403) + res.end('forbidden') return } - return handle(req, socket, head) + if (req.method === 'GET' && (pathname === MUX_EVENTS_PATH || pathname === HOST_EVENTS_PATH)) { + res.writeHead(426, { connection: 'Upgrade', upgrade: 'websocket' }) + res.end('upgrade required') + return + } + await bridge(req, res, apiHandler) }, - }), `client-connection: ${path} WebSocket`) - } - ctx.effect(() => () => downlinks.close(), 'client-connection: WebSocket downlinks') - registerDownlink(MUX_EVENTS_PATH, (req, socket, head) => { downlinks.handleMux(req, socket, head) }) - registerDownlink(HOST_EVENTS_PATH, (req, socket, head) => { downlinks.handleHost(req, socket, head) }) + } + apiCtx.effect(() => apiCtx.httpServer.register(route), 'client-connection: /api route') + const registerDownlink = ( + path: string, + handle: WebUpgradeRoute['handler'], + ): void => { + apiCtx.effect(() => apiCtx.httpServer.registerUpgrade({ + path, + handler: (req, socket, head) => { + if (!isTrustedApiRequest(req, trustedHosts)) { + rejectWebSocketUpgrade(socket) + return + } + return handle(req, socket, head) + }, + }), `client-connection: ${path} WebSocket`) + } + apiCtx.effect(() => () => downlinks.close(), 'client-connection: WebSocket downlinks') + registerDownlink(MUX_EVENTS_PATH, (req, socket, head) => { downlinks.handleMux(req, socket, head) }) + registerDownlink(HOST_EVENTS_PATH, (req, socket, head) => { downlinks.handleHost(req, socket, head) }) + }) } diff --git a/packages/client/connection/src/rpc-host.ts b/packages/client/connection/src/rpc-host.ts new file mode 100644 index 0000000000..be9eedca8f --- /dev/null +++ b/packages/client/connection/src/rpc-host.ts @@ -0,0 +1,150 @@ +/** Host registry and HTTP adapter for generic Connection RPC channels. */ + +import { Context, Service } from 'cordis' +import type { WebRoute } from '@deepseek-ai/dsh-host-webserver' +import { + clientRequestSchema, + RpcId, + type ClientRequest, + type RpcError, + type RpcId as RpcIdType, + type ServerResponse as RpcServerResponse, +} from '@deepseek-ai/dsh-host-apiproxy/api' +import { bridge } from './http-bridge.ts' +import { isTrustedApiRequest } from './api-request-trust.ts' +import type { + ConnectionRpcHandler, + ConnectionRpcHandlerOptions, + HostConnectionHandle, + HostConnectionRpc, +} from './rpc.ts' + +const INVALID_REQUEST_RPC_ID = RpcId('invalid-request') +const CHANNEL_PATTERN = /^\/[A-Za-z0-9._~-]+$/ +const ENDPOINT_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/ + +/** Host Connection service whose channel registrations belong to the caller fiber. */ +export class HostConnectionService extends Service implements HostConnectionHandle { + /** + * Provide the Host half over the active HTTP server. + * @param ctx - owning Connection plugin context. + * @param trustedHosts - deployment authorities accepted by trusted-host channels. + */ + constructor(ctx: Context, private readonly trustedHosts: readonly string[]) { + super(ctx, 'connection') + } + + /** Generic channel registry scoped to the Context reading this service. */ + get rpc(): HostConnectionRpc { + const owner = this.ctx + return { + handle: (channel, handler, options) => this.register(owner, channel, handler, options), + } + } + + private register( + owner: Context, + channel: string, + handler: ConnectionRpcHandler, + options: ConnectionRpcHandlerOptions, + ): () => Promise { + assertChannel(channel) + const trustedHosts = options.authority === 'loopback' ? [] : this.trustedHosts + const fetchHandler = rpcFetchHandler(channel, handler) + const route: WebRoute = { + kind: 'prefix', + path: channel, + handler: async (req, res) => { + if (!isTrustedApiRequest(req, trustedHosts)) { + res.writeHead(403) + res.end('forbidden') + return + } + await bridge(req, res, fetchHandler) + }, + } + return owner.effect( + () => owner.httpServer.register(route), + `client-connection: ${channel} rpc channel`, + ) + } +} + +function rpcFetchHandler( + channel: string, + handler: ConnectionRpcHandler, +): { fetch: typeof fetch } { + return { + async fetch(input: RequestInfo | URL, init?: RequestInit): Promise { + const request = input instanceof Request ? input : new Request(input, init) + const endpoint = endpointFromPath(channel, new URL(request.url).pathname) + if (request.method !== 'POST' || endpoint === undefined) { + return new Response('not found', { status: 404 }) + } + + const mediaType = request.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase() + if (mediaType !== 'application/json') { + return new Response('content type must be application/json', { status: 415 }) + } + + let body: unknown + try { + body = await request.json() + } catch { + return new Response('body is not JSON', { status: 400 }) + } + + const envelope = clientRequestSchema.safeParse(body) + if (!envelope.success) { + const rawId = (body as { rpcId?: unknown } | null)?.rpcId + const rpcId = typeof rawId === 'string' ? RpcId(rawId) : INVALID_REQUEST_RPC_ID + return errorResponse(rpcId, { + code: 'bad-request', + message: 'invalid client-request message', + details: { issues: envelope.error.issues }, + }) + } + const message: ClientRequest = envelope.data + if (message.method !== endpoint) { + return errorResponse(message.rpcId, { + code: 'bad-request', + message: `method ${JSON.stringify(message.method)} does not match endpoint ${JSON.stringify(endpoint)}`, + details: { issues: [] }, + }) + } + + try { + const result = await handler(endpoint, message.payload, request.signal) + return fullResponse(message.rpcId, result) + } catch (error) { + return new Response(`handler failure: ${String(error)}`, { status: 500 }) + } + }, + } +} + +function endpointFromPath(channel: string, pathname: string): string | undefined { + if (!pathname.startsWith(`${channel}/`)) return undefined + const endpoint = pathname.slice(channel.length + 1) + const segments = endpoint.split('/') + if (segments.length === 0 || segments.some(segment => + segment === '' || segment === '.' || segment === '..' || !ENDPOINT_SEGMENT_PATTERN.test(segment))) { + return undefined + } + return endpoint +} + +function errorResponse(rpcId: RpcIdType, error: RpcError): Response { + return fullResponse(rpcId, { ok: false, error }) +} + +function fullResponse(rpcId: RpcIdType, result: RpcServerResponse['result']): Response { + const body: RpcServerResponse = { type: 'server-response', rpcId, result } + return Response.json(body) +} + +function assertChannel(channel: string): void { + if (!CHANNEL_PATTERN.test(channel) || channel === '/api') { + throw new Error(`connection: invalid or reserved RPC channel ${JSON.stringify(channel)}`) + } +} diff --git a/packages/client/connection/src/rpc.ts b/packages/client/connection/src/rpc.ts new file mode 100644 index 0000000000..ab68783724 --- /dev/null +++ b/packages/client/connection/src/rpc.ts @@ -0,0 +1,59 @@ +/** Generic unary RPC contracts shared by the Host and Client Connection halves. */ + +import type { RpcResult } from '@deepseek-ai/dsh-host-apiproxy/api' + +/** Trust fence applied before a Host RPC channel reaches its handler. */ +export type ConnectionRpcAuthority = 'trusted-host' | 'loopback' + +/** Registration policy for one logical RPC channel. */ +export interface ConnectionRpcHandlerOptions { + /** Browser authority accepted by every endpoint in this channel. */ + readonly authority: ConnectionRpcAuthority +} + +/** Handler invoked after Connection has decoded the transport envelope. */ +export type ConnectionRpcHandler = ( + endpoint: string, + payload: unknown, + signal: AbortSignal, +) => Promise> + +/** Host registry for logical RPC channels carried by the current transport. */ +export interface HostConnectionRpc { + /** + * Register one absolute channel prefix and its trust policy. + * @param channel - absolute logical channel such as `/api2`. + * @param handler - decoded endpoint handler returning the existing RPC result shape. + * @param options - channel trust policy. + * @returns asynchronous disposer removing the channel and its physical route. + */ + handle( + channel: string, + handler: ConnectionRpcHandler, + options: ConnectionRpcHandlerOptions, + ): () => Promise +} + +/** Host `ctx.connection` shape consumed by transport-independent adapters. */ +export interface HostConnectionHandle { + /** Generic RPC channel registry. */ + readonly rpc: HostConnectionRpc +} + +/** Client caller for logical RPC channels carried by the current transport. */ +export interface ClientConnectionRpc { + /** + * Call one endpoint through an already registered logical channel. + * @param channel - absolute logical channel such as `/api2`. + * @param endpoint - channel-relative endpoint such as `goals/create`. + * @param payload - channel-owned request payload. + * @param signal - optional caller cancellation. + * @returns the existing RPC success/error result; correlation stays inside Connection. + */ + call( + channel: string, + endpoint: string, + payload: unknown, + signal?: AbortSignal, + ): Promise> +} diff --git a/packages/client/connection/tests/client-apply.spec.ts b/packages/client/connection/tests/client-apply.spec.ts index 524983fb4f..d93844a2b8 100644 --- a/packages/client/connection/tests/client-apply.spec.ts +++ b/packages/client/connection/tests/client-apply.spec.ts @@ -203,4 +203,41 @@ describe('connection client apply', () => { expect(sockets).toHaveLength(1) expect(sockets[0]?.readyState).toBe(FakeWebSocket.CLOSED) }) + + it('carries generic RPC calls over the isolated channel with rpcId echo validation', async () => { + ;(globalThis as Win).location = { hostname: 'localhost', search: '' } + const handle = await mount() + const original = globalThis.fetch + const seen: { url: string; body: unknown }[] = [] + globalThis.fetch = async (input: URL | RequestInfo, init?: RequestInit) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url + if (typeof init?.body !== 'string') throw new TypeError('expected a JSON string request body') + const body = JSON.parse(init.body) as { rpcId: string } + seen.push({ url, body }) + return Response.json({ + type: 'server-response', + rpcId: body.rpcId, + result: { ok: true, value: { ref: 'goal-1' } }, + }) + } + try { + await expect(handle.rpc.call('/api2', 'goals/create', { args: { agentId: 'agent-1' } })) + .resolves.toEqual({ ok: true, value: { ref: 'goal-1' } }) + } finally { + globalThis.fetch = original + } + expect(seen).toHaveLength(1) + expect(seen[0]?.url).toBe('http://dsh.internal/api2/goals/create') + expect(seen[0]?.body).toMatchObject({ + type: 'client-request', + method: 'goals/create', + payload: { args: { agentId: 'agent-1' } }, + }) + }) + + it('keeps generic Remote calls unavailable in the client-only fixture', async () => { + ;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' } + const handle = await mount() + await expect(handle.rpc.call('/api2', 'goals/create', {})).rejects.toThrow(/unavailable in fixture mode/) + }) }) diff --git a/packages/client/connection/tests/node-half.spec.ts b/packages/client/connection/tests/node-half.spec.ts index 3015881d2f..af85d4e510 100644 --- a/packages/client/connection/tests/node-half.spec.ts +++ b/packages/client/connection/tests/node-half.spec.ts @@ -7,8 +7,9 @@ import { describe, expect, it } from 'vitest' import type { AddressInfo } from 'node:net' import type { IncomingMessage, ServerResponse } from 'node:http' import type { ApiProxy } from '@deepseek-ai/dsh-host-apiproxy/api' +import { RpcId, type ClientRequest } from '@deepseek-ai/dsh-host-apiproxy/api' import type { HttpServerService, WebRoute, WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver' -import { API_PATH, apply, HOST_EVENTS_PATH, inject, MUX_EVENTS_PATH } from '../src/index.ts' +import { API_PATH, apply, HOST_EVENTS_PATH, inject, MUX_EVENTS_PATH, type HostConnectionHandle } from '../src/index.ts' /** Structural httpServer fake recording both route registries. */ function fakeHttpServer( @@ -17,6 +18,9 @@ function fakeHttpServer( ): Pick { return { register(route) { + if (routes.some(candidate => candidate.kind === route.kind && candidate.path === route.path)) { + throw new Error(`duplicate route ${route.path}`) + } routes.push(route) return () => { routes.splice(routes.indexOf(route), 1) } }, @@ -36,15 +40,25 @@ function fakeRequest(headers: Record, url = `${API_PATH}/session return request } +/** JSON POST carrying a complete client-request envelope. */ +function fakePost(headers: Record, url: string, body: unknown): IncomingMessage { + const request = Readable.from([Buffer.from(JSON.stringify(body))]) as unknown as IncomingMessage + Object.assign(request, { url, method: 'POST', headers: { 'content-type': 'application/json', ...headers } }) + return request +} + /** Response recorder compatible with both the fence's short-circuit and the bridge. */ function fakeResponse(): { response: ServerResponse; state: { status?: number; body?: unknown } } { const state: { status?: number; body?: unknown } = {} + const chunks: Buffer[] = [] const response = Object.assign(new EventEmitter(), { writableEnded: false, writeHead(value: number) { state.status = value; return this }, - write() { return true }, + write(value: string | Uint8Array) { chunks.push(Buffer.from(value)); return true }, end(this: { writableEnded: boolean }, value?: unknown) { - if (value !== undefined) state.body = value + if (typeof value === 'string' || value instanceof Uint8Array) chunks.push(Buffer.from(value)) + else if (value !== undefined) throw new TypeError('fake response only accepts string or Uint8Array bodies') + if (chunks.length > 0) state.body = Buffer.concat(chunks).toString() this.writableEnded = true return this }, @@ -173,6 +187,78 @@ describe('connection node half', () => { expect(declared.state.status).toBe(404) await dispose() }) + + it('provides a disposable generic RPC channel without requiring apiProxy', async () => { + const ctx = new Context() + const routes: WebRoute[] = [] + ctx.provide('httpServer', fakeHttpServer(routes, []) as HttpServerService) + const fiber = ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + expect(routes).toHaveLength(0) + + const connection = ctx.get('connection') as HostConnectionHandle + const calls: unknown[] = [] + const remove = connection.rpc.handle('/api2', async (endpoint, payload) => { + calls.push({ endpoint, payload }) + return { ok: true, value: { accepted: true } } + }, { authority: 'trusted-host' }) + const route = routes.find(candidate => candidate.path === '/api2') + expect(route).toBeDefined() + + const request: ClientRequest = { + type: 'client-request', + rpcId: RpcId('rpc-api2'), + method: 'goals/create', + payload: { args: { agentId: 'agent-1' } }, + } + const result = fakeResponse() + await route!.handler(fakePost({ host: '127.0.0.1:3080' }, '/api2/goals/create', request), result.response) + expect(result.state.status).toBe(200) + expect(JSON.parse(String(result.state.body))).toEqual({ + type: 'server-response', + rpcId: 'rpc-api2', + result: { ok: true, value: { accepted: true } }, + }) + expect(calls).toEqual([{ + endpoint: 'goals/create', + payload: { args: { agentId: 'agent-1' } }, + }]) + + expect(() => connection.rpc.handle('/api2', async () => ({ ok: true, value: null }), { + authority: 'trusted-host', + })).toThrow(/duplicate route/) + await remove() + expect(routes).toHaveLength(0) + await fiber.dispose() + }) + + it('applies the configured trust fence and JSON envelope checks to generic channels', async () => { + const ctx = new Context() + const routes: WebRoute[] = [] + ctx.provide('httpServer', fakeHttpServer(routes, []) as HttpServerService) + const fiber = ctx.plugin({ inject: [...inject], apply }, { trustedHosts: ['harness.example'] }) + await fiber.await() + const connection = ctx.get('connection') as HostConnectionHandle + const remove = connection.rpc.handle('/api2', async () => ({ ok: true, value: null }), { + authority: 'trusted-host', + }) + const route = routes[0]! + + const denied = fakeResponse() + await route.handler(fakePost({ host: 'other.example' }, '/api2/goals/create', {}), denied.response) + expect(denied.state).toMatchObject({ status: 403, body: 'forbidden' }) + + const badEnvelope = fakeResponse() + await route.handler(fakePost({ host: 'harness.example' }, '/api2/goals/create', { + type: 'client-request', rpcId: 'rpc-bad', method: 'other', payload: {}, + }), badEnvelope.response) + expect(JSON.parse(String(badEnvelope.state.body))).toMatchObject({ + rpcId: 'rpc-bad', + result: { ok: false, error: { code: 'bad-request' } }, + }) + await remove() + await fiber.dispose() + }) }) describe('connection node half over a real HTTP server', () => { diff --git a/packages/client/runtime/tests/client-apply.spec.ts b/packages/client/runtime/tests/client-apply.spec.ts index e5691a0619..14e51fae8e 100644 --- a/packages/client/runtime/tests/client-apply.spec.ts +++ b/packages/client/runtime/tests/client-apply.spec.ts @@ -27,6 +27,9 @@ async function mount(): Promise { const handle: ConnectionHandle = { api, isLoopback: true, + rpc: { + call: () => Promise.reject(new Error('unexpected generic RPC call')), + }, start: (sinks) => { bench.sinks = sinks return { stop: () => { bench.stopped += 1 } } diff --git a/packages/client/runtime/tests/wire-events.spec.ts b/packages/client/runtime/tests/wire-events.spec.ts index 21e7f1fc06..f081eb54c1 100644 --- a/packages/client/runtime/tests/wire-events.spec.ts +++ b/packages/client/runtime/tests/wire-events.spec.ts @@ -21,6 +21,9 @@ async function mount(): Promise { const handle: ConnectionHandle = { api, isLoopback: true, + rpc: { + call: () => Promise.reject(new Error('unexpected generic RPC call')), + }, start: (sinks) => { bench.sinks = sinks return { stop: () => {} } diff --git a/packages/client/tsdown.client.ts b/packages/client/tsdown.client.ts index 1e45991080..74facbd69b 100644 --- a/packages/client/tsdown.client.ts +++ b/packages/client/tsdown.client.ts @@ -31,6 +31,9 @@ const CSS_VIRTUAL_SUFFIX = '.mjs' */ export const INLINE_SAFE = /^@deepseek-ai\/dsh-(host-apiproxy|session|llm|tools|brand)(\/|$)/ +/** Generated descriptor/codec contribution with no shared runtime identity. */ +const GENERATED_REMOTE = /^@deepseek-ai\/dsh-[a-z0-9]+(?:-[a-z0-9]+)*\/remote$/ + /** * Documented TEMPORARY exemption, not a platform module (hence not in * platform.ts): the snapshot-store engine (createSnapshotStore/defineStore/ @@ -126,9 +129,9 @@ export function clientBundle(id: string, libEntry: readonly string[]): [UserConf resolveId(source: string) { if (!source.startsWith('@deepseek-ai/')) return null if (CLIENT_EXTERNALS.includes(source)) return null // platform module: external wins - if (INLINE_SAFE.test(source)) return null // wire/type layer: inline is the point + if (INLINE_SAFE.test(source) || GENERATED_REMOTE.test(source)) return null // wire contribution: inline is the point throw new Error( - `client bundle purity: "${source}" is not a platform module (CLIENT_EXTERNALS) and not an inline-safe wire layer — ` + `client bundle purity: "${source}" is not a platform module (CLIENT_EXTERNALS), an inline-safe wire layer, or a generated /remote contribution — ` + 'cross-plugin value imports are forbidden; collaborate through cordis services (type-only imports are erased and never reach this gate)', ) }, diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 2bf1c1b2d9..b8da6049e8 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -1118,11 +1118,11 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { key: 'typert', - summary: 'Registry of generated schemas and package reflection.', + summary: 'Registry of generated schemas, package reflection, invocations, and Remote dependency providers.', methods: [ { - signature: 'register(contribution: TypertContribution): () => void', - jsDoc: '/**\n * Register one generated contribution atomically for the calling fiber.\n * Duplicate package-face identities or schema keys reject the whole batch.\n * @param contribution - generated schemas and package metadata.\n * @returns the exact effect disposer that removes this contribution.\n */', + signature: 'register(contribution: TypertContribution): TypeRTDisposer', + jsDoc: '/**\n * Register one generated contribution atomically for the calling fiber.\n * Duplicate package-face identities, schemas, invocation ids, or endpoints\n * reject the whole batch.\n * @param contribution - generated schemas, reflection, and Host invocations.\n * @returns the exact effect disposer that removes this contribution.\n */', }, { signature: 'get(key: string): TypertSchemaRecord | undefined', @@ -1150,6 +1150,16 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, + { + key: 'typertGateway', + summary: 'Resolve strict generated definitions or conservative SRC markers against current Cordis Services and TypeRT providers.', + methods: [ + { + signature: 'async invoke(request: InvokeRemoteRequest): Promise', + jsDoc: '/**\n * Invoke one live Remote method through strict generated reflection or SRC markers.\n * @param request - decoded endpoint and exact named wire arguments.\n * @returns the validated business result.\n * @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; business errors retain their identity.\n */', + }, + ], + }, { key: 'userInteraction', summary: '`ctx.userInteraction`: one active UI provider plus an `ask()` surface.', @@ -2057,6 +2067,22 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'InvariantInstaller', declaration: 'export interface InvariantInstaller {\n (ctx: Context, fail: InvariantFailure): void | Promise;\n readonly inject?: Inject;\n}', }, + { + name: 'InvocationDescriptor', + declaration: 'export interface InvocationDescriptor {\n readonly id: string;\n readonly service: string;\n readonly namespace: string;\n readonly method: string;\n readonly implementation?: string;\n readonly invocation: {\n readonly kind: \'direct\';\n } | {\n readonly kind: \'context\';\n readonly context: string;\n readonly wire: string;\n readonly codec: TypeRTCodec;\n };\n readonly scope?: {\n readonly context: string;\n readonly wire: string;\n };\n readonly parameters: readonly InvocationParameterDescriptor[];\n readonly result: TypeRTCodec;\n readonly sourceLocation?: InvocationSourceLocation;\n}', + }, + { + name: 'InvocationParameterDescriptor', + declaration: 'export interface InvocationParameterDescriptor {\n readonly name: string;\n readonly wire: string;\n readonly source: \'json\' | \'lookup\';\n readonly lookup?: string;\n readonly codec: TypeRTCodec;\n}', + }, + { + name: 'InvocationSourceLocation', + declaration: 'export interface InvocationSourceLocation {\n readonly file: string;\n readonly line: number;\n readonly column: number;\n}', + }, + { + name: 'InvokeRemoteRequest', + declaration: 'export interface InvokeRemoteRequest {\n readonly namespace: string;\n readonly method: string;\n readonly args: Readonly>;\n}', + }, { name: 'JsonSchemaNode', declaration: 'export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n}', @@ -3037,9 +3063,17 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'TurnEndReasonMap', declaration: 'export interface TurnEndReasonMap {\n completed: {\n kind: \'completed\';\n };\n aborted: {\n kind: \'aborted\';\n reason: TurnEndCancelCause;\n };\n blocked: {\n kind: \'blocked\';\n };\n error: {\n kind: \'error\';\n error: LlmFailure;\n };\n \'max-tokens\': {\n kind: \'max-tokens\';\n };\n interrupted: {\n kind: \'interrupted\';\n };\n}', }, + { + name: 'TypeRTCodec', + declaration: 'export type TypeRTCodec = {\n readonly mode: \'strict\';\n readonly typeSymbol: string;\n readonly schema: TypeRTSchema;\n} | {\n readonly mode: \'src-json\';\n};', + }, { name: 'TypertContribution', - declaration: 'export interface TypertContribution {\n readonly package: string;\n readonly face: TypertFace;\n readonly schemas: readonly TypertSchema[];\n readonly model: TypertPackageModel;\n}', + declaration: 'export interface TypertContribution {\n readonly package: string;\n readonly face: TypertFace;\n readonly schemas: readonly TypertSchema[];\n readonly model: TypertPackageModel;\n readonly invocations?: readonly InvocationDescriptor[];\n}', + }, + { + name: 'TypeRTDisposer', + declaration: 'export type TypeRTDisposer = () => Promise;', }, { name: 'TypertDocTag', @@ -3077,6 +3111,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'TypertSchema', declaration: 'export interface TypertSchema {\n readonly name: string;\n readonly schema: z.ZodType;\n}', }, + { + name: 'TypeRTSchema', + declaration: 'export interface TypeRTSchema {\n parse(value: unknown): Output;\n}', + }, { name: 'TypertSchemaFilter', declaration: 'export interface TypertSchemaFilter {\n readonly package?: string;\n readonly face?: TypertFace;\n}', diff --git a/packages/core/agent/package.json b/packages/core/agent/package.json index 2e204bc7f0..9f64d33e75 100644 --- a/packages/core/agent/package.json +++ b/packages/core/agent/package.json @@ -15,12 +15,17 @@ "types": "./lib/types/invariant.d.ts", "default": "./lib/invariant.js" }, + "./types": { + "types": "./lib/types/types.d.ts", + "default": "./lib/types/types.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", "lib/invariant.js", + "lib/types/**/*.js", "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", @@ -30,6 +35,7 @@ "@deepseek-ai/dsh-scope": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-system-prompt": "^0.0.1", + "@deepseek-ai/dsh-type-meta": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { @@ -38,6 +44,8 @@ "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-type-meta": "workspace:^", + "@deepseek-ai/dsh-typert-registry": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index 55cb94d8f9..8f316f75dc 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -12,6 +12,7 @@ import { isPromise } from 'node:util/types' import { scopeTarget } from '@deepseek-ai/dsh-scope' import type { Scoped } from '@deepseek-ai/dsh-scope' import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' +import type { TypeRTContext, TypeRTLookup } from '@deepseek-ai/dsh-type-meta' import type { Agent, AgentOptions } from './types.ts' export * from './types.ts' @@ -20,6 +21,16 @@ export * from './llm-target.ts' export { agentCarrier, agentEvents, assembleContextFor, emitAgentEvent } from './dispatch.ts' export type { AgentEventDispatch, AgentSubjectEvent } from './dispatch.ts' +declare module '@deepseek-ai/dsh-type-meta' { + interface TypeRTLookupMap { + agent: TypeRTLookup + } + + interface TypeRTContextMap { + agent: TypeRTContext + } +} + declare module 'cordis' { interface Context { agents: AgentRegistry @@ -251,6 +262,20 @@ export class AgentRegistry extends Service { constructor(ctx: Context) { super(ctx, 'agents') + ctx.inject(['typert'], (typeCtx) => { + typeCtx.typert.lookups.register('agent', { + parameter: 'agent', + wire: 'agentId', + hostTypeSymbol: '@deepseek-ai/dsh-agent#Agent', + wireTypeSymbol: '@deepseek-ai/dsh-session/types#SessionId', + resolve: sessionId => this.get(sessionId), + }) + typeCtx.typert.contexts.registerHost('agent', { + wire: 'agentId', + wireTypeSymbol: '@deepseek-ai/dsh-session/types#SessionId', + resolve: sessionId => this.get(sessionId)?.ctx, + }) + }) // The `ctx.agent` DX accessor: default `undefined` on every context, so a // plain plugin context reads cleanly instead of hitting the Cordis // unknown-property throw. Each Agent.ctx shadows it with an own property diff --git a/packages/core/agent/tests/agent.spec.ts b/packages/core/agent/tests/agent.spec.ts index e80d575aeb..643a3a49a6 100644 --- a/packages/core/agent/tests/agent.spec.ts +++ b/packages/core/agent/tests/agent.spec.ts @@ -6,6 +6,7 @@ import AgentRegistry, { agentEvents, Inbox, } from '@deepseek-ai/dsh-agent' +import TypertRegistry from '@deepseek-ai/dsh-typert-registry' import type { Agent, @@ -142,6 +143,31 @@ describe('Inbox', () => { }) describe('AgentRegistry', () => { + it('contributes Agent lookup and scoped Context providers while TypeRT is live', async () => { + const ctx = new Context() + const agentFiber = ctx.plugin(AgentRegistry) + await agentFiber + await ctx.plugin(TypertRegistry) + const agent = stubAgent('remote-agent') + const disposeAgent = ctx.agents.register(agent) + + const lookup = ctx.typert.lookups.get('agent') + expect(lookup).toMatchObject({ + parameter: 'agent', + wire: 'agentId', + hostTypeSymbol: '@deepseek-ai/dsh-agent#Agent', + wireTypeSymbol: '@deepseek-ai/dsh-session/types#SessionId', + }) + expect(lookup?.resolve(agent.id)).toBe(agent) + expect(ctx.typert.contexts.getHost('agent')?.resolve(agent.id)).toBe(agent.ctx) + + disposeAgent() + expect(lookup?.resolve(agent.id)).toBeUndefined() + await agentFiber.dispose() + expect(ctx.typert.lookups.get('agent')).toBeUndefined() + expect(ctx.typert.contexts.getHost('agent')).toBeUndefined() + }) + it('registers exact entries, emits lifecycle events, and unregisters on owner disposal', async () => { const ctx = new Context() await ctx.plugin(AgentRegistry) diff --git a/packages/core/agent/tsconfig.json b/packages/core/agent/tsconfig.json index 1561175ed9..31d38b6017 100644 --- a/packages/core/agent/tsconfig.json +++ b/packages/core/agent/tsconfig.json @@ -28,6 +28,9 @@ }, { "path": "../../support/invariants" + }, + { + "path": "../../typert/type-meta" } ] } diff --git a/packages/core/session/package.json b/packages/core/session/package.json index 83be69528e..04aa221573 100644 --- a/packages/core/session/package.json +++ b/packages/core/session/package.json @@ -38,6 +38,7 @@ "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-scope": "^0.0.1", + "@deepseek-ai/dsh-type-meta": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { @@ -45,6 +46,8 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", + "@deepseek-ai/dsh-type-meta": "workspace:^", + "@deepseek-ai/dsh-typert-registry": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index d250998624..3f73242958 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -13,6 +13,7 @@ import { scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope' import type { Scoped } from '@deepseek-ai/dsh-scope' import type { Message } from '@deepseek-ai/dsh-llm' import { SESSION_FORMAT_VERSION, SessionId } from './types.ts' +import type { TypeRTLookup } from '@deepseek-ai/dsh-type-meta' import type { CreateSessionOptions, EpochHeader, PrepareSessionOptions, RequestContext, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceIntent, SurfaceEventType } from './types.ts' import { snapshotJsonValue } from './json.ts' import { deriveEventMessage, SurfaceManager } from './surface.ts' @@ -105,6 +106,12 @@ declare module 'cordis' { } } +declare module '@deepseek-ai/dsh-type-meta' { + interface TypeRTLookupMap { + session: TypeRTLookup + } +} + /** Validate and freeze one detached creation header in place. */ function validateSessionHeader(id: SessionId, input: unknown): SessionHeader { if (input === null || typeof input !== 'object' || Array.isArray(input)) { @@ -803,6 +810,15 @@ export class SessionStore extends Service { constructor(ctx: Context) { super(ctx, 'sessions') + ctx.inject(['typert'], (typeCtx) => { + typeCtx.typert.lookups.register('session', { + parameter: 'session', + wire: 'sessionId', + hostTypeSymbol: '@deepseek-ai/dsh-session#Session', + wireTypeSymbol: '@deepseek-ai/dsh-session/types#SessionId', + resolve: sessionId => this.get(sessionId), + }) + }) } /** diff --git a/packages/core/session/tests/typert.spec.ts b/packages/core/session/tests/typert.spec.ts new file mode 100644 index 0000000000..e1e2b32d68 --- /dev/null +++ b/packages/core/session/tests/typert.spec.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import TypertRegistry from '@deepseek-ai/dsh-typert-registry' + +describe('Session TypeRT provider', () => { + it('contributes live Session lookup in either service load order', async () => { + const ctx = new Context() + const sessionFiber = ctx.plugin(SessionStore) + await sessionFiber + await ctx.plugin(TypertRegistry) + const session = ctx.sessions.create(SessionId('remote-session')) + + const lookup = ctx.typert.lookups.get('session') + expect(lookup).toMatchObject({ + parameter: 'session', + wire: 'sessionId', + hostTypeSymbol: '@deepseek-ai/dsh-session#Session', + wireTypeSymbol: '@deepseek-ai/dsh-session/types#SessionId', + }) + expect(lookup?.resolve(session.id)).toBe(session) + + await sessionFiber.dispose() + expect(ctx.typert.lookups.get('session')).toBeUndefined() + }) +}) diff --git a/packages/core/session/tsconfig.json b/packages/core/session/tsconfig.json index 253a1c8793..076ff73d9f 100644 --- a/packages/core/session/tsconfig.json +++ b/packages/core/session/tsconfig.json @@ -25,6 +25,9 @@ }, { "path": "../../support/invariants" + }, + { + "path": "../../typert/type-meta" } ] } diff --git a/packages/host/api-gateway/README.i18n.yaml b/packages/host/api-gateway/README.i18n.yaml new file mode 100644 index 0000000000..2abe47e0d3 --- /dev/null +++ b/packages/host/api-gateway/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/host/api-gateway/README.md +README.md: 3ef926ace2ee4d6008b1d6c18b1e070fa39bc176 +README.zh.md: 77b8b8a87d5f511000aac5cf9f75ebca5fcdfbca diff --git a/packages/host/api-gateway/README.md b/packages/host/api-gateway/README.md new file mode 100644 index 0000000000..3ef926ace2 --- /dev/null +++ b/packages/host/api-gateway/README.md @@ -0,0 +1,36 @@ +# @deepseek-ai/dsh-host-api-gateway + +English | [中文](README.zh.md) + +Two-sided Remote control for Host and Client Cordis environments. The Host entry provides `ctx.typertGateway`, while `@deepseek-ai/dsh-host-api-gateway/client` provides `ctx.api`; both consume the same generated `InvocationDescriptor` contract and leave transport, request correlation, trust, and response envelopes to Connection. + +## Host service: `TypertGatewayService` (ctx key: `typertGateway`) + +`ctx.typertGateway.invoke()` resolves the current descriptor and Cordis Service for each call, validates exact named arguments, resolves registered object or Context identities, invokes the public business method, and validates its result. Business Services declare participation with `bindTypeRTGateway()` and `@Remote` or `@RemoteContext` from [`dsh-type-meta`](../../typert/type-meta/README.md). + +Strict mode reads generated invocation descriptors from `ctx.typert.local`. Lookup parameters use registered `ctx.typert.lookups` providers, while `@RemoteContext` resolves its receiver through a registered Host Context provider. SRC mode is a development fallback for endpoints that have never had a strict definition; it parses simple parameter names and accepts only JSON-safe values for non-lookup parameters. Withdrawing an observed strict definition fails instead of weakening validation. + +The Host entry registers the trusted-host `/api2` unary RPC channel when Connection is available. Direct `invoke()` calls preserve business errors; `TypertGatewayError` distinguishes failures owned by dispatch, binding, providers, lookup, Context, arguments, and codecs. + +## Client service: `ClientApi` (ctx key: `api`) + +`ctx.api.mount()` validates and registers a generated Host-for-Client contribution, then installs concrete direct and scoped methods for the calling Cordis fiber. Duplicate endpoints, namespace collisions, and descriptors without strict generated codecs fail before methods become callable. + +Each call validates positional inputs, constructs the descriptor's exact named `args`, and sends it through `ctx.connection.rpc.call('/api2', endpoint, ...)`. The returned value is validated before reaching application code. Withdrawing a contribution removes its descriptors and methods together, aborts in-flight calls, and makes retained method handles reject. + +Generated declaration merges provide the TypeScript API. The Client entry contains no Host Service or Host Cordis interface merge, and method lookup and invocation use ordinary objects and functions rather than a JavaScript Proxy. + +## Model Experience + +None, as the package dispatches application calls and registers no prompt, tool, or session event. + +#### KV Cache effect + +No direct effect; invoked business Services own any model-visible result. + +## Known Limitations and Deferred Work + +- The Connection adapter currently maps dispatch and business failures to the RPC `internal` code with empty details. Structured `TypertGatewayError` categories remain available only to same-process callers. +- SRC mode supports unique identifier parameters without destructuring, defaults, or rest parameters. It validates JSON safety rather than generated business types and never infers optional fields. +- Only strict generated contributions can mount on the Client face. SRC markers have no Client codec or type projection. +- The package dispatches unary methods only. Incremental Session data uses a separate named-stream protocol over the same Connection. diff --git a/packages/host/api-gateway/README.zh.md b/packages/host/api-gateway/README.zh.md new file mode 100644 index 0000000000..77b8b8a87d --- /dev/null +++ b/packages/host/api-gateway/README.zh.md @@ -0,0 +1,36 @@ +# @deepseek-ai/dsh-host-api-gateway + +[English](README.md) | 中文 + +为 Host 与 Client 两侧的 Cordis 环境提供 Remote 控制。Host 入口提供 `ctx.typertGateway`,`@deepseek-ai/dsh-host-api-gateway/client` 则提供 `ctx.api`;两者使用同一份生成的 `InvocationDescriptor` 契约,并将传输、请求关联、信任和响应封装交由 Connection 处理。 + +## Host 服务:`TypertGatewayService`(ctx key:`typertGateway`) + +每次调用时,`ctx.typertGateway.invoke()` 都会解析当前的描述符和 Cordis 服务,校验具名参数是否完全匹配,解析已注册的对象或 Context 身份标识,调用公开的业务方法,并校验其结果。业务服务调用 `bindTypeRTGateway()` 并使用 [`dsh-type-meta`](../../typert/type-meta/README.md) 提供的 `@Remote` 或 `@RemoteContext` 装饰器,以显式声明接入。 + +严格模式从 `ctx.typert.local` 读取生成的调用描述符。查找参数使用已向 `ctx.typert.lookups` 注册的提供方,`@RemoteContext` 则通过已注册的 Host Context 提供方解析其接收者。SRC 模式是开发阶段的回退路径,适用于从未具备严格定义的端点;它解析简单参数名,并且只允许非查找参数使用可安全表示为 JSON 的值。已观测到的严格定义一旦撤回,系统会直接报错,而不会降低校验强度。 + +Connection 可用时,Host 入口会注册 trusted-host 的 `/api2` 一元 RPC 通道。直接调用 `invoke()` 会保留业务错误;`TypertGatewayError` 可区分分发、绑定、提供方、查找、Context、参数和编解码器各自负责的故障。 + +## Client 服务:`ClientApi`(ctx key:`api`) + +`ctx.api.mount()` 会校验并注册生成的 Host-for-Client 贡献项,然后为发起调用的 Cordis fiber 安装具体的直接方法和作用域方法。重复端点、命名空间冲突,以及缺少生成的严格编解码器的描述符,都会在方法可调用前报错。 + +每次调用都会校验位置参数,构造与描述符完全匹配的具名 `args`,再通过 `ctx.connection.rpc.call('/api2', endpoint, ...)` 发送。返回值经过校验后才会交给应用代码。撤回贡献项会同时移除其描述符和方法、中止正在进行的调用,并使外部仍持有的方法句柄在调用时返回拒绝。 + +生成的声明合并提供 TypeScript API。Client 入口不包含 Host 服务或 Host Cordis 接口合并;方法查找和调用使用普通对象与函数,而不使用 JavaScript Proxy。 + +## 模型体验 + +无,因为该包分发应用调用,不注册任何提示词、工具或会话事件。 + +#### KV Cache 影响 + +无直接影响;被调用的业务服务负责产生任何模型可见结果。 + +## 已知限制与延期工作 + +- Connection 适配器目前将分发故障和业务故障映射为 RPC 的 `internal` 代码,且不附带详细信息。结构化的 `TypertGatewayError` 类别仅供同进程调用方使用。 +- SRC 模式仅支持名称唯一的标识符参数,不支持解构、默认值或剩余参数。它只校验值能否安全表示为 JSON,不校验生成的业务类型,也绝不会推断可选字段。 +- Client 侧只能挂载严格模式生成的贡献项。SRC 标记不具备 Client 编解码器或类型投影。 +- 该包只分发一元方法。增量会话数据通过同一个 Connection 上独立的具名流协议传输。 diff --git a/packages/host/api-gateway/package.json b/packages/host/api-gateway/package.json new file mode 100644 index 0000000000..3f3c905f1d --- /dev/null +++ b/packages/host/api-gateway/package.json @@ -0,0 +1,68 @@ +{ + "name": "@deepseek-ai/dsh-host-api-gateway", + "description": "Host dispatcher and Client API for TypeRT Remote invocations", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./types": { + "types": "./lib/types/types.d.ts", + "default": "./lib/types/types.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dshClient": { + "inject": [ + "@deepseek-ai/dsh-typert-registry", + "@deepseek-ai/dsh-client-connection" + ], + "platform": "web", + "immediately": true + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/client.js", + "lib/types/**/*.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "dependencies": { + "@deepseek-ai/dsh-type-meta": "workspace:^" + }, + "peerDependencies": { + "@deepseek-ai/dsh-client-connection": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-typert-registry": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-host-webserver": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-typert-registry": "workspace:^", + "cordis": "^4.0.0-rc.7", + "zod": "^4.4.3" + } +} diff --git a/packages/host/api-gateway/src/client/index.ts b/packages/host/api-gateway/src/client/index.ts new file mode 100644 index 0000000000..57116db2cf --- /dev/null +++ b/packages/host/api-gateway/src/client/index.ts @@ -0,0 +1,370 @@ +/** + * Client projection of generated TypeRT Remote descriptors. Contributions + * install concrete namespace methods; no JavaScript Proxy participates in + * lookup, invocation, or type exposure. + */ + +import { Service } from 'cordis' +import type { Context } from 'cordis' +import type { ConnectionHandle, RpcError } from '@deepseek-ai/dsh-client-connection/client' +import type { + InvocationDescriptor, + TypeRTCodec, + TypeRTDisposer, + TypeRTRemoteContribution, + TypeRTRemoteNamespaceMap, +} from '@deepseek-ai/dsh-type-meta' + +type RemoteMethod = (...args: unknown[]) => Promise + +interface MountToken { + active: boolean + readonly abort: AbortController +} + +interface DirectNamespaceRecord { + readonly value: Record + readonly tokens: Map +} + +interface ScopedNamespaceRecord { + readonly service: ScopedRemoteNamespace + readonly tokens: Map +} + +interface ScopedProjection { + readonly context: string + readonly wire: string + readonly codec: TypeRTCodec + readonly parameterIndex?: number +} + +/** Typed API service augmented by generated direct Remote namespaces. */ +export interface ClientApi extends TypeRTRemoteNamespaceMap { + /** + * Mount one generated Host-for-Client contribution in the caller's fiber. + * @param contribution - explicitly selected Remote package artifact. + * @returns disposer withdrawing descriptors and concrete methods together. + */ + mount(contribution: TypeRTRemoteContribution): TypeRTDisposer +} + +declare module 'cordis' { + interface Context { + /** Generated direct Remote namespaces selected by the Client assembly. */ + api: ClientApi + } +} + +/** Required Client services: the TypeRT registry and the existing Connection carrier. */ +export const inject = ['typert', 'connection'] + +/** + * Install the typed Client API service. + * @param ctx - Client Cordis root. + */ +export function apply(ctx: Context): void { + new ClientApiService(ctx) +} + +class ClientApiService extends Service implements ClientApi { + private readonly ownerCtx: Context + private readonly direct = new Map() + private readonly scoped = new Map() + + constructor(ctx: Context) { + super(ctx, 'api') + this.ownerCtx = ctx + } + + mount(contribution: TypeRTRemoteContribution): TypeRTDisposer { + this.validateContribution(contribution) + const callerCtx = this.ctx + const disposeRemote = callerCtx.typert.remotes.register(contribution) + let disposeMethods: () => void | Promise + try { + disposeMethods = callerCtx.effect(() => { + const installed = contribution.descriptors.map(descriptor => this.install(descriptor)) + return () => { + for (const dispose of installed.reverse()) dispose() + } + }, `api-gateway.client.mount(${JSON.stringify(contribution.package)})`) + } catch (error) { + disposeRemote().catch(() => {}) + throw error + } + return async () => { + await Promise.all([disposeMethods(), disposeRemote()]) + } + } + + private validateContribution(contribution: TypeRTRemoteContribution): void { + const direct = new Map>() + const scoped = new Map>() + const add = ( + table: Map>, + descriptor: InvocationDescriptor, + kind: 'direct' | 'scoped', + ): void => { + const methods = table.get(descriptor.namespace) ?? new Set() + if (methods.has(descriptor.method)) { + throw new Error(`client api: contribution repeats ${kind} method ${endpointOf(descriptor)}`) + } + methods.add(descriptor.method) + table.set(descriptor.namespace, methods) + const live = kind === 'direct' + ? this.direct.get(descriptor.namespace)?.tokens + : this.scoped.get(descriptor.namespace)?.tokens + if (live?.has(descriptor.method) === true) { + throw new Error(`client api: ${kind} method ${endpointOf(descriptor)} is already mounted`) + } + } + for (const descriptor of contribution.descriptors) { + requireStrictDescriptor(descriptor) + if (descriptor.invocation.kind === 'direct') add(direct, descriptor, 'direct') + if (scopedProjection(descriptor) !== undefined) add(scoped, descriptor, 'scoped') + } + for (const namespace of direct.keys()) { + if (!this.direct.has(namespace) && namespace in this) { + throw new Error(`client api: namespace ${JSON.stringify(namespace)} conflicts with the API service`) + } + } + for (const [namespace, methods] of scoped) { + const record = this.scoped.get(namespace) + if (record !== undefined) { + for (const method of methods) record.service.assertMethodAvailable(method) + } else if (this.ownerCtx.reflect.props[namespace] !== undefined) { + throw new Error(`client api: scoped namespace ${JSON.stringify(namespace)} conflicts with an existing Context property`) + } + } + } + + private install(descriptor: InvocationDescriptor): () => void { + const token: MountToken = { active: true, abort: new AbortController() } + const installed: (() => void)[] = [] + if (descriptor.invocation.kind === 'direct') { + installed.push(this.installDirect(descriptor, token)) + } + const projection = scopedProjection(descriptor) + if (projection !== undefined) installed.push(this.installScoped(descriptor, projection, token)) + return () => { + if (!token.active) return + token.active = false + for (const dispose of installed.reverse()) dispose() + token.abort.abort() + } + } + + private installDirect(descriptor: InvocationDescriptor, token: MountToken): () => void { + let namespace = this.direct.get(descriptor.namespace) + if (namespace === undefined) { + namespace = { value: Object.create(null) as Record, tokens: new Map() } + this.direct.set(descriptor.namespace, namespace) + Object.defineProperty(this, descriptor.namespace, { + configurable: true, + enumerable: true, + value: namespace.value, + }) + } + namespace.tokens.set(descriptor.method, token) + Object.defineProperty(namespace.value, descriptor.method, { + configurable: true, + enumerable: true, + value: (...args: unknown[]) => this.invoke(descriptor, undefined, token, this.ownerCtx, args), + }) + return () => { + if (namespace.tokens.get(descriptor.method) !== token) return + Reflect.deleteProperty(namespace.value, descriptor.method) + namespace.tokens.delete(descriptor.method) + if (namespace.tokens.size !== 0) return + this.direct.delete(descriptor.namespace) + Reflect.deleteProperty(this, descriptor.namespace) + } + } + + private installScoped( + descriptor: InvocationDescriptor, + projection: ScopedProjection, + token: MountToken, + ): () => void { + let namespace = this.scoped.get(descriptor.namespace) + if (namespace === undefined) { + namespace = { + service: new ScopedRemoteNamespace( + this.ownerCtx, + descriptor.namespace, + (current, currentProjection, currentToken, caller, args) => + this.invoke(current, currentProjection, currentToken, caller, args), + ), + tokens: new Map(), + } + this.scoped.set(descriptor.namespace, namespace) + } + namespace.tokens.set(descriptor.method, token) + namespace.service.install(descriptor, projection, token) + return () => { + if (namespace.tokens.get(descriptor.method) !== token) return + namespace.service.remove(descriptor.method) + namespace.tokens.delete(descriptor.method) + } + } + + private async invoke( + descriptor: InvocationDescriptor, + projection: ScopedProjection | undefined, + token: MountToken, + callerCtx: Context, + values: readonly unknown[], + ): Promise { + const endpoint = endpointOf(descriptor) + if (!token.active) throw new Error(`client api: Remote method ${endpoint} is no longer mounted`) + const expected = descriptor.parameters.length - (projection?.parameterIndex === undefined ? 0 : 1) + if (values.length !== expected) { + throw new Error( + `client api: ${endpoint} expected ${String(expected)} argument(s), got ${String(values.length)}`, + ) + } + const args: Record = {} + if (projection !== undefined) { + const binder = this.ownerCtx.typert.contexts.getClient(projection.context) + if (binder === undefined) { + throw new Error(`client api: ${endpoint} has no Client Context binder for ${JSON.stringify(projection.context)}`) + } + const identity = binder.identity(callerCtx) + if (identity === undefined) { + throw new Error(`client api: ${endpoint} requires a ${JSON.stringify(projection.context)} Context`) + } + args[projection.wire] = parse(projection.codec, identity, endpoint, projection.wire) + } + let valueIndex = 0 + descriptor.parameters.forEach((parameter, parameterIndex) => { + if (parameterIndex === projection?.parameterIndex) return + args[parameter.wire] = parse(parameter.codec, values[valueIndex], endpoint, parameter.wire) + valueIndex += 1 + }) + const connection = this.ownerCtx.get('connection') as ConnectionHandle | undefined + if (connection === undefined) throw new Error(`client api: ${endpoint} has no active Connection`) + const result = await connection.rpc.call('/api2', endpoint, { args }, token.abort.signal) + if (!mountActive(token)) throw new Error(`client api: Remote method ${endpoint} was withdrawn during invocation`) + if (!result.ok) throw remoteFailure(endpoint, result.error) + return parse(descriptor.result, result.value, endpoint, 'result') + } +} + +type InvokeRemote = ( + descriptor: InvocationDescriptor, + projection: ScopedProjection, + token: MountToken, + callerCtx: Context, + args: readonly unknown[], +) => Promise + +class ScopedRemoteNamespace extends Service { + private readonly ownerCtx: Context + private readonly methods = new Set() + + constructor( + ctx: Context, + name: string, + private readonly invokeRemote: InvokeRemote, + ) { + super(ctx, name) + this.ownerCtx = ctx + } + + assertMethodAvailable(method: string): void { + if (method in this) { + throw new Error(`client api: scoped method ${JSON.stringify(`${this.name}/${method}`)} conflicts with its namespace service`) + } + } + + install(descriptor: InvocationDescriptor, projection: ScopedProjection, token: MountToken): void { + this.assertMethodAvailable(descriptor.method) + const method = descriptor.method + Object.defineProperty(this, method, { + configurable: true, + enumerable: true, + value: function (this: ScopedRemoteNamespace, ...args: unknown[]): Promise { + return this.invokeRemote(descriptor, projection, token, this.ctx, args) + }, + }) + this.methods.add(method) + if (this.methods.size === 1 && this.ownerCtx.get(this.name, false) === undefined) { + this.ownerCtx.set(this.name, this) + } + } + + remove(method: string): void { + Reflect.deleteProperty(this, method) + this.methods.delete(method) + if (this.methods.size === 0) this.ownerCtx.set(this.name, undefined) + } +} + +function endpointOf(descriptor: Pick): string { + return `${descriptor.namespace}/${descriptor.method}` +} + +function mountActive(token: MountToken): boolean { + return token.active +} + +function scopedProjection(descriptor: InvocationDescriptor): ScopedProjection | undefined { + if (descriptor.invocation.kind === 'context') { + return { + context: descriptor.invocation.context, + wire: descriptor.invocation.wire, + codec: descriptor.invocation.codec, + } + } + if (descriptor.scope === undefined) return undefined + const lookupParameters = descriptor.parameters + .map((parameter, index) => ({ parameter, index })) + .filter(candidate => candidate.parameter.source === 'lookup') + const selected = lookupParameters.length === 1 ? lookupParameters[0] : undefined + if (selected === undefined + || selected.parameter.wire !== descriptor.scope.wire + || selected.parameter.lookup !== descriptor.scope.context) { + throw new Error( + `client api: generated Remote ${endpointOf(descriptor)} scope must select its only lookup parameter`, + ) + } + return { + context: descriptor.scope.context, + wire: descriptor.scope.wire, + codec: selected.parameter.codec, + parameterIndex: selected.index, + } +} + +function requireStrictDescriptor(descriptor: InvocationDescriptor): void { + const endpoint = endpointOf(descriptor) + requireStrictCodec(descriptor.result, endpoint, 'result') + for (const parameter of descriptor.parameters) { + requireStrictCodec(parameter.codec, endpoint, parameter.wire) + } + if (descriptor.invocation.kind === 'context') { + requireStrictCodec(descriptor.invocation.codec, endpoint, descriptor.invocation.wire) + } +} + +function requireStrictCodec(codec: TypeRTCodec, endpoint: string, field: string): void { + if (codec.mode !== 'strict') { + throw new Error(`client api: generated Remote ${endpoint} field ${JSON.stringify(field)} has no strict codec`) + } +} + +function parse(codec: TypeRTCodec, value: unknown, endpoint: string, field: string): unknown { + if (codec.mode !== 'strict') { + throw new Error(`client api: generated Remote ${endpoint} field ${JSON.stringify(field)} has no strict codec`) + } + try { + return codec.schema.parse(value) + } catch (cause) { + throw new Error(`client api: ${endpoint} rejected ${JSON.stringify(field)}`, { cause }) + } +} + +function remoteFailure(endpoint: string, error: RpcError): Error { + return new Error(`client api: ${endpoint} failed: ${error.code}: ${error.message}`, { cause: error }) +} diff --git a/packages/host/api-gateway/src/index.ts b/packages/host/api-gateway/src/index.ts new file mode 100644 index 0000000000..ccb76e2d48 --- /dev/null +++ b/packages/host/api-gateway/src/index.ts @@ -0,0 +1,604 @@ +/** + * Live TypeRT Remote dispatch over Cordis Services and registered providers. + * Transport, request correlation, and response envelopes belong to Connection. + * @module @deepseek-ai/dsh-host-api-gateway + */ + +import { Context, Service, symbols } from 'cordis' +import { + remoteMethods, + type InvocationDescriptor, + type InvocationParameterDescriptor, + type TypeRTCodec, + type TypeRTGatewayBinding, + type TypeRTLookupProvider, +} from '@deepseek-ai/dsh-type-meta' +import type { + InvokeRemoteRequest, + TypertGateway, + TypertGatewayErrorCode, +} from './types.ts' + +export type { + InvokeRemoteRequest, + TypertGateway, + TypertGatewayErrorCode, +} from './types.ts' + +interface GatewayErrorOptions { + readonly cause?: unknown + readonly field?: string +} + +interface ResolvedBinding { + readonly binding: TypeRTGatewayBinding + readonly original: object +} + +type ConnectionRpcResult = + | { readonly ok: true; readonly value: unknown } + | { + readonly ok: false + readonly error: { + readonly code: 'internal' + readonly message: string + readonly details: Record + } + } + +interface HostConnectionLike { + readonly rpc: { + handle( + channel: string, + handler: (endpoint: string, payload: unknown, signal: AbortSignal) => Promise, + options: { readonly authority: 'trusted-host' | 'loopback' }, + ): () => Promise + } +} + +/** Dispatch failure produced outside the invoked business method. */ +export class TypertGatewayError extends Error { + /** Machine-readable failure category. */ + readonly code: TypertGatewayErrorCode + /** Canonical `/` endpoint. */ + readonly endpoint: string + /** Affected wire field when the failure is field-specific. */ + readonly field: string | undefined + + /** + * Construct a Gateway failure without embedding boundary values in its message. + * @param code - stable failure category. + * @param endpoint - canonical Remote endpoint. + * @param message - correction-oriented diagnostic without sensitive values. + * @param options - optional field and contained cause. + */ + constructor( + code: TypertGatewayErrorCode, + endpoint: string, + message: string, + options: GatewayErrorOptions = {}, + ) { + super(`typert gateway: ${endpoint}: ${message}`, options.cause === undefined ? undefined : { cause: options.cause }) + this.name = 'TypertGatewayError' + this.code = code + this.endpoint = endpoint + this.field = options.field + } +} + +/** + * Resolve strict generated definitions or conservative SRC markers against + * current Cordis Services and TypeRT providers. + * @typert service typertGateway + */ +export class TypertGatewayService extends Service implements TypertGateway { + static inject = ['typert'] + + /** + * Register the Gateway against the active TypeRT registry. + * @param ctx - owning Host Context with TypeRT registry access. + */ + constructor(ctx: Context) { + super(ctx, 'typertGateway') + ctx.inject(['connection'], (connectionCtx) => { + const connection = connectionCtx.get('connection') as unknown as HostConnectionLike + connection.rpc.handle( + '/api2', + (endpoint, payload, signal) => this.dispatchRpc(endpoint, payload, signal), + { authority: 'trusted-host' }, + ) + }) + } + + /** + * Invoke one live Remote method through strict generated reflection or SRC markers. + * @param request - decoded endpoint and exact named wire arguments. + * @returns the validated business result. + * @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; business errors retain their identity. + */ + async invoke(request: InvokeRemoteRequest): Promise { + const endpoint = endpointOf(request.namespace, request.method) + const descriptor = this.resolveDescriptor(request.namespace, request.method, endpoint) + assertExactArguments(request.args, descriptor, endpoint) + const receiverContext = this.resolveReceiverContext(descriptor, request.args, endpoint) + const receiver = receiverContext.get(descriptor.service) as unknown + if (!isObject(receiver)) { + throw new TypertGatewayError( + 'service-unavailable', + endpoint, + `active Service ${JSON.stringify(descriptor.service)} is unavailable`, + ) + } + validateBinding(receiver, descriptor.service, descriptor.namespace, endpoint) + const args = descriptor.parameters.map(parameter => this.resolveParameter(parameter, request.args, endpoint)) + const implementation = descriptor.implementation ?? descriptor.method + const method = Reflect.get(receiver, implementation) as unknown + if (typeof method !== 'function') { + throw new TypertGatewayError( + 'method-unavailable', + endpoint, + `active Service ${JSON.stringify(descriptor.service)} has no callable method ${JSON.stringify(implementation)}`, + ) + } + + const result = await Reflect.apply(method, receiver, args) as unknown + return decode(descriptor.result, result, 'result-invalid', endpoint, 'result') + } + + private async dispatchRpc( + endpoint: string, + payload: unknown, + _signal: AbortSignal, + ): Promise { + return this.invokeRpc(endpoint, payload) + } + + private async invokeRpc(endpoint: string, payload: unknown): Promise { + try { + const segments = endpoint.split('/') + const namespace = segments[0] + const method = segments[1] + if (segments.length !== 2 || namespace === undefined || namespace === '' || method === undefined || method === '') { + throw new Error(`invalid Remote endpoint ${JSON.stringify(endpoint)}`) + } + if (!isObject(payload) + || !isPlainObject(payload) + || Reflect.ownKeys(payload).length !== 1 + || !Object.hasOwn(payload, 'args') + || !isObject(payload.args) + || !isPlainObject(payload.args)) { + throw new Error('Remote payload must contain exactly one plain-object args field') + } + const value = await this.invoke({ + namespace, + method, + args: payload.args, + }) + return { ok: true, value } + } catch (error) { + return rpcFailure(error) + } + } + + private resolveDescriptor(namespace: string, method: string, endpoint: string): InvocationDescriptor { + const strict = this.ctx.typert.local.get(endpoint) + if (strict !== undefined) return strict + if (this.ctx.typert.local.hasSeen(endpoint)) { + throw new TypertGatewayError( + 'definition-unavailable', + endpoint, + 'its strict definition was withdrawn and SRC fallback is forbidden', + ) + } + return this.resolveSrcDescriptor(namespace, method, endpoint) + } + + private resolveSrcDescriptor(namespace: string, method: string, endpoint: string): InvocationDescriptor { + const candidates: InvocationDescriptor[] = [] + for (const [serviceKey, definition] of Object.entries(this.ctx.reflect.props)) { + if (definition.type !== 'service') continue + const receiver = this.ctx.get(serviceKey) as unknown + if (!isObject(receiver)) continue + const original = originalOf(receiver) + const value = Reflect.get(original, 'typertGateway') as unknown + if (value === undefined) continue + const binding = readBinding(value, original, serviceKey, endpoint) + if (binding.namespace !== namespace) continue + const marker = remoteMethods(original).find(candidate => (candidate.exportName ?? candidate.method) === method) + if (marker === undefined) continue + candidates.push(this.srcDescriptor(binding, marker, method, endpoint)) + } + if (candidates.length === 0) { + throw new TypertGatewayError('invocation-unavailable', endpoint, 'no active Remote method exports this endpoint') + } + if (candidates.length > 1) { + throw new TypertGatewayError( + 'ambiguous-endpoint', + endpoint, + `multiple active Services export this endpoint: ${candidates.map(candidate => candidate.service).sort().join(', ')}`, + ) + } + return candidates[0] as InvocationDescriptor + } + + private srcDescriptor( + binding: TypeRTGatewayBinding, + marker: ReturnType[number], + method: string, + endpoint: string, + ): InvocationDescriptor { + const names = methodParameterNames(binding.service, marker.method, endpoint) + const parameters: InvocationParameterDescriptor[] = [] + const wires = new Set() + for (const name of names) { + const matches = this.ctx.typert.lookups.keys() + .map(key => ({ key, provider: this.ctx.typert.lookups.get(key) })) + .filter((entry): entry is { key: string; provider: TypeRTLookupProvider } => + entry.provider?.parameter === name) + if (matches.length > 1) { + throw new TypertGatewayError( + 'signature-invalid', + endpoint, + `parameter ${JSON.stringify(name)} matches multiple lookup providers`, + { field: name }, + ) + } + const match = matches[0] + const parameter: InvocationParameterDescriptor = match === undefined + ? { name, wire: name, source: 'json', codec: { mode: 'src-json' } } + : { + name, + wire: match.provider.wire, + source: 'lookup', + lookup: match.key, + codec: { mode: 'src-json' }, + } + if (wires.has(parameter.wire)) { + throw new TypertGatewayError( + 'signature-invalid', + endpoint, + `multiple parameters use wire field ${JSON.stringify(parameter.wire)}`, + { field: parameter.wire }, + ) + } + wires.add(parameter.wire) + parameters.push(parameter) + } + + let receiver: InvocationDescriptor['invocation'] = { kind: 'direct' } + if (marker.invocation.kind === 'context') { + const provider = this.ctx.typert.contexts.getHost(marker.invocation.context) + if (provider === undefined) { + throw new TypertGatewayError( + 'context-unavailable', + endpoint, + `Context provider ${JSON.stringify(marker.invocation.context)} is unavailable`, + ) + } + if (wires.has(provider.wire)) { + throw new TypertGatewayError( + 'signature-invalid', + endpoint, + `Context identity conflicts with wire field ${JSON.stringify(provider.wire)}`, + { field: provider.wire }, + ) + } + receiver = { + kind: 'context', + context: marker.invocation.context, + wire: provider.wire, + codec: { mode: 'src-json' }, + } + } + + return { + id: `src:${binding.serviceKey}#${endpoint}`, + service: binding.serviceKey, + namespace: binding.namespace, + method, + ...(marker.method === method ? {} : { implementation: marker.method }), + invocation: receiver, + parameters, + result: { mode: 'src-json' }, + } + } + + private resolveReceiverContext( + descriptor: InvocationDescriptor, + args: Readonly>, + endpoint: string, + ): Context { + if (descriptor.invocation.kind === 'direct') return this.ctx + const invocation = descriptor.invocation + const provider = this.ctx.typert.contexts.getHost(invocation.context) + if (provider === undefined) { + throw new TypertGatewayError( + 'context-unavailable', + endpoint, + `Context provider ${JSON.stringify(invocation.context)} is unavailable`, + ) + } + if (provider.wire !== invocation.wire + || (invocation.codec.mode === 'strict' && provider.wireTypeSymbol !== invocation.codec.typeSymbol)) { + throw new TypertGatewayError( + 'provider-mismatch', + endpoint, + `Context provider ${JSON.stringify(invocation.context)} does not match its strict definition`, + { field: invocation.wire }, + ) + } + const identity = decode(invocation.codec, args[invocation.wire], 'input-invalid', endpoint, invocation.wire) + let context: Context | undefined + try { + context = provider.resolve(identity) + } catch (cause) { + throw new TypertGatewayError( + 'context-failed', + endpoint, + `Context provider ${JSON.stringify(invocation.context)} failed`, + { cause, field: invocation.wire }, + ) + } + if (context === undefined) { + throw new TypertGatewayError( + 'context-not-found', + endpoint, + `Context provider ${JSON.stringify(invocation.context)} did not resolve the requested identity`, + { field: invocation.wire }, + ) + } + return context + } + + private resolveParameter( + parameter: InvocationParameterDescriptor, + args: Readonly>, + endpoint: string, + ): unknown { + const value = decode(parameter.codec, args[parameter.wire], 'input-invalid', endpoint, parameter.wire) + if (parameter.source === 'json') return value + const key = parameter.lookup + if (key === undefined) { + throw new TypertGatewayError( + 'lookup-unavailable', + endpoint, + `lookup parameter ${JSON.stringify(parameter.name)} has no provider key`, + { field: parameter.wire }, + ) + } + const provider = this.ctx.typert.lookups.get(key) + if (provider === undefined) { + throw new TypertGatewayError( + 'lookup-unavailable', + endpoint, + `lookup provider ${JSON.stringify(key)} is unavailable`, + { field: parameter.wire }, + ) + } + if (provider.wire !== parameter.wire + || (parameter.codec.mode === 'strict' && provider.wireTypeSymbol !== parameter.codec.typeSymbol)) { + throw new TypertGatewayError( + 'provider-mismatch', + endpoint, + `lookup provider ${JSON.stringify(key)} does not match its strict definition`, + { field: parameter.wire }, + ) + } + let resolved: unknown + try { + resolved = provider.resolve(value) + } catch (cause) { + throw new TypertGatewayError( + 'lookup-failed', + endpoint, + `lookup provider ${JSON.stringify(key)} failed`, + { cause, field: parameter.wire }, + ) + } + if (resolved === undefined) { + throw new TypertGatewayError( + 'lookup-not-found', + endpoint, + `lookup provider ${JSON.stringify(key)} did not resolve the requested identity`, + { field: parameter.wire }, + ) + } + return resolved + } +} + +function rpcFailure(error: unknown): ConnectionRpcResult { + return { + ok: false, + error: { + code: 'internal', + message: error instanceof Error ? error.message : String(error), + details: {}, + }, + } +} + +function endpointOf(namespace: string, method: string): string { + return `${namespace}/${method}` +} + +function validateBinding( + receiver: object, + serviceKey: string, + namespace: string, + endpoint: string, +): ResolvedBinding { + const original = originalOf(receiver) + const value = Reflect.get(original, 'typertGateway') as unknown + if (value === undefined) { + throw new TypertGatewayError( + 'binding-invalid', + endpoint, + `Service ${JSON.stringify(serviceKey)} has no visible typertGateway binding`, + ) + } + return { + binding: readBinding(value, original, serviceKey, endpoint, namespace), + original, + } +} + +function readBinding( + value: unknown, + original: object, + serviceKey: string, + endpoint: string, + namespace?: string, +): TypeRTGatewayBinding { + if (!isObject(value) + || Reflect.get(value, 'service') !== original + || Reflect.get(value, 'serviceKey') !== serviceKey + || typeof Reflect.get(value, 'namespace') !== 'string' + || (namespace !== undefined && Reflect.get(value, 'namespace') !== namespace)) { + throw new TypertGatewayError( + 'binding-invalid', + endpoint, + `Service ${JSON.stringify(serviceKey)} has an inconsistent typertGateway binding`, + ) + } + return value as unknown as TypeRTGatewayBinding +} + +function originalOf(receiver: object): object { + const original = Reflect.get(receiver, symbols.original) as unknown + return isObject(original) ? original : receiver +} + +function methodParameterNames(service: object, method: string, endpoint: string): readonly string[] { + let prototype: object | null = Object.getPrototypeOf(service) as object | null + let implementation: ((this: object, ...args: never[]) => unknown) | undefined + while (prototype !== null) { + const descriptor = Object.getOwnPropertyDescriptor(prototype, method) + if (descriptor !== undefined) { + if ('value' in descriptor && typeof descriptor.value === 'function') { + implementation = descriptor.value as (this: object, ...args: never[]) => unknown + } + break + } + prototype = Object.getPrototypeOf(prototype) as object | null + } + if (implementation === undefined) { + throw new TypertGatewayError( + 'method-unavailable', + endpoint, + `Remote marker has no prototype method ${JSON.stringify(method)}`, + ) + } + const source = Function.prototype.toString.call(implementation) + const open = source.indexOf('(') + const close = source.indexOf(')', open + 1) + if (open < 0 || close < 0) return invalidSignature(endpoint, method) + const body = source.slice(open + 1, close).trim() + if (body.length === 0) return [] + const parts = body.split(',').map(part => part.trim()) + if (parts.at(-1) === '') parts.pop() + const names = new Set() + for (const part of parts) { + if (!/^[$A-Z_a-z][$\w]*$/u.test(part) || names.has(part)) return invalidSignature(endpoint, method) + names.add(part) + } + return [...names] +} + +function invalidSignature(endpoint: string, method: string): never { + throw new TypertGatewayError( + 'signature-invalid', + endpoint, + `SRC method ${JSON.stringify(method)} must use unique identifier parameters without destructuring, defaults, or rest`, + ) +} + +function assertExactArguments( + args: Readonly>, + descriptor: InvocationDescriptor, + endpoint: string, +): void { + if (!isPlainObject(args)) { + throw new TypertGatewayError('arguments-invalid', endpoint, 'args must be a plain object') + } + const expected = new Set(descriptor.parameters.map(parameter => parameter.wire)) + if (descriptor.invocation.kind === 'context') expected.add(descriptor.invocation.wire) + const actual = Reflect.ownKeys(args) + const extra = actual.filter(key => typeof key !== 'string' || !expected.has(key)) + const missing = [...expected].filter(key => !Object.hasOwn(args, key)) + if (extra.length === 0 && missing.length === 0) return + const clauses: string[] = [] + if (missing.length > 0) clauses.push(`missing ${missing.map(key => JSON.stringify(key)).join(', ')}`) + if (extra.length > 0) clauses.push(`unexpected ${extra.map(key => JSON.stringify(String(key))).join(', ')}`) + throw new TypertGatewayError('arguments-invalid', endpoint, `args fields do not match the descriptor: ${clauses.join('; ')}`) +} + +function decode( + codec: TypeRTCodec, + value: unknown, + code: 'input-invalid' | 'result-invalid', + endpoint: string, + field: string, +): unknown { + try { + if (codec.mode === 'strict') return codec.schema.parse(value) + assertJsonValue(value, new Set()) + return value + } catch (cause) { + throw new TypertGatewayError( + code, + endpoint, + code === 'input-invalid' + ? `wire field ${JSON.stringify(field)} failed boundary validation` + : 'business result failed boundary validation', + { cause, field }, + ) + } +} + +function assertJsonValue(value: unknown, ancestors: Set): void { + if (value === null || typeof value === 'string' || typeof value === 'boolean') return + if (typeof value === 'number') { + if (Number.isFinite(value)) return + throw new TypeError('non-finite number is not JSON-safe') + } + if (!isObject(value)) throw new TypeError(`${typeof value} is not JSON-safe`) + if (ancestors.has(value)) throw new TypeError('cyclic value is not JSON-safe') + ancestors.add(value) + try { + if (Array.isArray(value)) { + if (Object.getOwnPropertySymbols(value).length > 0 || Object.keys(value).length !== value.length) { + throw new TypeError('sparse or decorated array is not JSON-safe') + } + for (let index = 0; index < value.length; index += 1) { + if (!Object.hasOwn(value, index)) throw new TypeError('sparse array is not JSON-safe') + assertJsonValue(value[index], ancestors) + } + return + } + if (!isPlainObject(value)) throw new TypeError('non-plain object is not JSON-safe') + if (Object.getOwnPropertySymbols(value).length > 0) throw new TypeError('symbol property is not JSON-safe') + for (const key of Reflect.ownKeys(value)) { + if (typeof key !== 'string') throw new TypeError('symbol property is not JSON-safe') + const descriptor = Object.getOwnPropertyDescriptor(value, key) + if (descriptor === undefined || !descriptor.enumerable || !('value' in descriptor)) { + throw new TypeError('non-data property is not JSON-safe') + } + assertJsonValue(descriptor.value, ancestors) + } + } finally { + ancestors.delete(value) + } +} + +function isPlainObject(value: object): value is Record { + if (Array.isArray(value)) return false + const prototype = Object.getPrototypeOf(value) as object | null + return prototype === null || prototype === Object.prototype +} + +function isObject(value: unknown): value is object { + return (typeof value === 'object' && value !== null) || typeof value === 'function' +} + +export default TypertGatewayService diff --git a/packages/host/api-gateway/src/invariant.ts b/packages/host/api-gateway/src/invariant.ts new file mode 100644 index 0000000000..65c94b4ac4 --- /dev/null +++ b/packages/host/api-gateway/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-host-api-gateway`. + * @module @deepseek-ai/dsh-host-api-gateway/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-host-api-gateway' + +/** Cordis companion plugin name. */ +export const name = 'host-api-gateway-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: Host calls re-read authoritative Cordis and TypeRT + * state, while Client methods and descriptors mutate in one owned effect. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/host/api-gateway/src/types.ts b/packages/host/api-gateway/src/types.ts new file mode 100644 index 0000000000..eea2bdc4f1 --- /dev/null +++ b/packages/host/api-gateway/src/types.ts @@ -0,0 +1,52 @@ +/** + * Carrier-independent TypeRT Gateway request, service, and error contracts. + * @module @deepseek-ai/dsh-host-api-gateway/types + */ + +/** One Remote method request after a carrier has decoded its envelope. */ +export interface InvokeRemoteRequest { + /** Remote namespace selected by the generated descriptor. */ + readonly namespace: string + /** Exported Service method name. */ + readonly method: string + /** Named wire values; fields must exactly match the descriptor. */ + readonly args: Readonly> +} + +/** Stable infrastructure and boundary failures emitted before or after business execution. */ +export type TypertGatewayErrorCode = + | 'ambiguous-endpoint' + | 'arguments-invalid' + | 'binding-invalid' + | 'context-failed' + | 'context-not-found' + | 'context-unavailable' + | 'definition-unavailable' + | 'input-invalid' + | 'invocation-unavailable' + | 'lookup-failed' + | 'lookup-not-found' + | 'lookup-unavailable' + | 'method-unavailable' + | 'provider-mismatch' + | 'result-invalid' + | 'service-unavailable' + | 'signature-invalid' + +/** Host dispatcher consumed by Connection adapters. */ +export interface TypertGateway { + /** + * Invoke one live Remote method without assuming a carrier or response envelope. + * @param request - decoded endpoint and named wire arguments. + * @returns the validated business result. + * @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; business errors retain their identity. + */ + invoke(request: InvokeRemoteRequest): Promise +} + +declare module 'cordis' { + interface Context { + /** Host dispatcher for TypeRT Remote calls. */ + typertGateway: TypertGateway + } +} diff --git a/packages/host/api-gateway/tests/client.spec.ts b/packages/host/api-gateway/tests/client.spec.ts new file mode 100644 index 0000000000..be0b12ed51 --- /dev/null +++ b/packages/host/api-gateway/tests/client.spec.ts @@ -0,0 +1,222 @@ +import { Context } from 'cordis' +import { describe, expect, it, vi } from 'vitest' +import { z } from 'zod' +import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' +import type { + InvocationDescriptor, + TypeRTContext, + TypeRTRemoteContextApi, + TypeRTRemoteNamespace, +} from '@deepseek-ai/dsh-type-meta' +import TypertRegistry from '@deepseek-ai/dsh-typert-registry' +import { apply, inject } from '../src/client/index.ts' + +declare module '@deepseek-ai/dsh-type-meta' { + interface TypeRTContextMap { + fixture: TypeRTContext + } + + interface TypeRTRemoteMap { + 'goals/create': (agentId: string, request: { readonly objective: string }) => Promise<{ readonly ref: string }> + } + + interface TypeRTRemoteContextMap { + 'fixture:goals/create': (request: { readonly objective: string }) => Promise<{ readonly ref: string }> + 'fixture:goals/rename': (request: { readonly objective: string }) => Promise<{ readonly renamed: boolean }> + } + + interface TypeRTRemoteNamespaceMap { + goals: TypeRTRemoteNamespace<'goals'> + } + +} + +type FixtureContext = Context & TypeRTRemoteContextApi<'fixture'> + +const idSchema = z.string().min(1) +const requestSchema = z.object({ objective: z.string().min(1) }) +const createResultSchema = z.object({ ref: z.string().min(1) }) +const renameResultSchema = z.object({ renamed: z.boolean() }) + +function directDescriptor(): InvocationDescriptor { + return { + id: '@fixture/goals#goals/create', + service: 'goals', + namespace: 'goals', + method: 'create', + invocation: { kind: 'direct' }, + scope: { context: 'fixture', wire: 'agentId' }, + parameters: [{ + name: 'agent', + wire: 'agentId', + source: 'lookup', + lookup: 'fixture', + codec: { mode: 'strict', typeSymbol: '@fixture#AgentId', schema: idSchema }, + }, { + name: 'request', + wire: 'request', + source: 'json', + codec: { mode: 'strict', typeSymbol: '@fixture#CreateRequest', schema: requestSchema }, + }], + result: { mode: 'strict', typeSymbol: '@fixture#CreateResult', schema: createResultSchema }, + } +} + +function contextDescriptor(): InvocationDescriptor { + return { + id: '@fixture/goals#goals/rename', + service: 'goals', + namespace: 'goals', + method: 'rename', + invocation: { + kind: 'context', + context: 'fixture', + wire: 'agentId', + codec: { mode: 'strict', typeSymbol: '@fixture#AgentId', schema: idSchema }, + }, + parameters: [{ + name: 'request', + wire: 'request', + source: 'json', + codec: { mode: 'strict', typeSymbol: '@fixture#RenameRequest', schema: requestSchema }, + }], + result: { mode: 'strict', typeSymbol: '@fixture#RenameResult', schema: renameResultSchema }, + } +} + +async function bench(call: ConnectionHandle['rpc']['call']): Promise { + const ctx = new Context() + await ctx.plugin(TypertRegistry) + ctx.provide('connection', { rpc: { call } } as unknown as ConnectionHandle) + await ctx.plugin({ inject, apply }) + return ctx +} + +describe('Client TypeRT API', () => { + it('mounts concrete direct methods, validates both boundaries, and withdraws retained handles', async () => { + const call = vi.fn() + .mockResolvedValue({ ok: true, value: { ref: 'goal-1' } }) + const ctx = await bench(call) + let retained: typeof ctx.api.goals.create | undefined + const assembly = ctx.plugin(Object.assign( + (scope: Context) => { + scope.api.mount({ package: '@fixture/goals', descriptors: [directDescriptor()] }) + retained = scope.api.goals.create + }, + { inject: ['api'] }, + )) + await assembly + + await expect(ctx.api.goals.create('agent-1', { objective: 'ship' })).resolves.toEqual({ ref: 'goal-1' }) + expect(call).toHaveBeenCalledWith( + '/api2', + 'goals/create', + { args: { agentId: 'agent-1', request: { objective: 'ship' } } }, + expect.any(AbortSignal), + ) + await expect(ctx.api.goals.create('', { objective: 'ship' })).rejects.toThrow('rejected "agentId"') + + call.mockResolvedValueOnce({ ok: true, value: { ref: 1 } }) + await expect(ctx.api.goals.create('agent-1', { objective: 'ship' })).rejects.toThrow('rejected "result"') + + await assembly.dispose() + expect((ctx.api as unknown as Record).goals).toBeUndefined() + expect(ctx.get('goals')).toBeUndefined() + expect(ctx.typert.remotes.list()).toEqual([]) + await expect(retained?.('agent-1', { objective: 'ship' })).rejects.toThrow('no longer mounted') + }) + + it('projects one direct lookup descriptor onto an Agent-scoped alias', async () => { + const call = vi.fn() + .mockResolvedValue({ ok: true, value: { ref: 'goal-2' } }) + const ctx = await bench(call) + const agentCtx = ctx.extend({ fixtureId: 'agent-2' }) as FixtureContext + ctx.typert.contexts.registerClient('fixture', { + identity: candidate => (candidate as Context & { fixtureId?: string }).fixtureId, + }) + const assembly = ctx.plugin(Object.assign( + (scope: Context) => { + scope.api.mount({ package: '@fixture/goals', descriptors: [directDescriptor()] }) + }, + { inject: ['api'] }, + )) + await assembly + + await expect(agentCtx.goals.create({ objective: 'ship scoped' })).resolves.toEqual({ ref: 'goal-2' }) + expect(call).toHaveBeenCalledWith( + '/api2', + 'goals/create', + { args: { agentId: 'agent-2', request: { objective: 'ship scoped' } } }, + expect.any(AbortSignal), + ) + await expect((ctx as FixtureContext).goals.create({ objective: 'wrong scope' })) + .rejects.toThrow('requires a "fixture" Context') + + await assembly.dispose() + expect((ctx.api as unknown as Record).goals).toBeUndefined() + expect(ctx.get('goals')).toBeUndefined() + }) + + it('uses the caller Context identity for scoped namespace methods', async () => { + const call = vi.fn() + .mockResolvedValue({ ok: true, value: { renamed: true } }) + const ctx = await bench(call) + const agentCtx = ctx.extend({ fixtureId: 'agent-2' }) as FixtureContext + ctx.typert.contexts.registerClient('fixture', { + identity: candidate => (candidate as Context & { fixtureId?: string }).fixtureId, + }) + const assembly = ctx.plugin(Object.assign( + (scope: Context) => { + scope.api.mount({ package: '@fixture/goals', descriptors: [contextDescriptor()] }) + }, + { inject: ['api'] }, + )) + await assembly + + await expect(agentCtx.goals.rename({ objective: 'land' })).resolves.toEqual({ renamed: true }) + expect(call).toHaveBeenCalledWith( + '/api2', + 'goals/rename', + { args: { agentId: 'agent-2', request: { objective: 'land' } } }, + expect.any(AbortSignal), + ) + await expect((ctx as FixtureContext).goals.rename({ objective: 'land' })) + .rejects.toThrow('requires a "fixture" Context') + + await assembly.dispose() + expect(ctx.get('goals')).toBeUndefined() + }) + + it('rejects weak descriptors and namespace collisions before registration', async () => { + const ctx = await bench(vi.fn()) + const weak: InvocationDescriptor = { + ...directDescriptor(), + result: { mode: 'src-json' }, + } + + expect(() => ctx.api.mount({ package: '@fixture/weak', descriptors: [weak] })) + .toThrow('has no strict codec') + expect(() => ctx.api.mount({ + package: '@fixture/conflict', + descriptors: [{ ...directDescriptor(), namespace: 'mount' }], + })).toThrow('conflicts with the API service') + expect(ctx.typert.remotes.list()).toEqual([]) + }) + + it('throws RPC failures with the structured error as its cause', async () => { + const rpcError = { code: 'internal' as const, message: 'host failed', details: {} } + const ctx = await bench(vi.fn().mockResolvedValue({ ok: false, error: rpcError })) + ctx.api.mount({ package: '@fixture/goals', descriptors: [directDescriptor()] }) + + let failure: unknown + try { + await ctx.api.goals.create('agent-1', { objective: 'ship' }) + } catch (error) { + failure = error + } + expect(failure).toBeInstanceOf(Error) + if (!(failure instanceof Error)) throw new Error('expected Client API invocation to fail') + expect(failure.message).toContain('internal: host failed') + expect(failure.cause).toBe(rpcError) + }) +}) diff --git a/packages/host/api-gateway/tests/gateway.spec.ts b/packages/host/api-gateway/tests/gateway.spec.ts new file mode 100644 index 0000000000..8f7c144f5e --- /dev/null +++ b/packages/host/api-gateway/tests/gateway.spec.ts @@ -0,0 +1,795 @@ +import { createServer } from 'node:http' +import type { AddressInfo } from 'node:net' +import { describe, expect, it } from 'vitest' +import { Context, Service, symbols } from 'cordis' +import { z } from 'zod' +import { apply as applyConnection, inject as connectionInject } from '@deepseek-ai/dsh-client-connection' +import type { HttpServerService, WebRoute } from '@deepseek-ai/dsh-host-webserver' +import { + bindTypeRTGateway, + Remote, + RemoteContext, + type InvocationDescriptor, + type TypeRTContext, + type TypeRTLookup, + type TypeRTLookupProvider, +} from '@deepseek-ai/dsh-type-meta' +import TypertRegistry, { type TypertContribution } from '@deepseek-ai/dsh-typert-registry' +import TypertGatewayService, { TypertGatewayError } from '@deepseek-ai/dsh-host-api-gateway' + +interface FixtureAgent { + readonly id: string +} + +interface MarkedContext extends Context { + readonly fixtureScope?: string +} + +declare module '@deepseek-ai/dsh-type-meta' { + interface TypeRTLookupMap { + gatewayFixture: TypeRTLookup + gatewayFixtureAlias: TypeRTLookup + } + + interface TypeRTContextMap { + gatewayFixture: TypeRTContext + } +} + +const emptyModel: TypertContribution['model'] = { + services: [], + events: [], + objects: [], +} + +class GoalService extends Service { + readonly typertGateway = bindTypeRTGateway(this, 'goals') + readonly calls: string[] = [] + nextResult: unknown = undefined + businessError: Error | undefined + + constructor(ctx: Context) { + super(ctx, 'goals') + } + + @Remote + create(agent: FixtureAgent, request: { readonly title: string }): unknown { + this.calls.push('create') + return { + agentId: agent.id, + title: request.title, + scope: (this.ctx as MarkedContext).fixtureScope ?? 'root', + } + } + + @RemoteContext('gatewayFixture') + rename(request: { readonly title: string }): unknown { + this.calls.push('rename') + return { title: request.title, scope: (this.ctx as MarkedContext).fixtureScope ?? 'root' } + } + + @Remote + passthrough(value: unknown): unknown { + this.calls.push('passthrough') + return this.nextResult === undefined ? value : this.nextResult + } + + @Remote + fail(request: unknown): never { + void request + this.calls.push('fail') + throw this.businessError ?? new Error('fixture business failure') + } + + strictOnly(request: { readonly title: string }): unknown { + this.calls.push('strictOnly') + return this.nextResult === undefined ? request : this.nextResult + } +} + +type FakeRpcResult = + | { readonly ok: true; readonly value: unknown } + | { readonly ok: false; readonly error: { readonly code: 'internal'; readonly message: string; readonly details: object } } + +type FakeRpcHandler = (endpoint: string, payload: unknown, signal: AbortSignal) => Promise + +class FakeConnectionService extends Service { + channel: string | undefined + authority: string | undefined + handler: FakeRpcHandler | undefined + + constructor(ctx: Context) { + super(ctx, 'connection') + } + + get rpc() { + const owner = this.ctx + return { + handle: (channel: string, handler: FakeRpcHandler, options: { readonly authority: string }) => + owner.effect(() => { + this.channel = channel + this.authority = options.authority + this.handler = handler + return () => { + this.channel = undefined + this.authority = undefined + this.handler = undefined + } + }), + } + } +} + +function fakeHttpServer(routes: WebRoute[]): Pick { + return { + register(route) { + if (routes.some(candidate => candidate.kind === route.kind && candidate.path === route.path)) { + throw new Error(`duplicate route ${route.path}`) + } + routes.push(route) + return () => { routes.splice(routes.indexOf(route), 1) } + }, + tapIndex: () => () => {}, + port: 0, + } +} + +async function serveRoute(route: WebRoute): Promise<{ readonly origin: string; close(): Promise }> { + const server = createServer((request, response) => { + void route.handler(request, response) + }) + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() as AddressInfo + return { + origin: `http://127.0.0.1:${String(address.port)}`, + close: () => new Promise((resolve, reject) => { + server.close((error) => { + if (error === undefined || error === null) resolve() + else reject(error) + }) + }), + } +} + +class FirstSharedService extends Service { + readonly typertGateway = bindTypeRTGateway(this, 'firstShared', { namespace: 'shared' }) + + constructor(ctx: Context) { + super(ctx, 'firstShared') + } + + @Remote + run(value: string): string { + return value + } +} + +class SecondSharedService extends Service { + readonly typertGateway = bindTypeRTGateway(this, 'secondShared', { namespace: 'shared' }) + + constructor(ctx: Context) { + super(ctx, 'secondShared') + } + + @Remote + run(value: string): string { + return value + } +} + +class DefaultParameterService extends Service { + readonly typertGateway = bindTypeRTGateway(this, 'defaultParameter', { namespace: 'invalid-default' }) + + constructor(ctx: Context) { + super(ctx, 'defaultParameter') + } + + @Remote + run(value = 'fallback'): string { + return value + } +} + +class DestructuredParameterService extends Service { + readonly typertGateway = bindTypeRTGateway(this, 'destructuredParameter', { namespace: 'invalid-destructure' }) + + constructor(ctx: Context) { + super(ctx, 'destructuredParameter') + } + + @Remote + run({ value }: { readonly value: string }): string { + return value + } +} + +class RestParameterService extends Service { + readonly typertGateway = bindTypeRTGateway(this, 'restParameter', { namespace: 'invalid-rest' }) + + constructor(ctx: Context) { + super(ctx, 'restParameter') + } + + @Remote + run(...values: readonly unknown[]): string { + return values.map(String).join(',') + } +} + +class WrongBindingService extends Service { + readonly typertGateway = bindTypeRTGateway(this, 'notWrongBinding', { namespace: 'wrong-binding' }) + + constructor(ctx: Context) { + super(ctx, 'wrongBinding') + } + + @Remote + run(value: string): string { + return value + } +} + +describe('TypertGatewayService', () => { + it('invokes a strict direct method with schema decoding and a live lookup', async () => { + const { ctx, service } = await setup() + const agent = { id: 'agent-1' } + registerAgentLookup(ctx, agent) + registerStrict(ctx, [createDescriptor()]) + const caller = ctx.extend({ fixtureScope: 'direct-caller' }) + + await expect(caller.typertGateway.invoke({ + namespace: 'goals', + method: 'create', + args: { agentId: 'agent-1', request: { title: ' ship ' } }, + })).resolves.toEqual({ agentId: 'agent-1', title: 'ship', scope: 'direct-caller' }) + expect(service.calls).toEqual(['create']) + }) + + it('resolves strict Remote Context identity without adding a business argument', async () => { + const { ctx, service } = await setup() + const scoped = ctx.extend({ fixtureScope: 'agent-scope' }) + ctx.typert.contexts.registerHost('gatewayFixture', contextProvider(scoped)) + registerStrict(ctx, [renameDescriptor()]) + + await expect(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'rename', + args: { agentId: 'agent-1', request: { title: 'land' } }, + })).resolves.toEqual({ title: 'land', scope: 'agent-scope' }) + expect(service.calls).toEqual(['rename']) + }) + + it('derives SRC direct lookup and JSON parameters from marker and parameter names', async () => { + const { ctx } = await setup() + const agent = { id: 'agent-1' } + registerAgentLookup(ctx, agent) + const caller = ctx.extend({ fixtureScope: 'direct-src' }) + + await expect(caller.typertGateway.invoke({ + namespace: 'goals', + method: 'create', + args: { agentId: 'agent-1', request: { title: 'ship' } }, + })).resolves.toEqual({ agentId: 'agent-1', title: 'ship', scope: 'direct-src' }) + }) + + it('derives SRC Remote Context identity and preserves the scoped Proxy receiver', async () => { + const { ctx } = await setup() + const scoped = ctx.extend({ fixtureScope: 'agent-src' }) + ctx.typert.contexts.registerHost('gatewayFixture', contextProvider(scoped)) + + await expect(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'rename', + args: { agentId: 'agent-1', request: { title: 'land' } }, + })).resolves.toEqual({ title: 'land', scope: 'agent-src' }) + }) + + it('re-reads Service and providers on every strict invocation', async () => { + const { ctx, serviceFiber } = await setup() + const agent = { id: 'agent-1' } + const disposeLookup = registerAgentLookup(ctx, agent) + registerStrict(ctx, [createDescriptor()]) + + await disposeLookup() + await expectCode(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'create', + args: { agentId: 'agent-1', request: { title: 'ship' } }, + }), 'lookup-unavailable') + + registerAgentLookup(ctx, agent) + await serviceFiber.dispose() + await expectCode(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'create', + args: { agentId: 'agent-1', request: { title: 'ship' } }, + }), 'service-unavailable') + }) + + it('re-reads and contains Context providers', async () => { + const { ctx } = await setup() + const scoped = ctx.extend() + const dispose = ctx.typert.contexts.registerHost('gatewayFixture', contextProvider(scoped)) + registerStrict(ctx, [renameDescriptor()]) + + await dispose() + await expectCode(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'rename', + args: { agentId: 'agent-1', request: { title: 'land' } }, + }), 'context-unavailable') + + ctx.typert.contexts.registerHost('gatewayFixture', { + ...contextProvider(scoped), + resolve: () => { throw new Error('provider failed') }, + }) + const error = await expectCode(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'rename', + args: { agentId: 'agent-1', request: { title: 'land' } }, + }), 'context-failed') + expect(error.cause).toEqual(new Error('provider failed')) + }) + + it('never downgrades an observed strict endpoint after definition disposal', async () => { + const { ctx } = await setup() + const dispose = registerStrict(ctx, [passthroughDescriptor()]) + await dispose() + + await expectCode(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'passthrough', + args: { value: 'would pass through SRC' }, + }), 'definition-unavailable') + }) + + it('seeds the no-downgrade guard from definitions present before Gateway startup', async () => { + const ctx = new Context() + await ctx.plugin(TypertRegistry) + const dispose = registerStrict(ctx, [passthroughDescriptor()]) + await ctx.plugin(TypertGatewayService) + await ctx.plugin(GoalService) + await dispose() + + await expectCode(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'passthrough', + args: { value: 'would pass through SRC' }, + }), 'definition-unavailable') + }) + + it('retains the no-downgrade guard across Gateway Service reloads', async () => { + const ctx = new Context() + await ctx.plugin(TypertRegistry) + const gatewayFiber = ctx.plugin(TypertGatewayService) + await gatewayFiber + await ctx.plugin(GoalService) + const dispose = registerStrict(ctx, [passthroughDescriptor()]) + await dispose() + + await gatewayFiber.dispose() + await ctx.plugin(TypertGatewayService) + + await expectCode(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'passthrough', + args: { value: 'would pass through SRC' }, + }), 'definition-unavailable') + }) + + it('rejects ambiguous SRC endpoints independently of reflection order', async () => { + const ctx = await setupGateway() + await ctx.plugin(FirstSharedService) + await ctx.plugin(SecondSharedService) + + const error = await expectCode(ctx.typertGateway.invoke({ + namespace: 'shared', + method: 'run', + args: { value: 'ship' }, + }), 'ambiguous-endpoint') + expect(error.message).toContain('firstShared, secondShared') + }) + + it('rejects SRC signatures that cannot map one wire field to each position', async () => { + const cases = [ + { plugin: DefaultParameterService, namespace: 'invalid-default', args: { value: 'x' } }, + { plugin: DestructuredParameterService, namespace: 'invalid-destructure', args: { value: { value: 'x' } } }, + { plugin: RestParameterService, namespace: 'invalid-rest', args: { values: ['x'] } }, + ] as const + for (const testCase of cases) { + const ctx = await setupGateway() + await ctx.plugin(testCase.plugin) + await expectCode(ctx.typertGateway.invoke({ + namespace: testCase.namespace, + method: 'run', + args: testCase.args, + }), 'signature-invalid') + } + }) + + it('rejects a SRC parameter matching more than one lookup provider', async () => { + const { ctx } = await setup() + const provider = agentLookup({ id: 'agent-1' }) + ctx.typert.lookups.register('gatewayFixture', provider) + ctx.typert.lookups.register('gatewayFixtureAlias', provider) + + await expectCode(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'create', + args: { agentId: 'agent-1', request: { title: 'ship' } }, + }), 'signature-invalid') + }) + + it('requires exact wire fields before invoking business code', async () => { + const { ctx, service } = await setup() + registerAgentLookup(ctx, { id: 'agent-1' }) + + await expectCode(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'create', + args: { request: { title: 'ship' } }, + }), 'arguments-invalid') + await expectCode(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'create', + args: { agentId: 'agent-1', request: { title: 'ship' }, optional: true }, + }), 'arguments-invalid') + expect(service.calls).toEqual([]) + }) + + it('distinguishes strict input and result validation failures', async () => { + const { ctx, service } = await setup() + registerStrict(ctx, [strictOnlyDescriptor()]) + + await expectCode(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'strictOnly', + args: { request: { title: 1 } }, + }), 'input-invalid') + + service.nextResult = { title: 1 } + await expectCode(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'strictOnly', + args: { request: { title: 'ship' } }, + }), 'result-invalid') + }) + + it.each([ + undefined, + Number.NaN, + Number.POSITIVE_INFINITY, + 1n, + Symbol('value'), + () => 'value', + new Date(0), + new Map(), + [, 'sparse'], + ])('rejects non-JSON SRC input %#', async (value) => { + const { ctx } = await setup() + await expectCode(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'passthrough', + args: { value }, + }), 'input-invalid') + }) + + it('rejects cyclic SRC input and non-JSON SRC results', async () => { + const { ctx, service } = await setup() + const cyclic: { self?: unknown } = {} + cyclic.self = cyclic + await expectCode(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'passthrough', + args: { value: cyclic }, + }), 'input-invalid') + + service.nextResult = new Date(0) + await expectCode(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'passthrough', + args: { value: null }, + }), 'result-invalid') + }) + + it('validates strict provider identity against generated wire metadata', async () => { + const { ctx } = await setup() + ctx.typert.lookups.register('gatewayFixture', { + ...agentLookup({ id: 'agent-1' }), + wire: 'differentAgentId', + }) + registerStrict(ctx, [createDescriptor()]) + + await expectCode(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'create', + args: { agentId: 'agent-1', request: { title: 'ship' } }, + }), 'provider-mismatch') + }) + + it('validates binding identity and active method availability', async () => { + const ctx = await setupGateway() + await ctx.plugin(WrongBindingService) + await expectCode(ctx.typertGateway.invoke({ + namespace: 'wrong-binding', + method: 'run', + args: { value: 'ship' }, + }), 'binding-invalid') + + await ctx.plugin(GoalService) + registerStrict(ctx, [{ ...passthroughDescriptor(), method: 'missing' }]) + await expectCode(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'missing', + args: { value: 'ship' }, + }), 'method-unavailable') + }) + + it('preserves business exception identity after invocation begins', async () => { + const { ctx, service } = await setup() + const failure = new Error('business identity') + service.businessError = failure + + await expect(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'fail', + args: { request: { reason: 'fixture' } }, + })).rejects.toBe(failure) + }) + + it('reports an absent endpoint without retaining receiver state', async () => { + const { ctx } = await setup() + await expectCode(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'absent', + args: {}, + }), 'invocation-unavailable') + }) + + it('mounts /api2 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(FakeConnectionService) + const gatewayFiber = ctx.plugin(TypertGatewayService) + await gatewayFiber + await ctx.plugin(GoalService) + const connection = rawConnection(ctx) + expect(connection).toMatchObject({ channel: '/api2', authority: 'trusted-host' }) + + registerAgentLookup(ctx, { id: 'agent-1' }) + registerStrict(ctx, [createDescriptor()]) + const signal = new AbortController().signal + const handler = connection.handler + if (handler === undefined) throw new Error('fixture Connection did not retain the /api2 handler') + await expect(handler('goals/create', { + args: { agentId: 'agent-1', request: { title: 'ship' } }, + }, signal)).resolves.toEqual({ + ok: true, + value: { agentId: 'agent-1', title: 'ship', scope: 'rpc-caller' }, + }) + const invalid = await handler('goals/create', { invalid: true }, signal) + expect(invalid).toMatchObject({ + ok: false, + error: { code: 'internal' }, + }) + if (invalid.ok) throw new Error('invalid Remote payload unexpectedly succeeded') + expect(invalid.error.message).toMatch(/exactly one plain-object args field/) + + await gatewayFiber.dispose() + expect(connection.handler).toBeUndefined() + }) + + it('dispatches a generated invocation through the real /api2 HTTP carrier', async () => { + const ctx = new Context().extend({ fixtureScope: 'http-caller' }) + const routes: WebRoute[] = [] + ctx.provide('httpServer', fakeHttpServer(routes) as HttpServerService) + const connectionFiber = ctx.plugin({ inject: [...connectionInject], apply: applyConnection }) + await connectionFiber + await ctx.plugin(TypertRegistry) + const gatewayFiber = ctx.plugin(TypertGatewayService) + await gatewayFiber + const goalFiber = ctx.plugin(GoalService) + await goalFiber + const removeLookup = registerAgentLookup(ctx, { id: 'agent-1' }) + const removeStrict = registerStrict(ctx, [createDescriptor()]) + expect(routes).toHaveLength(1) + const server = await serveRoute(routes[0]!) + + try { + const response = await fetch(`${server.origin}/api2/goals/create`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + type: 'client-request', + rpcId: 'rpc-http', + method: 'goals/create', + payload: { args: { agentId: 'agent-1', request: { title: ' ship ' } } }, + }), + }) + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ + type: 'server-response', + rpcId: 'rpc-http', + result: { + ok: true, + value: { agentId: 'agent-1', title: 'ship', scope: 'http-caller' }, + }, + }) + } finally { + await server.close() + await removeStrict() + await removeLookup() + await goalFiber.dispose() + await gatewayFiber.dispose() + await connectionFiber.dispose() + } + expect(routes).toHaveLength(0) + }) +}) + +async function setup(): Promise<{ + readonly ctx: Context + readonly service: GoalService + readonly serviceFiber: ReturnType +}> { + const ctx = await setupGateway() + const serviceFiber = ctx.plugin(GoalService) + await serviceFiber + return { ctx, service: rawGoalService(ctx), serviceFiber } +} + +async function setupGateway(): Promise { + const ctx = new Context() + await ctx.plugin(TypertRegistry) + await ctx.plugin(TypertGatewayService) + return ctx +} + +function rawGoalService(ctx: Context): GoalService { + const receiver = ctx.get('goals') as unknown as GoalService & { [symbols.original]?: GoalService } + return receiver[symbols.original] ?? receiver +} + +function rawConnection(ctx: Context): FakeConnectionService { + const receiver = ctx.get('connection') as unknown as FakeConnectionService & { + [symbols.original]?: FakeConnectionService + } + return receiver[symbols.original] ?? receiver +} + +function registerStrict(ctx: Context, descriptors: readonly InvocationDescriptor[]): () => Promise { + return ctx.typert.register({ + package: '@fixture/gateway', + face: 'host', + schemas: [], + model: emptyModel, + invocations: descriptors, + }) +} + +function registerAgentLookup(ctx: Context, agent: FixtureAgent): () => Promise { + return ctx.typert.lookups.register('gatewayFixture', agentLookup(agent)) +} + +function agentLookup(agent: FixtureAgent): TypeRTLookupProvider { + return { + parameter: 'agent', + wire: 'agentId', + hostTypeSymbol: '@fixture/domain#Agent', + wireTypeSymbol: '@fixture/domain#AgentId', + resolve: id => id === agent.id ? agent : undefined, + } +} + +function contextProvider(context: Context) { + return { + wire: 'agentId', + wireTypeSymbol: '@fixture/domain#AgentId', + resolve: (id: string) => id === 'agent-1' ? context : undefined, + } +} + +function strictCodec(typeSymbol: string, schema: z.ZodType): InvocationDescriptor['result'] { + return { mode: 'strict', typeSymbol, schema } +} + +function createDescriptor(): InvocationDescriptor { + return { + id: '@fixture/gateway#goals/create', + service: 'goals', + namespace: 'goals', + method: 'create', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'agent', + wire: 'agentId', + source: 'lookup', + lookup: 'gatewayFixture', + codec: strictCodec('@fixture/domain#AgentId', z.string()), + }, + { + name: 'request', + wire: 'request', + source: 'json', + codec: strictCodec('@fixture/gateway#CreateRequest', z.object({ + title: z.string().transform(value => value.trim()), + })), + }, + ], + result: strictCodec('@fixture/gateway#CreateResult', z.object({ + agentId: z.string(), + title: z.string(), + scope: z.string(), + })), + } +} + +function renameDescriptor(): InvocationDescriptor { + return { + id: '@fixture/gateway#goals/rename', + service: 'goals', + namespace: 'goals', + method: 'rename', + invocation: { + kind: 'context', + context: 'gatewayFixture', + wire: 'agentId', + codec: strictCodec('@fixture/domain#AgentId', z.string()), + }, + parameters: [{ + name: 'request', + wire: 'request', + source: 'json', + codec: strictCodec('@fixture/gateway#RenameRequest', z.object({ title: z.string() })), + }], + result: strictCodec('@fixture/gateway#RenameResult', z.object({ + title: z.string(), + scope: z.string(), + })), + } +} + +function passthroughDescriptor(): InvocationDescriptor { + return { + id: '@fixture/gateway#goals/passthrough', + service: 'goals', + namespace: 'goals', + method: 'passthrough', + invocation: { kind: 'direct' }, + parameters: [{ + name: 'value', + wire: 'value', + source: 'json', + codec: { mode: 'src-json' }, + }], + result: { mode: 'src-json' }, + } +} + +function strictOnlyDescriptor(): InvocationDescriptor { + const value = strictCodec('@fixture/gateway#StrictValue', z.object({ title: z.string() })) + return { + id: '@fixture/gateway#goals/strictOnly', + service: 'goals', + namespace: 'goals', + method: 'strictOnly', + invocation: { kind: 'direct' }, + parameters: [{ name: 'request', wire: 'request', source: 'json', codec: value }], + result: value, + } +} + +async function expectCode( + promise: Promise, + code: TypertGatewayError['code'], +): Promise { + try { + await promise + } catch (error) { + expect(error).toBeInstanceOf(TypertGatewayError) + expect(error).toMatchObject({ code }) + return error as TypertGatewayError + } + throw new Error(`expected TypertGatewayError ${code}`) +} diff --git a/packages/host/api-gateway/tsconfig.json b/packages/host/api-gateway/tsconfig.json new file mode 100644 index 0000000000..fea39663f7 --- /dev/null +++ b/packages/host/api-gateway/tsconfig.json @@ -0,0 +1,27 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../support/invariants" + }, + { + "path": "../../client/connection" + }, + { + "path": "../../typert/type-meta" + } + ] +} diff --git a/packages/host/api-gateway/tsdown.config.ts b/packages/host/api-gateway/tsdown.config.ts new file mode 100644 index 0000000000..1f95a1f2c5 --- /dev/null +++ b/packages/host/api-gateway/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../../client/tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-host-api-gateway', ['lib/types/index.js', 'lib/types/invariant.js']) diff --git a/packages/host/apiproxy/src/api/index.ts b/packages/host/apiproxy/src/api/index.ts index c9dea52f98..cb83c5328d 100644 --- a/packages/host/apiproxy/src/api/index.ts +++ b/packages/host/apiproxy/src/api/index.ts @@ -72,6 +72,11 @@ export type { // ---- Errors and ids ---- export { RpcId, transportError } from './rpc.ts' export type { RpcError, RpcErrorCode, RpcErrorDetailsMap, RpcResult } from './rpc.ts' +export { + clientRequestSchema, + serverRequestSchema, + serverResponseSchema, +} from './rpc.schema.ts' // ---- Fixed session-search product bounds ---- export { diff --git a/packages/typert/generator/package.json b/packages/typert/generator/package.json index 3e9d8f7f61..5ffb933214 100644 --- a/packages/typert/generator/package.json +++ b/packages/typert/generator/package.json @@ -30,6 +30,7 @@ ], "license": "BSD-3-Clause", "dependencies": { + "@jridgewell/gen-mapping": "^0.3.13", "typescript": "^6.0.3" }, "peerDependencies": { diff --git a/packages/typert/generator/src/analyzer.ts b/packages/typert/generator/src/analyzer.ts index 005b8e2157..5757d7cef5 100644 --- a/packages/typert/generator/src/analyzer.ts +++ b/packages/typert/generator/src/analyzer.ts @@ -15,6 +15,8 @@ import type { EnumMemberModel, ExportModel, FaceModel, + InvocationModel, + InvocationParameterModel, JsDocTagModel, KeywordTypeName, MemberBase, @@ -23,6 +25,8 @@ import type { ObjectModel, PackageModel, ParameterModel, + RemoteBoundaryModel, + RemoteTypeImportModel, SchemaModel, ServiceModel, SignatureModel, @@ -122,6 +126,25 @@ interface ModuleIdentity { readonly subpath: string } +interface StaticLookupDeclaration { + readonly key: string + readonly hostSymbol: SymbolId + readonly wireType: ts.TypeNode + readonly site: ts.Node +} + +interface StaticContextDeclaration { + readonly key: string + readonly wireType: ts.TypeNode + readonly site: ts.Node +} + +interface GatewayBinding { + readonly service: string + readonly namespace: string + readonly site: ts.PropertyDeclaration +} + type ReferenceSite = ts.TypeReferenceNode | ts.ExpressionWithTypeArguments | ts.ImportTypeNode const EMPTY_DOCUMENTATION: DocumentationModel = { tags: [] } @@ -453,15 +476,11 @@ export class WorkspaceAnalyzer { config: this.caches.config(configPath), manifest, } - const packagePath = slash(relative(this.options.root, packageRoot)) - const clientPackage = packagePath === 'packages/client' || packagePath.startsWith('packages/client/') - if (clientPackage && isDualFacePackage(manifest)) { + if (isDualFacePackage(manifest)) { registrations.push({ ...registration, face: 'host', exportSubpaths: hostExportSubpaths(manifest) }) registrations.push({ ...registration, face: 'client', exportSubpaths: clientExportSubpaths(manifest) }) - } else if (clientPackage) { - registrations.push({ ...registration, face: 'client' }) } else { - registrations.push({ ...registration, face: 'host' }) + registrations.push(registration) } } } @@ -480,6 +499,7 @@ export class WorkspaceAnalyzer { && subpath !== './package.json' && subpath !== './typert' && subpath !== './client/typert' + && subpath !== './remote' && !target.endsWith('.json')) .map(([, target]) => sourcePathForExport(registration.root, target)) .filter(existsSync) @@ -578,6 +598,8 @@ class FaceAnalyzer { private readonly nodes = new Map() private readonly exportsByPackage = new Map() private readonly nodeOrdinals = new Map() + private staticLookups: readonly StaticLookupDeclaration[] | undefined + private staticContexts: ReadonlyMap | undefined constructor(options: FaceAnalyzerOptions) { this.root = options.root @@ -601,6 +623,7 @@ class FaceAnalyzer { const packages = this.registrations .map(registration => this.analyzePackage(registration)) .filter(hasPackageSurface) + this.validateInvocationIdentity(packages) return { face: this.face, packages, @@ -634,6 +657,7 @@ class FaceAnalyzer { } } } + const explicitServices = this.collectExplicitServices(records) const objects: ObjectModel[] = [] const schemas: SchemaModel[] = [] @@ -672,10 +696,14 @@ class FaceAnalyzer { root: slash(relative(this.root, registration.root)), exports: records.map(record => record.model) .sort((left, right) => left.subpath.localeCompare(right.subpath) || left.name.localeCompare(right.name)), - services: uniqueBy(services, service => service.key).sort((left, right) => left.key.localeCompare(right.key)), + services: uniqueBy([...explicitServices, ...services], service => service.key) + .sort((left, right) => left.key.localeCompare(right.key)), events: uniqueBy(events, event => event.name).sort((left, right) => left.name.localeCompare(right.name)), objects: objects.sort((left, right) => left.export.name.localeCompare(right.export.name)), schemas: schemas.sort((left, right) => left.export.name.localeCompare(right.export.name)), + invocations: this.face === 'host' + ? this.collectInvocations(registration, reachable).sort((left, right) => left.id.localeCompare(right.id)) + : [], } } @@ -686,7 +714,7 @@ class FaceAnalyzer { const records: ExportRecord[] = [] for (const [subpath, target] of targets) { if (target.includes('*') || subpath === './package.json' - || subpath === './typert' || subpath === './client/typert' + || subpath === './typert' || subpath === './client/typert' || subpath === './remote' // Data exports (bundle patch lists, JSON manifests) carry no TypeScript API. || target.endsWith('.json') || target.endsWith('.yml') || target.endsWith('.yaml')) continue const sourcePath = sourcePathForExport(registration.root, target) @@ -849,6 +877,740 @@ class FaceAnalyzer { return result } + private collectExplicitServices(records: readonly ExportRecord[]): ServiceModel[] { + const result: ServiceModel[] = [] + const seen = new Set() + for (const record of records) { + const tag = typertServiceTag(record.declaration) + if (tag === undefined) continue + const words = (ts.getTextOfJSDocComment(tag.comment) ?? '').trim().split(/\s+/) + if (words.length !== 2 || !isRemoteSegment(words[1] ?? '')) { + this.fail(tag, '@typert service requires exactly one nonempty Cordis service key without "/"') + } + if (!ts.isClassDeclaration(record.declaration)) { + this.fail(record.declaration, '@typert service requires an exported class') + } + const symbol = this.resolveSymbol(record.symbol) + const symbolId = this.symbolId(symbol) + if (seen.has(symbolId)) continue + seen.add(symbolId) + const model = this.ensureDeclaration(symbol, record.declaration) + result.push({ + ...documentationOf(record.declaration), + key: words[1] as string, + symbol: symbolId, + export: record.model, + members: model.members.filter(exposableMember).map(member => member.id), + location: this.location(record.declaration), + }) + } + return result + } + + private collectInvocations( + registration: PackageRegistration, + reachable: readonly ts.SourceFile[], + ): InvocationModel[] { + const result: InvocationModel[] = [] + for (const sourceFile of reachable) { + for (const statement of sourceFile.statements) { + if (!ts.isClassDeclaration(statement)) continue + const marked = statement.members.flatMap((member) => { + const invocation = this.remoteMarker(member) + if (invocation === undefined) return [] + if (!ts.isMethodDeclaration(member)) { + this.fail(member, 'Remote decorators require a public instance method') + } + return [{ method: member, invocation }] + }) + const first = marked[0] + if (first === undefined) continue + const binding = this.gatewayBinding(statement) + if (binding === undefined) { + this.fail(first.method, 'Remote methods require readonly typertGateway = bindTypeRTGateway(this, serviceKey)') + } + for (const { method, invocation } of marked) { + result.push(this.invocationModel(registration, binding, method, invocation)) + } + } + } + return result + } + + private invocationModel( + registration: PackageRegistration, + binding: GatewayBinding, + method: ts.MethodDeclaration, + invocation: + | { readonly kind: 'direct'; readonly exportName?: string } + | { readonly kind: 'context'; readonly context: string; readonly exportName?: string }, + ): InvocationModel { + if (visibilityOf(method) !== 'public' || hasModifier(method, ts.SyntaxKind.StaticKeyword)) { + this.fail(method, 'Remote decorators require a public instance method') + } + if (hasModifier(method, ts.SyntaxKind.AbstractKeyword) || method.body === undefined) { + this.fail(method, 'Remote methods must have a concrete implementation') + } + if (!ts.isIdentifier(method.name)) { + this.fail(method, 'Remote method names must be identifiers') + } + if ((method.typeParameters?.length ?? 0) > 0) { + this.fail(method, 'generic Remote methods are not supported') + } + const methodName = method.name.text + const exportedMethod = invocation.exportName ?? methodName + + const lookups = this.lookupDeclarations() + const lookupByHost = new Map(lookups.map(lookup => [lookup.hostSymbol, lookup])) + const parameters: InvocationParameterModel[] = [] + const wires = new Set() + for (const parameter of method.parameters) { + if (!ts.isIdentifier(parameter.name)) { + this.fail(parameter, 'Remote parameters must use identifier bindings') + } + if (parameter.dotDotDotToken !== undefined) this.fail(parameter, 'Remote parameters cannot be rest parameters') + if (parameter.initializer !== undefined) this.fail(parameter, 'Remote parameters cannot have default values') + if (parameter.questionToken !== undefined) this.fail(parameter, 'Remote parameters cannot be optional') + if (parameter.name.text === 'this') this.fail(parameter, 'Remote methods cannot declare an explicit this parameter') + const authoredType = this.requiredType(parameter, parameter.type, 'parameter') + const hostSymbol = this.symbolAtType(authoredType) + const lookup = hostSymbol === undefined ? undefined : lookupByHost.get(this.symbolId(hostSymbol)) + let modeled: InvocationParameterModel + if (lookup !== undefined) { + if (parameter.name.text !== lookup.key) { + this.fail(parameter, `lookup parameter for ${lookup.key} must also be named ${lookup.key}`) + } + const boundary = this.remoteBoundary( + lookup.wireType, + `${registration.name}#${binding.namespace}/${exportedMethod}:${lookup.key}Id`, + true, + ) + modeled = { + name: parameter.name.text, + wire: `${lookup.key}Id`, + source: 'lookup', + lookup: lookup.key, + boundary, + } + } else { + if (hostSymbol !== undefined && this.isWorkspaceClass(hostSymbol)) { + this.fail(parameter, `non-JSON class parameter ${hostSymbol.name} requires a TypeRTLookupMap entry`) + } + modeled = { + name: parameter.name.text, + wire: parameter.name.text, + source: 'json', + boundary: this.remoteBoundary( + authoredType, + `${registration.name}#${binding.namespace}/${exportedMethod}:${parameter.name.text}`, + false, + ), + } + } + if (wires.has(modeled.wire)) this.fail(parameter, `duplicate Remote wire field ${modeled.wire}`) + wires.add(modeled.wire) + parameters.push(modeled) + } + + let receiver: InvocationModel['invocation'] = { kind: 'direct' } + if (invocation.kind === 'context') { + const context = this.contextDeclarations().get(invocation.context) + if (context === undefined) { + this.fail(method, `Remote Context ${invocation.context} has no TypeRTContextMap entry`) + } + const wire = `${invocation.context}Id` + if (wires.has(wire)) this.fail(method, `Remote Context wire field ${wire} conflicts with a method parameter`) + receiver = { + kind: 'context', + context: invocation.context, + wire, + boundary: this.remoteBoundary( + context.wireType, + `${registration.name}#${binding.namespace}/${exportedMethod}:${wire}`, + true, + ), + } + } + + let scope: InvocationModel['scope'] + if (invocation.kind === 'direct') { + const lookupParameters = parameters.filter(parameter => parameter.source === 'lookup') + const parameter = lookupParameters.length === 1 ? lookupParameters[0] : undefined + const context = parameter?.lookup === undefined + ? undefined + : this.contextDeclarations().get(parameter.lookup) + if (parameter !== undefined && context !== undefined) { + const contextBoundary = this.remoteBoundary( + context.wireType, + `${registration.name}#${binding.namespace}/${exportedMethod}:scope:${context.key}`, + true, + ) + if (contextBoundary.typeSymbol !== parameter.boundary.typeSymbol) { + this.fail( + method, + `Remote scope ${context.key} wire type ${contextBoundary.typeSymbol} does not match lookup wire type ${parameter.boundary.typeSymbol}`, + ) + } + scope = { context: context.key, wire: parameter.wire } + } + } + + const resultType = this.remoteResultType(method) + return { + id: `${registration.name}#${binding.namespace}/${exportedMethod}`, + service: binding.service, + namespace: binding.namespace, + method: exportedMethod, + ...(exportedMethod === methodName ? {} : { implementation: methodName }), + invocation: receiver, + ...(scope === undefined ? {} : { scope }), + parameters, + result: this.remoteBoundary( + resultType, + `${registration.name}#${binding.namespace}/${exportedMethod}:result`, + false, + ), + location: this.location(method.name), + } + } + + private gatewayBinding(declaration: ts.ClassDeclaration): GatewayBinding | undefined { + const candidates = declaration.members.filter((member): member is ts.PropertyDeclaration => + ts.isPropertyDeclaration(member) && memberName(member.name) === 'typertGateway') + const [property, duplicate] = candidates + if (property === undefined) return undefined + if (duplicate !== undefined) this.fail(duplicate, 'Service has more than one typertGateway field') + if (visibilityOf(property) !== 'public' + || hasModifier(property, ts.SyntaxKind.StaticKeyword) + || !hasModifier(property, ts.SyntaxKind.ReadonlyKeyword)) { + this.fail(property, 'typertGateway must be a public readonly instance field') + } + if (property.initializer === undefined + || !ts.isCallExpression(property.initializer) + || !this.isTypeMetaSymbol(property.initializer.expression, 'bindTypeRTGateway')) { + this.fail(property, 'typertGateway must call bindTypeRTGateway()') + } + const call = property.initializer + if (call.arguments.length < 2 || call.arguments.length > 3) { + this.fail(call, 'bindTypeRTGateway() requires this, service key, and an optional options object') + } + if (call.arguments[0]?.kind !== ts.SyntaxKind.ThisKeyword) { + this.fail(call.arguments[0] ?? call, 'bindTypeRTGateway() first argument must be this') + } + const serviceArgument = call.arguments[1] + if (serviceArgument === undefined) this.fail(call, 'bindTypeRTGateway() service key must be a string literal') + const service = stringLiteralValue(serviceArgument) + if (service === undefined) this.fail(serviceArgument, 'bindTypeRTGateway() service key must be a string literal') + let namespace = service + const options = call.arguments[2] + if (options !== undefined) { + if (!ts.isObjectLiteralExpression(options)) { + this.fail(options, 'bindTypeRTGateway() options must be an object literal') + } + for (const propertyOption of options.properties) { + if (!ts.isPropertyAssignment(propertyOption) + || memberName(propertyOption.name) !== 'namespace') { + this.fail(propertyOption, 'bindTypeRTGateway() only supports a namespace option') + } + const value = stringLiteralValue(propertyOption.initializer) + if (value === undefined) this.fail(propertyOption.initializer, 'Gateway namespace must be a string literal') + namespace = value + } + } + if (!isRemoteSegment(service)) this.fail(serviceArgument, 'Gateway service key must be nonempty and must not contain "/"') + if (!isRemoteSegment(namespace)) this.fail(options ?? call, 'Gateway namespace must be nonempty and must not contain "/"') + return { service, namespace, site: property } + } + + private remoteMarker( + member: ts.ClassElement, + ): + | { readonly kind: 'direct'; readonly exportName?: string } + | { readonly kind: 'context'; readonly context: string; readonly exportName?: string } + | undefined { + let found: + | { readonly kind: 'direct'; readonly exportName?: string } + | { readonly kind: 'context'; readonly context: string; readonly exportName?: string } + | undefined + for (const decorator of ts.canHaveDecorators(member) ? ts.getDecorators(member) ?? [] : []) { + const expression = decorator.expression + let marker: typeof found + if (this.isTypeMetaSymbol(expression, 'Remote')) { + marker = { kind: 'direct' } + } else if (ts.isCallExpression(expression) + && this.isTypeMetaSymbol(expression.expression, 'Remote')) { + if (expression.arguments.length !== 1) this.fail(expression, 'Remote() requires one exported method name') + const exportName = stringLiteralValue(expression.arguments[0]) + if (exportName === undefined || !isRemoteSegment(exportName)) { + this.fail(expression.arguments[0] ?? expression, 'Remote() name must be a nonempty string literal without "/"') + } + marker = { kind: 'direct', exportName } + } else if (ts.isCallExpression(expression) + && this.isTypeMetaSymbol(expression.expression, 'RemoteContext')) { + if (expression.arguments.length < 1 || expression.arguments.length > 2) { + this.fail(expression, 'RemoteContext() requires a Context key and optional exported method name') + } + const context = stringLiteralValue(expression.arguments[0]) + if (context === undefined || !isRemoteSegment(context)) { + this.fail(expression.arguments[0] ?? expression, 'RemoteContext() key must be a nonempty string literal without "/"') + } + const exportArgument = expression.arguments[1] + const exportName = exportArgument === undefined ? undefined : stringLiteralValue(exportArgument) + if (exportArgument !== undefined && (exportName === undefined || !isRemoteSegment(exportName))) { + this.fail(exportArgument, 'RemoteContext() name must be a nonempty string literal without "/"') + } + marker = { kind: 'context', context, ...exportName === undefined ? {} : { exportName } } + } else { + continue + } + if (found !== undefined) this.fail(decorator, 'a method can have only one Remote invocation decorator') + found = marker + } + return found + } + + private remoteResultType(method: ts.MethodDeclaration): ts.TypeNode { + const authored = this.requiredType(method, method.type, 'return') + if (!ts.isTypeReferenceNode(authored)) return authored + const symbol = this.checker.getSymbolAtLocation(authored.typeName) + const resolved = symbol === undefined ? undefined : this.resolveSymbol(symbol) + const resultType = authored.typeArguments?.[0] + if (resolved?.name !== 'Promise' || resultType === undefined || authored.typeArguments?.length !== 1) return authored + const declaration = preferredDeclaration(resolved) + if (declaration === undefined || !isStandardLibraryFile(declaration.getSourceFile().fileName)) return authored + return resultType + } + + private lookupDeclarations(): readonly StaticLookupDeclaration[] { + if (this.staticLookups !== undefined) return this.staticLookups + const byKey = new Map() + const byHost = new Map() + for (const declaration of this.typeMetaMapMembers('TypeRTLookupMap')) { + if (!ts.isPropertySignature(declaration) || declaration.type === undefined) { + this.fail(declaration, 'TypeRTLookupMap entries must be required properties') + } + const key = memberName(declaration.name) + if (!isRemoteSegment(key)) this.fail(declaration.name, 'TypeRTLookupMap key must be nonempty and must not contain "/"') + if (!ts.isTypeReferenceNode(declaration.type) + || !this.isTypeMetaSymbol(declaration.type.typeName, 'TypeRTLookup') + || declaration.type.typeArguments?.length !== 2) { + this.fail(declaration.type, 'TypeRTLookupMap values must be TypeRTLookup') + } + const hostType = declaration.type.typeArguments[0] + const wireType = declaration.type.typeArguments[1] + if (hostType === undefined || wireType === undefined) { + this.fail(declaration.type, 'TypeRTLookupMap values must be TypeRTLookup') + } + const host = this.symbolAtType(hostType) + if (host === undefined) this.fail(hostType, 'TypeRTLookup Host must be a named type') + const entry: StaticLookupDeclaration = { + key, + hostSymbol: this.symbolId(host), + wireType, + site: declaration, + } + if (byKey.has(key)) this.fail(declaration, `duplicate TypeRTLookupMap key ${key}`) + if (byHost.has(entry.hostSymbol)) this.fail(declaration, `Host type ${host.name} has more than one TypeRT lookup`) + byKey.set(key, entry) + byHost.set(entry.hostSymbol, entry) + } + this.staticLookups = [...byKey.values()] + return this.staticLookups + } + + private contextDeclarations(): ReadonlyMap { + if (this.staticContexts !== undefined) return this.staticContexts + const result = new Map() + for (const declaration of this.typeMetaMapMembers('TypeRTContextMap')) { + if (!ts.isPropertySignature(declaration) || declaration.type === undefined) { + this.fail(declaration, 'TypeRTContextMap entries must be required properties') + } + const key = memberName(declaration.name) + if (!isRemoteSegment(key)) this.fail(declaration.name, 'TypeRTContextMap key must be nonempty and must not contain "/"') + if (!ts.isTypeReferenceNode(declaration.type) + || !this.isTypeMetaSymbol(declaration.type.typeName, 'TypeRTContext') + || declaration.type.typeArguments?.length !== 1) { + this.fail(declaration.type, 'TypeRTContextMap values must be TypeRTContext') + } + if (result.has(key)) this.fail(declaration, `duplicate TypeRTContextMap key ${key}`) + const wireType = declaration.type.typeArguments[0] + if (wireType === undefined) this.fail(declaration.type, 'TypeRTContextMap values must be TypeRTContext') + result.set(key, { + key, + wireType, + site: declaration, + }) + } + this.staticContexts = result + return result + } + + private typeMetaMapMembers(name: 'TypeRTLookupMap' | 'TypeRTContextMap'): ts.TypeElement[] { + const result: ts.TypeElement[] = [] + for (const sourceFile of this.program.getSourceFiles()) { + for (const statement of sourceFile.statements) { + if (!ts.isModuleDeclaration(statement) + || !ts.isStringLiteral(statement.name) + || statement.name.text !== '@deepseek-ai/dsh-type-meta' + || statement.body === undefined + || !ts.isModuleBlock(statement.body)) continue + for (const nested of statement.body.statements) { + if (ts.isInterfaceDeclaration(nested) && nested.name.text === name) result.push(...nested.members) + } + } + } + return result + } + + private remoteBoundary( + authoredType: ts.TypeNode, + fallbackTypeSymbol: string, + requireNamed: boolean, + ): RemoteBoundaryModel { + const type = this.convertType(authoredType) + const codecType = this.resolvedRemoteCodecType(authoredType) + const rootSymbol = this.namedWorkspaceType(authoredType) + if (rootSymbol !== undefined) { + const imported = this.publicRemoteType(rootSymbol, authoredType) + return { + type, + codecType, + typeSymbol: `${imported.specifier}#${imported.name}`, + imports: [imported], + } + } + if (requireNamed) this.fail(authoredType, 'lookup and Context wire types must be named public types') + const imports = new Map() + const visit = (node: ts.Node): void => { + if ((ts.isTypeReferenceNode(node) || ts.isImportTypeNode(node))) { + const symbol = ts.isTypeReferenceNode(node) + ? this.checker.getSymbolAtLocation(node.typeName) + : node.qualifier === undefined ? undefined : this.checker.getSymbolAtLocation(node.qualifier) + if (symbol !== undefined) { + const resolved = this.resolveSymbol(symbol) + const declaration = preferredDeclaration(resolved) + if (declaration !== undefined + && !isStandardLibraryFile(declaration.getSourceFile().fileName) + && this.registrationForFile(declaration.getSourceFile().fileName) !== undefined) { + const imported = this.publicRemoteType(resolved, node) + imports.set(imported.symbol, imported) + return + } + } + } + ts.forEachChild(node, visit) + } + visit(authoredType) + return { + type, + codecType, + typeSymbol: fallbackTypeSymbol, + imports: [...imports.values()].sort((left, right) => + left.specifier.localeCompare(right.specifier) || left.name.localeCompare(right.name)), + } + } + + /** + * Project one authored Remote boundary through the complete face Program. + * Consumer declarations retain the authored alias, while codecs use this + * concrete graph so declaration-merged mapped and conditional types are + * validated without teaching the compiler-independent emitter TypeScript's + * type evaluator. + */ + private resolvedRemoteCodecType(authoredType: ts.TypeNode): TypeNodeId { + const completed = new Map() + const active = new Map() + const recursiveDeclarations = new Map() + const convert = (type: ts.Type): TypeNodeId => { + const cached = completed.get(type) + if (cached !== undefined) return cached + const activeId = active.get(type) + if (activeId !== undefined) { + if (this.checker.isArrayType(type) || this.checker.isArrayLikeType(type)) { + const element = this.checker.getIndexTypeOfType(type, ts.IndexKind.Number) + const elementId = element === undefined ? undefined : active.get(element) + if (element !== undefined && elementId !== undefined) { + return this.addNode(authoredType, { + kind: 'array', + element: this.resolvedCycleReference( + element, + authoredType, + elementId, + recursiveDeclarations, + ), + }) + } + } + return this.resolvedCycleReference(type, authoredType, activeId, recursiveDeclarations) + } + const id = this.allocateNodeId(authoredType) + active.set(type, id) + try { + const add = (model: TypeNodeInput): TypeNodeId => { + this.nodes.set(id, { id, ...model }) + completed.set(type, id) + return id + } + const flags = type.flags + if ((flags & ts.TypeFlags.Any) !== 0) return add({ kind: 'keyword', name: 'any' }) + if ((flags & ts.TypeFlags.Unknown) !== 0) return add({ kind: 'keyword', name: 'unknown' }) + if ((flags & ts.TypeFlags.Never) !== 0) return add({ kind: 'keyword', name: 'never' }) + if ((flags & ts.TypeFlags.String) !== 0) return add({ kind: 'keyword', name: 'string' }) + if ((flags & ts.TypeFlags.Number) !== 0) return add({ kind: 'keyword', name: 'number' }) + if ((flags & ts.TypeFlags.BigInt) !== 0) return add({ kind: 'keyword', name: 'bigint' }) + if ((flags & ts.TypeFlags.Boolean) !== 0) return add({ kind: 'keyword', name: 'boolean' }) + if ((flags & ts.TypeFlags.ESSymbol) !== 0) return add({ kind: 'keyword', name: 'symbol' }) + if ((flags & ts.TypeFlags.Undefined) !== 0) return add({ kind: 'keyword', name: 'undefined' }) + if ((flags & ts.TypeFlags.Void) !== 0) return add({ kind: 'keyword', name: 'void' }) + if ((flags & ts.TypeFlags.Null) !== 0) return add({ kind: 'literal', value: null, text: 'null' }) + if ((flags & ts.TypeFlags.StringLiteral) !== 0) { + const value = (type as ts.StringLiteralType).value + return add({ kind: 'literal', value, text: JSON.stringify(value) }) + } + if ((flags & ts.TypeFlags.NumberLiteral) !== 0) { + const value = (type as ts.NumberLiteralType).value + return add({ kind: 'literal', value, text: String(value) }) + } + if ((flags & ts.TypeFlags.BigIntLiteral) !== 0) { + const value = (type as ts.BigIntLiteralType).value + const text = `${value.negative ? '-' : ''}${value.base10Value}n` + return add({ kind: 'literal', value: BigInt(`${value.negative ? '-' : ''}${value.base10Value}`), text }) + } + if ((flags & ts.TypeFlags.BooleanLiteral) !== 0) { + const value = (type as ts.Type & { readonly intrinsicName?: string }).intrinsicName === 'true' + return add({ kind: 'literal', value, text: String(value) }) + } + if (type.isUnionOrIntersection()) { + return add({ + kind: (flags & ts.TypeFlags.Union) !== 0 ? 'union' : 'intersection', + types: type.types.map(convert), + }) + } + if ((flags & ts.TypeFlags.TypeParameter) !== 0) { + this.fail(authoredType, 'Remote codec contains an unresolved type parameter') + } + if ((flags & ts.TypeFlags.Object) === 0) { + this.fail( + authoredType, + `Remote codec type ${this.checker.typeToString(type, authoredType, ts.TypeFormatFlags.NoTruncation)} has no concrete Zod projection`, + ) + } + if (this.checker.isTupleType(type)) { + const reference = type as ts.TypeReference + const target = reference.target as ts.TupleType + const arguments_ = this.checker.getTypeArguments(reference) + return add({ + kind: 'tuple', + elements: arguments_.map((argument, index) => { + const elementFlags = target.elementFlags[index] ?? ts.ElementFlags.Required + return { + type: convert(argument), + optional: (elementFlags & ts.ElementFlags.Optional) !== 0, + rest: (elementFlags & (ts.ElementFlags.Rest | ts.ElementFlags.Variadic)) !== 0, + } + }), + }) + } + if (this.checker.isArrayType(type) || this.checker.isArrayLikeType(type)) { + const element = this.checker.getIndexTypeOfType(type, ts.IndexKind.Number) + if (element === undefined) this.fail(authoredType, 'Remote codec array has no element type') + return add({ kind: 'array', element: convert(element) }) + } + if (type.getCallSignatures().length > 0 || type.getConstructSignatures().length > 0) { + this.fail(authoredType, 'Remote codec cannot contain callable or constructable values') + } + const members: MemberModel[] = [] + for (const property of this.checker.getPropertiesOfType(type)) { + const declaration = property.valueDeclaration ?? property.declarations?.[0] + const propertyType = this.checker.getTypeOfSymbolAtLocation(property, declaration ?? authoredType) + const symbolKey = property.getName() + members.push({ + ...EMPTY_DOCUMENTATION, + id: `${id}#${symbolKey}`, + name: symbolKey, + ...(symbolKey.startsWith('__@') ? { computed: 'symbol' as const } : {}), + optional: (property.flags & ts.SymbolFlags.Optional) !== 0, + readonly: declaration !== undefined && hasModifier(declaration, ts.SyntaxKind.ReadonlyKeyword), + async: false, + abstract: false, + static: false, + visibility: 'public', + location: this.location(authoredType), + text: '', + kind: 'property', + type: convert(propertyType), + }) + } + for (const [index, info] of this.checker.getIndexInfosOfType(type).entries()) { + members.push({ + ...EMPTY_DOCUMENTATION, + id: `${id}#index:${String(index)}`, + name: '(index)', + optional: false, + readonly: info.isReadonly, + async: false, + abstract: false, + static: false, + visibility: 'public', + location: this.location(authoredType), + text: '', + kind: 'index', + signature: { + typeParameters: [], + parameters: [{ + name: 'key', + binding: 'identifier', + type: convert(info.keyType), + optional: false, + rest: false, + receiver: false, + }], + returns: convert(info.type), + }, + }) + } + return add({ kind: 'object', members }) + } finally { + active.delete(type) + } + } + return convert(this.checker.getTypeFromTypeNode(authoredType)) + } + + private resolvedCycleReference( + type: ts.Type, + site: ts.TypeNode, + resolvedType: TypeNodeId, + recursiveDeclarations: Map, + ): TypeNodeId { + const symbol = type.aliasSymbol ?? type.getSymbol() + if (symbol === undefined) this.fail(site, 'Remote codec contains an unnamed recursive type') + const resolved = this.resolveSymbol(symbol) + const declaration = preferredDeclaration(resolved) + if (declaration === undefined || isStandardLibraryFile(declaration.getSourceFile().fileName)) { + this.fail(site, `Remote codec recursive type ${resolved.name} has no workspace declaration`) + } + const owner = this.registrationForFile(declaration.getSourceFile().fileName) + if (owner === undefined) this.fail(site, `Remote codec recursive type ${resolved.name} is not owned by this face`) + let id = recursiveDeclarations.get(type) + if (id === undefined) { + id = `${this.symbolId(resolved)}#remote-codec:${resolvedType}` + recursiveDeclarations.set(type, id) + this.declarations.set(id, { + ...EMPTY_DOCUMENTATION, + id, + package: owner.name, + name: `${resolved.name}RemoteCodec`, + kind: 'alias', + abstract: false, + exported: false, + location: this.location(declaration), + text: '', + typeParameters: [], + extends: [], + implements: [], + members: [], + type: resolvedType, + }) + } + return this.addNode(site, { + kind: 'reference', + name: `${resolved.name}RemoteCodec`, + target: { kind: 'declaration', symbol: id }, + arguments: [], + }) + } + + private namedWorkspaceType(node: ts.TypeNode): ts.Symbol | undefined { + if (!ts.isTypeReferenceNode(node) && !ts.isImportTypeNode(node)) return undefined + const symbol = ts.isTypeReferenceNode(node) + ? this.checker.getSymbolAtLocation(node.typeName) + : node.qualifier === undefined ? undefined : this.checker.getSymbolAtLocation(node.qualifier) + if (symbol === undefined) return undefined + const resolved = this.resolveSymbol(symbol) + const declaration = preferredDeclaration(resolved) + if (declaration === undefined + || isStandardLibraryFile(declaration.getSourceFile().fileName) + || this.registrationForFile(declaration.getSourceFile().fileName) === undefined) return undefined + return resolved + } + + private publicRemoteType(symbol: ts.Symbol, site: ts.Node): RemoteTypeImportModel { + const declaration = preferredDeclaration(symbol) + if (declaration === undefined) this.fail(site, `type ${symbol.name} has no declaration`) + const registration = this.registrationForFile(declaration.getSourceFile().fileName) + if (registration === undefined) this.fail(site, `type ${symbol.name} is not owned by a workspace package`) + const candidates: RemoteTypeImportModel[] = [] + for (const [subpath, target] of packageExportTargets(registration.manifest)) { + if (subpath === '.' || subpath === './package.json' || subpath === './typert' + || subpath === './client/typert' || subpath === './remote' || target.includes('*')) continue + const sourceFile = this.sourceFiles.get(realPath(sourcePathForExport(registration.root, target))) + if (sourceFile === undefined) continue + const moduleSymbol = this.checker.getSymbolAtLocation(sourceFile) + if (moduleSymbol === undefined) continue + for (const exported of this.checker.getExportsOfModule(moduleSymbol)) { + if (this.resolveSymbol(exported) !== symbol) continue + candidates.push({ + symbol: this.symbolId(symbol), + specifier: packageExportSpecifier(registration.name, subpath), + name: exported.name, + }) + } + } + const selected = candidates.sort((left, right) => + left.specifier.localeCompare(right.specifier) || left.name.localeCompare(right.name))[0] + if (selected === undefined) { + this.fail(site, `Remote boundary type ${symbol.name} must be exported from a public non-root type subpath`) + } + return selected + } + + private isWorkspaceClass(symbol: ts.Symbol): boolean { + const declaration = preferredDeclaration(symbol) + return declaration !== undefined + && ts.isClassDeclaration(declaration) + && this.registrationForFile(declaration.getSourceFile().fileName) !== undefined + } + + private isTypeMetaSymbol(node: ts.Node, name: string): boolean { + const symbol = this.checker.getSymbolAtLocation(node) + if (symbol === undefined) return false + const resolved = this.resolveSymbol(symbol) + if (resolved.name !== name) return false + const declaration = preferredDeclaration(resolved) + if (declaration === undefined) return false + const registration = this.registrationForFile(declaration.getSourceFile().fileName) + if (registration?.name === '@deepseek-ai/dsh-type-meta') return true + for (let current: ts.Node | undefined = declaration; current !== undefined; current = optionalParent(current)) { + if (ts.isModuleDeclaration(current) + && ts.isStringLiteral(current.name) + && current.name.text === '@deepseek-ai/dsh-type-meta') return true + } + return false + } + + private validateInvocationIdentity(packages: readonly PackageModel[]): void { + const endpoints = new Map() + const ids = new Map() + for (const invocation of packages.flatMap(packageModel => packageModel.invocations)) { + const endpoint = `${invocation.namespace}/${invocation.method}` + const existingEndpoint = endpoints.get(endpoint) + if (existingEndpoint !== undefined) { + throw new TypertAnalysisError( + `typert(${this.face}): ${invocation.location.file}:${String(invocation.location.line)}:${String(invocation.location.column)}: Remote endpoint ${endpoint} conflicts with ${existingEndpoint.id}`, + ) + } + const existingId = ids.get(invocation.id) + if (existingId !== undefined) { + throw new TypertAnalysisError( + `typert(${this.face}): ${invocation.location.file}:${String(invocation.location.line)}:${String(invocation.location.column)}: Remote invocation id ${invocation.id} conflicts with ${existingId.id}`, + ) + } + endpoints.set(endpoint, invocation) + ids.set(invocation.id, invocation) + } + } + private collectEvents(events: ts.InterfaceDeclaration): EventModel[] { const result: EventModel[] = [] for (const member of events.members) { @@ -1015,6 +1777,11 @@ class FaceAnalyzer { ): MemberModel[] { const result: MemberModel[] = [] for (const member of members) { + if (ts.isPropertyDeclaration(member) + && memberName(member.name) === 'typertGateway' + && member.initializer !== undefined + && ts.isCallExpression(member.initializer) + && this.isTypeMetaSymbol(member.initializer.expression, 'bindTypeRTGateway')) continue const visibility = visibilityOf(member) const isStatic = hasModifier(member, ts.SyntaxKind.StaticKeyword) if (visibility !== 'public' || isStatic || ts.isConstructorDeclaration(member)) continue @@ -1045,17 +1812,19 @@ class FaceAnalyzer { visibility: MemberVisibility, isStatic: boolean, ): MemberBase { - const name = member.name !== undefined - ? memberName(member.name) - : ts.isCallSignatureDeclaration(member) - ? '(call)' - : ts.isConstructSignatureDeclaration(member) - ? '(construct)' - : '(index)' + const identity = member.name !== undefined + ? this.memberIdentity(member.name) + : { + name: ts.isCallSignatureDeclaration(member) + ? '(call)' + : ts.isConstructSignatureDeclaration(member) + ? '(construct)' + : '(index)', + } return { ...documentationOf(member), - id: `${ownerId}#${name}@${String(member.getStart())}`, - name, + id: `${ownerId}#${identity.name}@${String(member.getStart())}`, + ...identity, optional: 'questionToken' in member && member.questionToken !== undefined, readonly: hasModifier(member, ts.SyntaxKind.ReadonlyKeyword), async: hasModifier(member, ts.SyntaxKind.AsyncKeyword), @@ -1067,6 +1836,20 @@ class FaceAnalyzer { } } + private memberIdentity(name: ts.PropertyName): Pick { + if (!ts.isComputedPropertyName(name)) return { name: memberName(name) } + const expression = name.expression + if (ts.isStringLiteral(expression) || ts.isNumericLiteral(expression) + || ts.isNoSubstitutionTemplateLiteral(expression)) { + return { name: memberName(name), jsonName: expression.text } + } + const type = this.checker.getTypeAtLocation(expression) + return { + name: memberName(name), + computed: (type.flags & ts.TypeFlags.UniqueESSymbol) !== 0 ? 'symbol' : 'dynamic', + } + } + private signature( node: ts.SignatureDeclarationBase, explicitReturn: ts.TypeNode | undefined, @@ -1570,7 +2353,23 @@ function sourceFileHasSurface(sourceFile: ts.SourceFile): boolean { || ts.isInterfaceDeclaration(statement) || ts.isTypeAliasDeclaration(statement) || ts.isEnumDeclaration(statement)) - && typertMode(statement) !== undefined) return true + && (typertMode(statement) !== undefined || typertServiceTag(statement) !== undefined)) return true + if (ts.isClassDeclaration(statement)) { + for (const member of statement.members) { + if (ts.isPropertyDeclaration(member) + && memberName(member.name) === 'typertGateway' + && member.initializer !== undefined + && ts.isCallExpression(member.initializer) + && expressionName(member.initializer.expression) === 'bindTypeRTGateway') return true + for (const decorator of ts.canHaveDecorators(member) ? ts.getDecorators(member) ?? [] : []) { + const expression = ts.isCallExpression(decorator.expression) + ? decorator.expression.expression + : decorator.expression + const name = expressionName(expression) + if (name === 'Remote' || name === 'RemoteContext') return true + } + } + } if (!ts.isModuleDeclaration(statement) || !ts.isStringLiteral(statement.name) || statement.name.text !== 'cordis' @@ -1588,6 +2387,7 @@ function hasPackageSurface(model: PackageModel): boolean { || model.events.length > 0 || model.objects.length > 0 || model.schemas.length > 0 + || model.invocations.length > 0 } function isDualFacePackage(manifest: Record): boolean { @@ -1599,7 +2399,9 @@ function isDualFacePackage(manifest: Record): boolean { function hostExportSubpaths(manifest: Record): string[] { return packageExportTargets(manifest) .map(([subpath]) => subpath) - .filter(subpath => subpath !== './client' && !subpath.startsWith('./client/')) + .filter(subpath => subpath !== './client' + && !subpath.startsWith('./client/') + && subpath !== './remote') } function clientExportSubpaths(manifest: Record): string[] { @@ -1668,6 +2470,10 @@ function preferredDeclaration(symbol: ts.Symbol): ts.Declaration | undefined { ?? symbol.declarations?.[0] } +function optionalParent(node: ts.Node): ts.Node | undefined { + return (node as ts.Node & { readonly parent?: ts.Node }).parent +} + function isTypeDeclaration( node: ts.Node, ): node is ts.ClassDeclaration | ts.InterfaceDeclaration | ts.TypeAliasDeclaration | ts.EnumDeclaration { @@ -1822,6 +2628,11 @@ function typertMode(node: ts.Node): 'object' | 'schema' | undefined { return undefined } +function typertServiceTag(node: ts.Node): ts.JSDocTag | undefined { + return ts.getJSDocTags(node).find(tag => tag.tagName.text === 'typert' + && (ts.getTextOfJSDocComment(tag.comment) ?? '').trim().split(/\s+/, 1)[0] === 'service') +} + function memberName(name: ts.PropertyName | ts.BindingName): string { if (ts.isIdentifier(name) || ts.isPrivateIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name) || ts.isNoSubstitutionTemplateLiteral(name)) return name.text @@ -1829,6 +2640,26 @@ function memberName(name: ts.PropertyName | ts.BindingName): string { return name.getText() } +function stringLiteralValue(node: ts.Node | undefined): string | undefined { + return node !== undefined && (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) + ? node.text + : undefined +} + +function isRemoteSegment(value: string): boolean { + return value.length > 0 && !value.includes('/') +} + +function expressionName(node: ts.Expression): string | undefined { + if (ts.isIdentifier(node)) return node.text + if (ts.isPropertyAccessExpression(node)) return node.name.text + return undefined +} + +function packageExportSpecifier(packageName: string, subpath: string): string { + return subpath === '.' ? packageName : `${packageName}${subpath.slice(1)}` +} + function visibilityOf(node: ts.Node): MemberVisibility { if ('name' in node && node.name !== undefined && ts.isPrivateIdentifier(node.name as ts.Node)) return 'private' if (hasModifier(node, ts.SyntaxKind.PrivateKeyword)) return 'private' diff --git a/packages/typert/generator/src/cordis-catalog.ts b/packages/typert/generator/src/cordis-catalog.ts index 1bcb1ca72a..e5c2c15a00 100644 --- a/packages/typert/generator/src/cordis-catalog.ts +++ b/packages/typert/generator/src/cordis-catalog.ts @@ -231,7 +231,7 @@ export class CordisCatalogProjector { for (const service of packageModel.services) { const declaration = this.renderer.declaration(service.symbol) if (declaration.kind !== 'class' - || !/^packages\/[^/]+\/[^/]+\/src\/index\.ts$/.test(service.location.file) + || !/^packages\/[^/]+\/[^/]+\/src\/[^/]+\.ts$/.test(service.location.file) || declaration.location.file !== service.location.file) continue const doc = parseJsDoc(declaration.jsDoc ?? '').doc const source = pointer(declaration.location) diff --git a/packages/typert/generator/src/emitter.ts b/packages/typert/generator/src/emitter.ts index 4a09eaad68..3e79780593 100644 --- a/packages/typert/generator/src/emitter.ts +++ b/packages/typert/generator/src/emitter.ts @@ -4,11 +4,17 @@ * @module @deepseek-ai/dsh-typert-generator/emitter */ +import { Buffer } from 'node:buffer' +import { posix } from 'node:path' +import { GenMapping, addMapping, toEncodedMap } from '@jridgewell/gen-mapping' import type { DocumentationModel, FaceModel, + InvocationModel, MemberModel, PackageModel, + RemoteBoundaryModel, + RemoteTypeImportModel, SchemaModel, SymbolId, TypeDeclarationModel, @@ -29,6 +35,14 @@ export interface ModelEmitResult { readonly exports: readonly string[] readonly js: string readonly dts: string + readonly remote?: RemoteModelEmitResult +} + +/** Host-for-Client Remote contribution generated from the Host Program. */ +export interface RemoteModelEmitResult { + readonly js: string + readonly dts: string + readonly dtsMap: string } interface RuntimeMemberModel { @@ -92,7 +106,11 @@ export class FaceModelEmitter { if (packageModel === undefined) { throw new TypertEmitError(`typert emitter(${this.face.face}): package ${packageName} is not modeled on this face`) } - const schemas = new SchemaEmitter(this.renderer, packageModel.schemas) + const schemas = new SchemaEmitter( + this.renderer, + packageModel.schemas, + invocationBoundaryRoots(packageModel.invocations), + ) const schemaArtifact = schemas.emit() const runtimeModel = this.runtimeModel(packageModel) const js = this.renderJs(packageModel, schemaArtifact, runtimeModel) @@ -103,6 +121,9 @@ export class FaceModelEmitter { exports: packageModel.schemas.map(schema => schema.export.name), js, dts, + ...(this.face.face === 'host' && packageModel.invocations.length > 0 + ? { remote: this.emitRemote(packageModel) } + : {}), } } @@ -184,6 +205,11 @@ export class FaceModelEmitter { lines.push(` { name: ${quote(schema.exportName)}, schema: ${schema.exportName} },`) } lines.push(' ],') + lines.push(' invocations: [') + for (const invocation of packageModel.invocations) { + lines.push(`${indent(this.invocationLiteral(invocation, schemas), 4)},`) + } + lines.push(' ],') lines.push(` model: ${indent(model, 2).trimStart()},`) lines.push('}') return `${lines.join('\n')}\n` @@ -215,6 +241,246 @@ export class FaceModelEmitter { lines.push('export declare const TYPERT: unknown') return `${lines.join('\n')}\n` } + + private emitRemote(packageModel: PackageModel): RemoteModelEmitResult { + const schemas = new SchemaEmitter( + this.renderer, + [], + invocationBoundaryRoots(packageModel.invocations), + ).emit() + const lines = [ + '/* Generated by @deepseek-ai/dsh-typert-generator from the Host FaceModel — do not edit. */', + ] + if (schemas.definitions.length > 0) lines.push('import { z } from \'zod\'', '') + lines.push(...schemas.definitions) + if (schemas.definitions.length > 0) lines.push('') + lines.push('export const TYPERT_REMOTE = {') + lines.push(` package: ${quote(packageModel.name)},`) + lines.push(' descriptors: [') + for (const invocation of packageModel.invocations) { + lines.push(`${indent(this.invocationLiteral(invocation, schemas), 4)},`) + } + lines.push(' ],') + lines.push('}') + lines.push('') + lines.push('export default TYPERT_REMOTE') + const declaration = this.renderRemoteDts(packageModel) + return { + js: `${lines.join('\n')}\n`, + ...declaration, + } + } + + private invocationLiteral(invocation: InvocationModel, schemas: SchemaArtifact): string { + const lines = [ + '{', + ` id: ${quote(invocation.id)},`, + ` service: ${quote(invocation.service)},`, + ` namespace: ${quote(invocation.namespace)},`, + ` method: ${quote(invocation.method)},`, + ] + if (invocation.implementation !== undefined) { + lines.push(` implementation: ${quote(invocation.implementation)},`) + } + if (invocation.invocation.kind === 'direct') { + lines.push(' invocation: { kind: \'direct\' },') + } else { + lines.push(' invocation: {') + lines.push(' kind: \'context\',') + lines.push(` context: ${quote(invocation.invocation.context)},`) + lines.push(` wire: ${quote(invocation.invocation.wire)},`) + lines.push(` codec: ${indent(strictCodec( + invocation.invocation.boundary, + schemas.boundary(contextBoundaryKey(invocation)), + ), 4).trimStart()},`) + lines.push(' },') + } + if (invocation.scope !== undefined) { + lines.push(' scope: {') + lines.push(` context: ${quote(invocation.scope.context)},`) + lines.push(` wire: ${quote(invocation.scope.wire)},`) + lines.push(' },') + } + lines.push(' parameters: [') + invocation.parameters.forEach((parameter, index) => { + lines.push(' {') + lines.push(` name: ${quote(parameter.name)},`) + lines.push(` wire: ${quote(parameter.wire)},`) + lines.push(` source: ${quote(parameter.source)},`) + if (parameter.lookup !== undefined) lines.push(` lookup: ${quote(parameter.lookup)},`) + lines.push(` codec: ${indent(strictCodec( + parameter.boundary, + schemas.boundary(parameterBoundaryKey(invocation, index)), + ), 6).trimStart()},`) + lines.push(' },') + }) + lines.push(' ],') + lines.push(` result: ${indent(strictCodec( + invocation.result, + schemas.boundary(resultBoundaryKey(invocation)), + ), 2).trimStart()},`) + lines.push(` sourceLocation: ${JSON.stringify(invocation.location)},`) + lines.push('}') + return lines.join('\n') + } + + private renderRemoteDts(packageModel: PackageModel): Pick { + const imports = remoteImports(packageModel.invocations) + const referenceNames = allocateRemoteImportNames(imports) + const grouped = new Map() + for (const imported of imports) { + const values = grouped.get(imported.specifier) ?? [] + values.push({ + name: imported.name, + local: referenceNames.get(imported.symbol) as string, + }) + grouped.set(imported.specifier, values) + } + const lines = [ + '/* Generated by @deepseek-ai/dsh-typert-generator from the Host FaceModel — do not edit. */', + 'import type {', + ' TypeRTRemoteContribution,', + '} from \'@deepseek-ai/dsh-type-meta\'', + ] + const sourceMap = new GenMapping({ file: 'typert.remote-client.d.ts' }) + for (const [specifier, values] of [...grouped].sort(([left], [right]) => left.localeCompare(right))) { + const names = values.sort((left, right) => left.local.localeCompare(right.local)).map(value => + value.name === value.local ? value.name : `${value.name} as ${value.local}`) + lines.push(`import type { ${names.join(', ')} } from ${quote(specifier)}`) + } + lines.push('') + lines.push('declare module \'@deepseek-ai/dsh-type-meta\' {') + const direct = packageModel.invocations.filter(invocation => invocation.invocation.kind === 'direct') + const scoped = packageModel.invocations.filter(invocation => + invocation.invocation.kind === 'context' || invocation.scope !== undefined) + if (direct.length > 0) { + for (const namespace of uniqueNamespaces(direct)) { + lines.push(` interface ${remoteNamespaceInterface(namespace)} {`) + for (const invocation of direct.filter(candidate => candidate.namespace === namespace)) { + this.pushRemoteNamespaceSignature(lines, sourceMap, packageModel, invocation, referenceNames) + } + lines.push(' }') + } + lines.push(' interface TypeRTRemoteMap {') + for (const invocation of direct) { + this.pushRemoteSignature(lines, sourceMap, packageModel, invocation, referenceNames, false) + } + lines.push(' }') + lines.push(' interface TypeRTRemoteNamespaceMap {') + for (const namespace of uniqueNamespaces(direct)) { + lines.push(` ${quote(namespace)}: ${remoteNamespaceInterface(namespace)}`) + } + lines.push(' }') + } + if (scoped.length > 0) { + lines.push(' interface TypeRTRemoteContextMap {') + for (const invocation of scoped) { + this.pushRemoteSignature(lines, sourceMap, packageModel, invocation, referenceNames, true) + } + lines.push(' }') + } + lines.push('}') + lines.push('') + lines.push('export declare const TYPERT_REMOTE: TypeRTRemoteContribution') + lines.push('export default TYPERT_REMOTE') + lines.push('//# sourceMappingURL=typert.remote-client.d.ts.map') + return { + dts: `${lines.join('\n')}\n`, + dtsMap: `${JSON.stringify(toEncodedMap(sourceMap))}\n`, + } + } + + private pushRemoteSignature( + lines: string[], + sourceMap: GenMapping, + packageModel: PackageModel, + invocation: InvocationModel, + referenceNames: ReadonlyMap, + scoped: boolean, + ): void { + const signature = this.remoteSignature(invocation, referenceNames, scoped) + const line = ` ${signature}` + lines.push(line) + const generatedLine = lines.length + const keyLength = signature.indexOf(': (') + if (keyLength < 0) throw new TypertEmitError(`Remote signature ${invocation.id} has no property delimiter`) + const source = remoteDeclarationSource(packageModel, invocation) + addMapping(sourceMap, { + generated: { line: generatedLine, column: 4 }, + source, + original: { line: invocation.location.line, column: invocation.location.column - 1 }, + name: invocation.method, + }) + addMapping(sourceMap, { + generated: { line: generatedLine, column: 4 + keyLength }, + }) + } + + private pushRemoteNamespaceSignature( + lines: string[], + sourceMap: GenMapping, + packageModel: PackageModel, + invocation: InvocationModel, + referenceNames: ReadonlyMap, + ): void { + const signature = `${invocation.method}: ${this.remoteFunctionType(invocation, referenceNames, false)}` + lines.push(` ${signature}`) + const generatedLine = lines.length + const source = remoteDeclarationSource(packageModel, invocation) + addMapping(sourceMap, { + generated: { line: generatedLine, column: 4 }, + source, + original: { line: invocation.location.line, column: invocation.location.column - 1 }, + name: invocation.method, + }) + addMapping(sourceMap, { + generated: { line: generatedLine, column: 4 + invocation.method.length }, + }) + } + + private remoteSignature( + invocation: InvocationModel, + referenceNames: ReadonlyMap, + scoped: boolean, + ): string { + const context = invocation.invocation.kind === 'context' + ? invocation.invocation.context + : invocation.scope?.context + const key = scoped + ? `${context as string}:${invocation.namespace}/${invocation.method}` + : `${invocation.namespace}/${invocation.method}` + return `${quote(key)}: ${this.remoteFunctionType(invocation, referenceNames, scoped)}` + } + + private remoteFunctionType( + invocation: InvocationModel, + referenceNames: ReadonlyMap, + scoped: boolean, + ): string { + const parameters = invocation.parameters.filter(parameter => + !scoped || invocation.invocation.kind === 'context' || parameter.wire !== invocation.scope?.wire).map(parameter => + `${safeIdentifier(parameter.wire)}: ${this.renderer.renderType(parameter.boundary.type, referenceNames)}`) + const result = this.renderer.renderType(invocation.result.type, referenceNames) + return `(${parameters.join(', ')}) => Promise<${result}>` + } +} + +function remoteDeclarationSource(packageModel: PackageModel, invocation: InvocationModel): string { + const relativeSource = posix.relative(packageModel.root, invocation.location.file) + if (relativeSource === '' || relativeSource === '..' || relativeSource.startsWith('../') || posix.isAbsolute(relativeSource)) { + throw new TypertEmitError( + `Remote declaration ${invocation.id} is outside its package root ${packageModel.root}`, + ) + } + return posix.join('..', relativeSource) +} + +function uniqueNamespaces(invocations: readonly InvocationModel[]): string[] { + return [...new Set(invocations.map(invocation => invocation.namespace))].sort() +} + +function remoteNamespaceInterface(namespace: string): string { + return `TypeRTRemoteNamespace$${Buffer.from(namespace, 'utf8').toString('hex')}` } interface SchemaExport { @@ -226,15 +492,23 @@ interface SchemaExport { interface SchemaArtifact { readonly definitions: readonly string[] readonly exports: readonly SchemaExport[] + boundary(key: string): string +} + +interface BoundarySchemaRoot { + readonly key: string + readonly type: TypeNodeId } class SchemaEmitter { private readonly names = new Map() + private readonly boundaryNames = new Map() private readonly declarations: TypeDeclarationModel[] constructor( private readonly renderer: TypeGraphRenderer, private readonly schemas: readonly SchemaModel[], + private readonly boundaries: readonly BoundarySchemaRoot[], ) { const declarations = new Map() for (const schema of schemas) { @@ -242,6 +516,11 @@ class SchemaEmitter { declarations.set(declaration.id, declaration) } } + for (const boundary of boundaries) { + for (const declaration of renderer.declarationClosureForTypes([boundary.type])) { + declarations.set(declaration.id, declaration) + } + } this.declarations = renderer.graph.declarations.filter(declaration => declarations.has(declaration.id)) const identifiers = new Set() for (const declaration of this.declarations) { @@ -252,65 +531,92 @@ class SchemaEmitter { identifiers.add(name) this.names.set(declaration.id, name) } + for (const boundary of boundaries) { + const base = `${safeIdentifier(boundary.key)}$schema` + let name = base + let suffix = 2 + while (identifiers.has(name)) name = `${base}${String(suffix++)}` + identifiers.add(name) + this.boundaryNames.set(boundary.key, name) + } } emit(): SchemaArtifact { - const definitions = this.declarations.map((declaration) => { - if (declaration.typeParameters.length > 0) { - this.fail(declaration.name, 'generic declarations require a schema-factory projection') - } - return `const ${this.schemaName(declaration.id)} = ${this.declarationSchema(declaration)}` - }) + const definitions = this.declarations.map(declaration => this.declarationDefinition(declaration)) + for (const boundary of this.boundaries) { + definitions.push(`const ${this.boundaryName(boundary.key)} = ${this.typeSchema(boundary.type)}`) + } const exports = this.schemas.map((model): SchemaExport => ({ model, exportName: safeIdentifier(model.export.name), - internalName: this.schemaName(model.symbol), + internalName: this.exportSchemaName(model), })) - return { definitions, exports } + return { + definitions, + exports, + boundary: key => this.boundaryName(key), + } } - private declarationSchema(declaration: TypeDeclarationModel): string { + private declarationDefinition(declaration: TypeDeclarationModel): string { + const name = this.schemaName(declaration.id) + if (declaration.typeParameters.length === 0) { + return `const ${name} = ${this.declarationSchema(declaration, new Map())}` + } + const parameters = declaration.typeParameters.map((parameter, index) => + [`type${String(index)}$schema`, parameter.id] as const) + const substitutions = new Map(parameters.map(([schema, id]) => [id, schema])) + return `const ${name} = (${parameters.map(([schema]) => schema).join(', ')}) => ${this.declarationSchema(declaration, substitutions)}` + } + + private declarationSchema( + declaration: TypeDeclarationModel, + substitutions: ReadonlyMap, + ): string { if (declaration.kind === 'enum') { this.fail(declaration.name, 'enum declarations have no Zod projection') } if (declaration.kind === 'alias') { if (declaration.type === undefined) this.fail(declaration.name, 'alias has no modeled type') - return this.describe(this.typeSchema(declaration.type), declaration) + return this.describe(this.typeSchema(declaration.type, substitutions), declaration) } - const own = this.objectSchema(declaration.members, declaration.name) + const own = this.objectSchema(declaration.members, declaration.name, substitutions) let result = own for (const heritage of declaration.extends) { - result = `z.intersection(${this.typeSchema(heritage)}, ${result})` + result = `z.intersection(${this.typeSchema(heritage, substitutions)}, ${result})` } return this.describe(result, declaration) } - private typeSchema(id: TypeNodeId): string { + private typeSchema(id: TypeNodeId, substitutions: ReadonlyMap = new Map()): string { const node = this.renderer.node(id) switch (node.kind) { case 'keyword': return this.keywordSchema(node.name) case 'literal': return `z.literal(${node.text})` - case 'parenthesized': return this.typeSchema(node.type) - case 'reference': return this.referenceSchema(node) + case 'parenthesized': return this.typeSchema(node.type, substitutions) + case 'reference': return this.referenceSchema(node, substitutions) case 'union': { if (node.types.length === 0) return 'z.never()' - if (node.types.length === 1) return this.typeSchema(node.types[0] as TypeNodeId) - return `z.union([${node.types.map(type => this.typeSchema(type)).join(', ')}])` + if (node.types.length === 1) return this.typeSchema(node.types[0] as TypeNodeId, substitutions) + return `z.union([${node.types.map(type => this.typeSchema(type, substitutions)).join(', ')}])` } case 'intersection': { const [head, ...tail] = node.types if (head === undefined) return 'z.unknown()' - return tail.reduce((left, right) => `z.intersection(${left}, ${this.typeSchema(right)})`, this.typeSchema(head)) + return tail.reduce( + (left, right) => `z.intersection(${left}, ${this.typeSchema(right, substitutions)})`, + this.typeSchema(head, substitutions), + ) } - case 'array': return `z.array(${this.typeSchema(node.element)})` + case 'array': return `z.array(${this.typeSchema(node.element, substitutions)})` case 'tuple': { const fixed = node.elements.filter(element => !element.rest) const rest = node.elements.find(element => element.rest) - let schema = `z.tuple([${fixed.map(element => this.optional(this.typeSchema(element.type), element.optional)).join(', ')}])` - if (rest !== undefined) schema += `.rest(${this.tupleRestSchema(rest.type)})` + let schema = `z.tuple([${fixed.map(element => this.optional(this.typeSchema(element.type, substitutions), element.optional)).join(', ')}])` + if (rest !== undefined) schema += `.rest(${this.tupleRestSchema(rest.type, substitutions)})` return schema } - case 'object': return this.objectSchema(node.members, id) + case 'object': return this.objectSchema(node.members, id, substitutions) case 'operator': case 'indexed-access': case 'conditional': @@ -326,9 +632,27 @@ class SchemaEmitter { } } - private referenceSchema(node: Extract): string { + private referenceSchema( + node: Extract, + substitutions: ReadonlyMap, + ): string { if (node.target.kind === 'declaration') { - return `z.lazy(() => ${this.schemaName(node.target.symbol)})` + const name = this.schemaName(node.target.symbol) + const declaration = this.renderer.declaration(node.target.symbol) + if (declaration.typeParameters.length === 0) { + if (node.arguments.length > 0) { + this.fail(node.name, `non-generic declaration received ${String(node.arguments.length)} type arguments`) + } + return `z.lazy(() => ${name})` + } + const arguments_ = this.declarationArguments(node, declaration, substitutions) + return `z.lazy(() => ${name}(${arguments_.join(', ')}))` + } + if (node.target.kind === 'type-parameter') { + if (node.arguments.length > 0) this.fail(node.name, 'type parameter reference cannot receive type arguments') + const schema = substitutions.get(node.target.parameter) + if (schema === undefined) this.fail(node.name, 'type parameter has no schema substitution') + return schema } if (node.target.kind === 'standard') { switch (node.target.name) { @@ -336,13 +660,16 @@ class SchemaEmitter { case 'ReadonlyArray': { const element = node.arguments[0] if (element === undefined) this.fail(node.name, 'array reference has no element type') - return this.readonly(`z.array(${this.typeSchema(element)})`, node.target.name === 'ReadonlyArray') + return this.readonly( + `z.array(${this.typeSchema(element, substitutions)})`, + node.target.name === 'ReadonlyArray', + ) } case 'Record': { const key = node.arguments[0] const value = node.arguments[1] if (key === undefined || value === undefined) this.fail(node.name, 'Record requires key and value types') - return `z.record(${this.typeSchema(key)}, ${this.typeSchema(value)})` + return `z.record(${this.typeSchema(key, substitutions)}, ${this.typeSchema(value, substitutions)})` } case 'Date': return 'z.date()' default: this.fail(node.name, `standard type ${node.target.name} has no Zod projection`) @@ -351,31 +678,97 @@ class SchemaEmitter { this.fail(node.name, `${node.target.kind} reference has no Zod projection`) } - private tupleRestSchema(id: TypeNodeId): string { + private declarationArguments( + node: Extract, + declaration: TypeDeclarationModel, + substitutions: ReadonlyMap, + ): string[] { + if (node.arguments.length > declaration.typeParameters.length) { + this.fail( + node.name, + `generic declaration accepts ${String(declaration.typeParameters.length)} type arguments but received ${String(node.arguments.length)}`, + ) + } + const resolved = new Map(substitutions) + const arguments_: string[] = [] + for (const [index, parameter] of declaration.typeParameters.entries()) { + const argument = node.arguments[index] + const schema = argument === undefined + ? parameter.default === undefined + ? this.fail(node.name, `missing type argument ${parameter.name}`) + : this.typeSchema(parameter.default, resolved) + : this.typeSchema(argument, substitutions) + arguments_.push(schema) + resolved.set(parameter.id, schema) + } + return arguments_ + } + + private tupleRestSchema(id: TypeNodeId, substitutions: ReadonlyMap): string { const node = this.renderer.node(id) - if (node.kind === 'array') return this.typeSchema(node.element) + if (node.kind === 'array') return this.typeSchema(node.element, substitutions) if (node.kind === 'reference' && node.target.kind === 'standard' && (node.target.name === 'Array' || node.target.name === 'ReadonlyArray')) { const element = node.arguments[0] if (element === undefined) this.fail(node.name, 'tuple rest array has no element type') - return this.typeSchema(element) + return this.typeSchema(element, substitutions) } this.fail(id, 'tuple rest element must retain an array type') } - private objectSchema(members: readonly MemberModel[], subject: string): string { + private objectSchema( + members: readonly MemberModel[], + subject: string, + substitutions: ReadonlyMap, + ): string { const properties: string[] = [] + const indices: string[] = [] + let symbolMembers = 0 for (const member of members) { if (member.static || member.visibility !== 'public') continue + if (member.computed === 'symbol') { + symbolMembers++ + continue + } + if (member.computed === 'dynamic') { + this.fail(subject, `computed member ${member.name} has no fixed JSON property name`) + } + if (member.kind === 'index') { + const parameter = member.signature.parameters[0] + if (member.signature.parameters.length !== 1 || parameter === undefined) { + this.fail(subject, 'index signature must have exactly one key parameter') + } + indices.push(this.readonly( + `z.record(${this.typeSchema(parameter.type, substitutions)}, ${this.typeSchema(member.signature.returns, substitutions)})`, + member.readonly, + )) + continue + } if (member.kind !== 'property') this.fail(subject, `${member.kind} member ${member.name} is not data-schema projectable`) const property = this.describe( - this.optional(this.readonly(this.typeSchema(member.type), member.readonly), member.optional), + this.optional(this.readonly(this.typeSchema(member.type, substitutions), member.readonly), member.optional), member, ) - properties.push(`${quote(member.name)}: ${property}`) + properties.push(`${quote(member.jsonName ?? member.name)}: ${property}`) } - return `z.object({${properties.length === 0 ? '' : `\n${properties.map(property => ` ${property},`).join('\n')}\n`}})` + if (indices.length > 1) this.fail(subject, 'object type has more than one JSON index signature') + // A unique-symbol-only object is a compile-time marker and imposes no JSON shape. + if (properties.length === 0 && indices.length === 0 && symbolMembers > 0) return 'z.unknown()' + const object = `z.object({${properties.length === 0 ? '' : `\n${properties.map(property => ` ${property},`).join('\n')}\n`}})` + const index = indices[0] + if (index === undefined) return object + if (properties.length === 0) return index + return `z.intersection(${object}, ${index})` + } + + private exportSchemaName(model: SchemaModel): string { + const name = this.schemaName(model.symbol) + const declaration = this.renderer.declaration(model.symbol) + if (declaration.typeParameters.length > 0) { + this.fail(model.export.name, 'generic schema exports require a concrete declaration') + } + return name } private keywordSchema(name: string): string { @@ -401,6 +794,12 @@ class SchemaEmitter { return name } + private boundaryName(key: string): string { + const name = this.boundaryNames.get(key) + if (name === undefined) this.fail(key, 'invocation boundary is outside the selected schema roots') + return name + } + private describe(schema: string, documentation: DocumentationModel): string { return documentation.description === undefined ? schema : `${schema}.describe(${quote(documentation.description)})` } @@ -431,6 +830,77 @@ function documentationLiteral(documentation: DocumentationModel): DocumentationM } } +function invocationBoundaryRoots(invocations: readonly InvocationModel[]): BoundarySchemaRoot[] { + const result: BoundarySchemaRoot[] = [] + for (const invocation of invocations) { + if (invocation.invocation.kind === 'context') { + result.push({ key: contextBoundaryKey(invocation), type: invocation.invocation.boundary.codecType }) + } + invocation.parameters.forEach((parameter, index) => { + result.push({ key: parameterBoundaryKey(invocation, index), type: parameter.boundary.codecType }) + }) + result.push({ key: resultBoundaryKey(invocation), type: invocation.result.codecType }) + } + return result +} + +function contextBoundaryKey(invocation: InvocationModel): string { + return `${invocation.id}:context` +} + +function parameterBoundaryKey(invocation: InvocationModel, index: number): string { + return `${invocation.id}:parameter:${String(index)}` +} + +function resultBoundaryKey(invocation: InvocationModel): string { + return `${invocation.id}:result` +} + +function strictCodec(boundary: RemoteBoundaryModel, schema: string): string { + return [ + '{', + ' mode: \'strict\',', + ` typeSymbol: ${quote(boundary.typeSymbol)},`, + ` schema: ${schema},`, + '}', + ].join('\n') +} + +function remoteImports(invocations: readonly InvocationModel[]): RemoteTypeImportModel[] { + const imports = new Map() + const add = (boundary: RemoteBoundaryModel): void => { + for (const imported of boundary.imports) { + const current = imports.get(imported.symbol) + if (current !== undefined + && (current.specifier !== imported.specifier || current.name !== imported.name)) { + throw new TypertEmitError(`typert Remote emitter: symbol ${imported.symbol} has inconsistent public imports`) + } + imports.set(imported.symbol, imported) + } + } + for (const invocation of invocations) { + if (invocation.invocation.kind === 'context') add(invocation.invocation.boundary) + for (const parameter of invocation.parameters) add(parameter.boundary) + add(invocation.result) + } + return [...imports.values()].sort((left, right) => + left.specifier.localeCompare(right.specifier) || left.name.localeCompare(right.name)) +} + +function allocateRemoteImportNames(imports: readonly RemoteTypeImportModel[]): ReadonlyMap { + const used = new Set(['TypeRTRemoteContribution', 'TYPERT_REMOTE']) + const names = new Map() + for (const imported of imports) { + const base = safeIdentifier(imported.name) + let name = base + let suffix = 2 + while (used.has(name)) name = `${base}$remote${String(suffix++)}` + used.add(name) + names.set(imported.symbol, name) + } + return names +} + function packageExportSpecifier(packageName: string, subpath: string): string { return subpath === '.' ? packageName : `${packageName}${subpath.slice(1)}` } diff --git a/packages/typert/generator/src/model.ts b/packages/typert/generator/src/model.ts index c6b7ffbc87..7f15c8407c 100644 --- a/packages/typert/generator/src/model.ts +++ b/packages/typert/generator/src/model.ts @@ -94,6 +94,56 @@ export interface SchemaModel extends DocumentationModel { readonly type: TypeNodeId } +/** One public business type import retained for a generated Remote declaration. */ +export interface RemoteTypeImportModel { + readonly symbol: SymbolId + readonly specifier: string + readonly name: string +} + +/** One strict wire boundary and the public symbols needed to name it. */ +export interface RemoteBoundaryModel { + /** Authored public type retained for generated consumer declarations. */ + readonly type: TypeNodeId + /** Checker-resolved projection used only to emit the runtime codec. */ + readonly codecType: TypeNodeId + readonly typeSymbol: string + readonly imports: readonly RemoteTypeImportModel[] +} + +/** One ordered business argument projected onto a Remote wire field. */ +export interface InvocationParameterModel { + readonly name: string + readonly wire: string + readonly source: 'json' | 'lookup' + readonly lookup?: string + readonly boundary: RemoteBoundaryModel +} + +/** One strictly analyzed Host method exported through TypeRT Gateway. */ +export interface InvocationModel { + readonly id: string + readonly service: string + readonly namespace: string + readonly method: string + readonly implementation?: string + readonly invocation: + | { readonly kind: 'direct' } + | { + readonly kind: 'context' + readonly context: string + readonly wire: string + readonly boundary: RemoteBoundaryModel + } + readonly scope?: { + readonly context: string + readonly wire: string + } + readonly parameters: readonly InvocationParameterModel[] + readonly result: RemoteBoundaryModel + readonly location: SourceLocation +} + /** Business semantics discovered in one package on one face. */ export interface PackageModel { readonly name: string @@ -103,6 +153,7 @@ export interface PackageModel { readonly events: readonly EventModel[] readonly objects: readonly ObjectModel[] readonly schemas: readonly SchemaModel[] + readonly invocations: readonly InvocationModel[] } /** One explicit import/re-export edge between independently compiled faces. */ @@ -173,6 +224,10 @@ export interface SignatureModel { export interface MemberBase extends DocumentationModel { readonly id: string readonly name: string + /** JSON property name when a literal computed key differs from source text. */ + readonly jsonName?: string + /** Non-literal computed keys; symbol keys are erased from JSON schemas. */ + readonly computed?: 'symbol' | 'dynamic' readonly optional: boolean readonly readonly: boolean readonly async: boolean diff --git a/packages/typert/generator/src/renderer.ts b/packages/typert/generator/src/renderer.ts index 8d9a3c4954..5c6fc5cb2b 100644 --- a/packages/typert/generator/src/renderer.ts +++ b/packages/typert/generator/src/renderer.ts @@ -81,32 +81,35 @@ export class TypeGraphRenderer { /** * Render one type expression from the retained source structure. * @param id - type node id. + * @param references - optional generated names for declaration references. * @returns TypeScript type text. */ - renderType(id: TypeNodeId): string { + renderType(id: TypeNodeId, references?: ReadonlyMap): string { const node = this.node(id) switch (node.kind) { case 'keyword': return node.name case 'literal': return node.text - case 'parenthesized': return `(${this.renderType(node.type)})` + case 'parenthesized': return `(${this.renderType(node.type, references)})` case 'reference': { const name = node.target.kind === 'type-parameter' ? this.parameterNames.get(node.target.parameter) ?? node.name - : node.name + : node.target.kind === 'declaration' + ? references?.get(node.target.symbol) ?? node.name + : node.name return node.arguments.length === 0 ? name - : `${name}<${node.arguments.map(argument => this.renderType(argument)).join(', ')}>` + : `${name}<${node.arguments.map(argument => this.renderType(argument, references)).join(', ')}>` } - case 'union': return node.types.map(type => this.renderType(type)).join(' | ') - case 'intersection': return node.types.map(type => this.renderType(type)).join(' & ') + case 'union': return node.types.map(type => this.renderType(type, references)).join(' | ') + case 'intersection': return node.types.map(type => this.renderType(type, references)).join(' & ') case 'array': { - const element = this.renderType(node.element) + const element = this.renderType(node.element, references) const wrapped = needsArrayParentheses(this.node(node.element)) ? `(${element})` : element return `${wrapped}[]` } case 'tuple': { const elements = node.elements.map((element) => { - const type = this.renderType(element.type) + const type = this.renderType(element.type, references) if (element.name !== undefined) { return `${element.rest ? '...' : ''}${element.name}${element.optional ? '?' : ''}: ${type}` } @@ -114,34 +117,34 @@ export class TypeGraphRenderer { }) return `[${elements.join(', ')}]` } - case 'object': return this.renderObject(node.members) - case 'function': return `${this.renderSignatureHead(node.signature)} => ${this.renderType(node.signature.returns)}` - case 'constructor': return `${node.abstract ? 'abstract ' : ''}new ${this.renderSignatureHead(node.signature)} => ${this.renderType(node.signature.returns)}` - case 'indexed-access': return `${this.renderType(node.object)}[${this.renderType(node.index)}]` - case 'operator': return `${node.operator} ${this.renderType(node.type)}` + case 'object': return this.renderObject(node.members, references) + case 'function': return `${this.renderSignatureHead(node.signature, references)} => ${this.renderType(node.signature.returns, references)}` + case 'constructor': return `${node.abstract ? 'abstract ' : ''}new ${this.renderSignatureHead(node.signature, references)} => ${this.renderType(node.signature.returns, references)}` + case 'indexed-access': return `${this.renderType(node.object, references)}[${this.renderType(node.index, references)}]` + case 'operator': return `${node.operator} ${this.renderType(node.type, references)}` case 'conditional': { - return `${this.renderType(node.check)} extends ${this.renderType(node.extends)} ? ${this.renderType(node.whenTrue)} : ${this.renderType(node.whenFalse)}` + return `${this.renderType(node.check, references)} extends ${this.renderType(node.extends, references)} ? ${this.renderType(node.whenTrue, references)} : ${this.renderType(node.whenFalse, references)}` } - case 'infer': return `infer ${this.renderTypeParameter(node.parameter, false)}` + case 'infer': return `infer ${this.renderTypeParameter(node.parameter, false, references)}` case 'mapped': { const readonly = node.readonly === 'preserve' ? '' : node.readonly === 'remove' ? '-readonly ' : 'readonly ' const optional = node.optional === 'preserve' ? '' : node.optional === 'remove' ? '-?' : '?' if (node.parameter.constraint === undefined) { throw new TypeGraphRenderError(`mapped type parameter ${node.parameter.name} has no constraint`) } - const parameter = `${node.parameter.name} in ${this.renderType(node.parameter.constraint)}` - const nameType = node.nameType === undefined ? '' : ` as ${this.renderType(node.nameType)}` - const value = node.value === undefined ? 'unknown' : this.renderType(node.value) + const parameter = `${node.parameter.name} in ${this.renderType(node.parameter.constraint, references)}` + const nameType = node.nameType === undefined ? '' : ` as ${this.renderType(node.nameType, references)}` + const value = node.value === undefined ? 'unknown' : this.renderType(node.value, references) return `{ ${readonly}[${parameter}${nameType}]${optional}: ${value} }` } case 'template-literal': { - const spans = node.spans.map(span => `\${${this.renderType(span.type)}}${escapeTemplate(span.text)}`).join('') + const spans = node.spans.map(span => `\${${this.renderType(span.type, references)}}${escapeTemplate(span.text)}`).join('') return `\`${escapeTemplate(node.head)}${spans}\`` } case 'type-query': { const argumentsText = node.arguments.length === 0 ? '' - : `<${node.arguments.map(argument => this.renderType(argument)).join(', ')}>` + : `<${node.arguments.map(argument => this.renderType(argument, references)).join(', ')}>` return `typeof ${node.expression}${argumentsText}` } case 'import-type': { @@ -149,14 +152,14 @@ export class TypeGraphRenderer { const imported = `import(${quote(node.module)}${attributes})${node.qualifier === undefined ? '' : `.${node.qualifier}`}` const argumentsText = node.arguments.length === 0 ? '' - : `<${node.arguments.map(argument => this.renderType(argument)).join(', ')}>` + : `<${node.arguments.map(argument => this.renderType(argument, references)).join(', ')}>` return `${node.typeof ? 'typeof ' : ''}${imported}${argumentsText}` } case 'predicate': { const assertion = node.asserts ? 'asserts ' : '' return node.type === undefined ? `${assertion}${node.parameter}` - : `${assertion}${node.parameter} is ${this.renderType(node.type)}` + : `${assertion}${node.parameter} is ${this.renderType(node.type, references)}` } case 'this': return 'this' default: return assertNever(node) @@ -166,34 +169,36 @@ export class TypeGraphRenderer { /** * Render a callable signature without a member name. * @param signature - modeled signature. + * @param references - optional generated names for declaration references. * @returns parameter list and return type. */ - renderSignature(signature: SignatureModel): string { - return `${this.renderSignatureHead(signature)}: ${this.renderType(signature.returns)}` + renderSignature(signature: SignatureModel, references?: ReadonlyMap): string { + return `${this.renderSignatureHead(signature, references)}: ${this.renderType(signature.returns, references)}` } /** * Render one class/interface member as a body-free declaration. * @param member - modeled member. * @param sourceModifiers - retain source-only modifiers for reflection text. + * @param references - optional generated names for declaration references. * @returns one-line TypeScript member text. */ - renderMember(member: MemberModel, sourceModifiers = false): string { + renderMember(member: MemberModel, sourceModifiers = false, references?: ReadonlyMap): string { if (sourceModifiers) return member.text const name = renderPropertyName(member.name) const optional = member.optional ? '?' : '' const readonly = member.readonly ? 'readonly ' : '' const abstract = member.abstract ? 'abstract ' : '' switch (member.kind) { - case 'property': return `${abstract}${readonly}${name}${optional}: ${this.renderType(member.type)}` - case 'method': return `${abstract}${name}${optional}${this.renderSignature(member.signature)}` - case 'getter': return `${abstract}get ${name}()${this.renderReturn(member.signature)}` - case 'setter': return `${abstract}set ${name}${this.renderSignatureHead(member.signature)}` - case 'call': return this.renderSignature(member.signature) - case 'construct': return `new ${this.renderSignature(member.signature)}` + case 'property': return `${abstract}${readonly}${name}${optional}: ${this.renderType(member.type, references)}` + case 'method': return `${abstract}${name}${optional}${this.renderSignature(member.signature, references)}` + case 'getter': return `${abstract}get ${name}()${this.renderReturn(member.signature, references)}` + case 'setter': return `${abstract}set ${name}${this.renderSignatureHead(member.signature, references)}` + case 'call': return this.renderSignature(member.signature, references) + case 'construct': return `new ${this.renderSignature(member.signature, references)}` case 'index': { - const parameters = member.signature.parameters.map(parameter => this.renderParameter(parameter)).join(', ') - return `${readonly}[${parameters}]: ${this.renderType(member.signature.returns)}` + const parameters = member.signature.parameters.map(parameter => this.renderParameter(parameter, references)).join(', ') + return `${readonly}[${parameters}]: ${this.renderType(member.signature.returns, references)}` } default: return assertNever(member) } @@ -290,38 +295,42 @@ export class TypeGraphRenderer { return this.graph.declarations.filter(declaration => found.has(declaration.id)) } - private renderSignatureHead(signature: SignatureModel): string { - return `${this.renderTypeParameters(signature.typeParameters)}(${signature.parameters.map(parameter => this.renderParameter(parameter)).join(', ')})` + private renderSignatureHead(signature: SignatureModel, references?: ReadonlyMap): string { + return `${this.renderTypeParameters(signature.typeParameters, references)}(${signature.parameters.map(parameter => this.renderParameter(parameter, references)).join(', ')})` } - private renderReturn(signature: SignatureModel): string { - return `: ${this.renderType(signature.returns)}` + private renderReturn(signature: SignatureModel, references?: ReadonlyMap): string { + return `: ${this.renderType(signature.returns, references)}` } - private renderParameter(parameter: ParameterModel): string { + private renderParameter(parameter: ParameterModel, references?: ReadonlyMap): string { const name = parameter.binding === 'identifier' ? renderPropertyName(parameter.name) : parameter.name const optional = parameter.initializer === undefined && parameter.optional && !parameter.rest ? '?' : '' const initializer = parameter.initializer === undefined ? '' : ` = ${parameter.initializer}` - return `${parameter.rest ? '...' : ''}${name}${optional}: ${this.renderType(parameter.type)}${initializer}` + return `${parameter.rest ? '...' : ''}${name}${optional}: ${this.renderType(parameter.type, references)}${initializer}` } - private renderTypeParameters(parameters: readonly TypeParameterModel[]): string { + private renderTypeParameters(parameters: readonly TypeParameterModel[], references?: ReadonlyMap): string { return parameters.length === 0 ? '' - : `<${parameters.map(parameter => this.renderTypeParameter(parameter, true)).join(', ')}>` + : `<${parameters.map(parameter => this.renderTypeParameter(parameter, true, references)).join(', ')}>` } - private renderTypeParameter(parameter: TypeParameterModel, includeDefault: boolean): string { + private renderTypeParameter( + parameter: TypeParameterModel, + includeDefault: boolean, + references?: ReadonlyMap, + ): string { const variance = parameter.variance === undefined ? '' : `${parameter.variance === 'in-out' ? 'in out' : parameter.variance} ` const constModifier = parameter.const ? 'const ' : '' - const constraint = parameter.constraint === undefined ? '' : ` extends ${this.renderType(parameter.constraint)}` - const fallback = !includeDefault || parameter.default === undefined ? '' : ` = ${this.renderType(parameter.default)}` + const constraint = parameter.constraint === undefined ? '' : ` extends ${this.renderType(parameter.constraint, references)}` + const fallback = !includeDefault || parameter.default === undefined ? '' : ` = ${this.renderType(parameter.default, references)}` return `${constModifier}${variance}${parameter.name}${constraint}${fallback}` } - private renderObject(members: readonly MemberModel[]): string { + private renderObject(members: readonly MemberModel[], references?: ReadonlyMap): string { if (members.length === 0) return '{}' - return `{ ${members.map(member => `${this.renderMember(member)};`).join(' ')} }` + return `{ ${members.map(member => `${this.renderMember(member, false, references)};`).join(' ')} }` } private indexParameters(parameters: readonly TypeParameterModel[]): void { diff --git a/packages/typert/generator/src/tsdown-plugin.ts b/packages/typert/generator/src/tsdown-plugin.ts index 9254eeb16d..a5c6ef93e2 100644 --- a/packages/typert/generator/src/tsdown-plugin.ts +++ b/packages/typert/generator/src/tsdown-plugin.ts @@ -2,7 +2,7 @@ * Optional tsdown (rolldown) plugin face of the typert generator. When added * to a workspace tsdown config, it runs after each opted-in package bundle is * written and re-emits its model-driven face artifact at the package output - * root. Packages without a Typert export are skipped. + * root. Packages without a Typert or Remote export are skipped. * @module @deepseek-ai/dsh-typert-generator/tsdown */ @@ -10,6 +10,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { dirname, join, resolve } from 'node:path' import { WorkspaceTypertGenerator } from './workspace.ts' import type { WorkspaceEmitResult } from './workspace.ts' +import type { TypertFace } from './model.ts' /** The subset of the rolldown output-plugin contract this plugin uses (structural; avoids a rolldown type dependency). */ interface TypertPlugin { @@ -17,21 +18,37 @@ interface TypertPlugin { writeBundle: (options: { dir?: string }) => void } +/** Generation scope selected by a tsdown build phase. */ +export interface TypertPluginOptions { + /** Package mode emits only the package being bundled; workspace mode emits every explicit contributor once. */ + readonly mode?: 'package' | 'workspace' + /** Independent TypeScript program faces included in this phase. */ + readonly faces?: readonly TypertFace[] +} + /** * Create the typert generation plugin for the root tsdown config. - * @returns a rolldown-compatible plugin that emits `lib/typert..js` and `.d.ts` for contributing packages. + * @param pluginOptions - package/workspace emission mode and independent program faces. + * @returns a rolldown-compatible plugin that emits local face and Host-for-Client Remote artifacts. */ -export function typertPlugin(): TypertPlugin { +export function typertPlugin(pluginOptions: TypertPluginOptions = {}): TypertPlugin { const artifactsByRoot = new Map() + const emittedWorkspaces = new Set() return { name: 'dsh-typert-generator', - writeBundle(options) { + writeBundle(bundleOptions) { // options.dir is the package's absolute outDir (/lib); its // nearest package.json owns the bundle even when a custom config writes // a nested output such as /lib/dev. - if (options.dir === undefined) return - const root = workspaceRoot(options.dir) - const packageDir = packageRoot(options.dir, root) + if (bundleOptions.dir === undefined) return + const root = workspaceRoot(bundleOptions.dir) + if (emittedWorkspaces.has(root)) return + if (pluginOptions.mode === 'workspace') { + emitWorkspace(root, pluginOptions.faces) + emittedWorkspaces.add(root) + return + } + const packageDir = packageRoot(bundleOptions.dir, root) if (packageDir === undefined) return const manifest = JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf8')) as { name?: string @@ -40,22 +57,54 @@ export function typertPlugin(): TypertPlugin { if (manifest.name === undefined || !hasTypertExport(manifest.exports)) return let artifacts = artifactsByRoot.get(root) if (artifacts === undefined) { - artifacts = new WorkspaceTypertGenerator(root).generate() + const generator = new WorkspaceTypertGenerator(root) + artifacts = pluginOptions.faces === undefined + ? generator.generate() + : generator.generate(undefined, pluginOptions.faces) artifactsByRoot.set(root, artifacts) } - const output = join(packageDir, 'lib') - mkdirSync(output, { recursive: true }) - for (const artifact of artifacts.filter(candidate => candidate.package === manifest.name)) { - writeFileSync(join(output, `typert.${artifact.face}.js`), artifact.js) - writeFileSync(join(output, `typert.${artifact.face}.d.ts`), artifact.dts) - } + emitArtifacts(packageDir, artifacts.filter(candidate => candidate.package === manifest.name)) }, } + + function emitWorkspace(root: string, faces: readonly TypertFace[] | undefined): void { + const generator = new WorkspaceTypertGenerator(root) + const packages = generator.discover(faces) + .filter(candidate => hasTypertExport(readManifest(join(root, candidate.root)).exports)) + .map(candidate => candidate.package) + if (packages.length === 0) return + for (const artifact of generator.generate(packages, faces)) { + emitArtifacts(join(root, artifact.packageRoot), [artifact]) + } + } +} + +function emitArtifacts(packageDir: string, artifacts: readonly WorkspaceEmitResult[]): void { + const output = join(packageDir, 'lib') + mkdirSync(output, { recursive: true }) + for (const artifact of artifacts) { + writeFileSync(join(output, `typert.${artifact.face}.js`), artifact.js) + writeFileSync(join(output, `typert.${artifact.face}.d.ts`), artifact.dts) + if (artifact.remote !== undefined) { + writeFileSync(join(output, 'typert.remote-client.js'), artifact.remote.js) + writeFileSync(join(output, 'typert.remote-client.d.ts'), artifact.remote.dts) + writeFileSync(join(output, 'typert.remote-client.d.ts.map'), artifact.remote.dtsMap) + } + } +} + +function readManifest(packageDir: string): { name?: string; exports?: unknown } { + return JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf8')) as { + name?: string + exports?: unknown + } } function hasTypertExport(exportsField: unknown): boolean { if (exportsField === null || typeof exportsField !== 'object' || Array.isArray(exportsField)) return false - return Object.hasOwn(exportsField, './typert') || Object.hasOwn(exportsField, './client/typert') + return Object.hasOwn(exportsField, './typert') + || Object.hasOwn(exportsField, './client/typert') + || Object.hasOwn(exportsField, './remote') } function packageRoot(start: string, workspace: string): string | undefined { diff --git a/packages/typert/generator/src/workspace.ts b/packages/typert/generator/src/workspace.ts index 6153a0241a..c79861a796 100644 --- a/packages/typert/generator/src/workspace.ts +++ b/packages/typert/generator/src/workspace.ts @@ -9,6 +9,7 @@ import { TypertAnalysisError, WorkspaceAnalyzer } from './analyzer.ts' import type { DiscoveredTypertPackage } from './analyzer.ts' import { FaceModelEmitter } from './emitter.ts' import type { ModelEmitResult } from './emitter.ts' +import type { TypertFace } from './model.ts' /** One emitted artifact paired with its source package root. */ export interface WorkspaceEmitResult extends ModelEmitResult { @@ -26,20 +27,29 @@ export class WorkspaceTypertGenerator { /** * Find public package faces that contribute Cordis services/events or * explicitly tagged Typert roots. + * @param faces - optional independent program faces to inspect. * @returns discovered packages in stable package-name order. */ - discover(): DiscoveredTypertPackage[] { - return new WorkspaceAnalyzer({ root: this.root }).discoverPackages() + discover(faces?: readonly TypertFace[]): DiscoveredTypertPackage[] { + return new WorkspaceAnalyzer({ + root: this.root, + ...(faces === undefined ? {} : { faces }), + }).discoverPackages() } /** * Generate all discovered contributors, or an explicit package subset. * @param packages - optional exact package names for a focused pass. + * @param faces - optional independent program faces to analyze. * @returns one artifact per package face. */ - generate(packages?: readonly string[]): WorkspaceEmitResult[] { - const selected = packages ?? this.discover().map(candidate => candidate.package) - const workspace = new WorkspaceAnalyzer({ root: this.root, packages: selected }).analyze() + generate(packages?: readonly string[], faces?: readonly TypertFace[]): WorkspaceEmitResult[] { + const selected = packages ?? this.discover(faces).map(candidate => candidate.package) + const workspace = new WorkspaceAnalyzer({ + root: this.root, + packages: selected, + ...(faces === undefined ? {} : { faces }), + }).analyze() const artifacts: WorkspaceEmitResult[] = [] for (const face of workspace.faces) { const emitter = new FaceModelEmitter(face) @@ -80,6 +90,28 @@ export class WorkspaceTypertGenerator { throw new TypertAnalysisError(`typert(${artifact.face}): ${artifact.package} package files must include ${file}`) } } + if (artifact.remote === undefined) return + const remoteExpected = { + types: './lib/typert.remote-client.d.ts', + default: './lib/typert.remote-client.js', + } + const remoteActual = manifest.exports !== null && typeof manifest.exports === 'object' + ? (manifest.exports as Record)['./remote'] + : undefined + if (!sameExport(remoteActual, remoteExpected)) { + throw new TypertAnalysisError( + `typert(host): ${artifact.package} must export ./remote as ${JSON.stringify(remoteExpected)}`, + ) + } + for (const file of [ + 'lib/typert.remote-client.js', + 'lib/typert.remote-client.d.ts', + 'lib/typert.remote-client.d.ts.map', + ]) { + if (!files.includes(file)) { + throw new TypertAnalysisError(`typert(host): ${artifact.package} package files must include ${file}`) + } + } } } diff --git a/packages/typert/generator/tests/__snapshots__/type-model.spec.ts.snap b/packages/typert/generator/tests/__snapshots__/type-model.spec.ts.snap index aad86b0102..bcc28cd8b2 100644 --- a/packages/typert/generator/tests/__snapshots__/type-model.spec.ts.snap +++ b/packages/typert/generator/tests/__snapshots__/type-model.spec.ts.snap @@ -17,6 +17,8 @@ export const TYPERT = { schemas: [ { name: 'Payload', schema: Payload }, ], + invocations: [ + ], model: { "services": [ { @@ -3815,6 +3817,7 @@ exports[`WorkspaceAnalyzer > builds independent face models with an explicit cro "abstract": false, "async": false, "id": "type:packages/host/src/models.ts:123:11#1#['computed']@3756", + "jsonName": "computed", "kind": "property", "location": { "column": 5, @@ -5634,6 +5637,7 @@ exports[`WorkspaceAnalyzer > builds independent face models with an explicit cro "symbol": "@fixture/host:packages/host/src/models.ts#Variance", }, ], + "invocations": [], "name": "@fixture/host", "objects": [ { @@ -6449,6 +6453,7 @@ exports[`WorkspaceAnalyzer > builds independent face models with an explicit cro "symbol": ":../../../../../../node_modules/.pnpm/zod@4.4.3/node_modules/zod/v4/classic/schemas.d.cts#ZodType", }, ], + "invocations": [], "name": "@fixture/client", "objects": [], "root": "packages/client", diff --git a/packages/typert/generator/tests/fixtures/remote-model/package.json b/packages/typert/generator/tests/fixtures/remote-model/package.json new file mode 100644 index 0000000000..00ac86bdcc --- /dev/null +++ b/packages/typert/generator/tests/fixtures/remote-model/package.json @@ -0,0 +1,5 @@ +{ + "name": "@fixture/remote-workspace", + "private": true, + "type": "module" +} diff --git a/packages/typert/generator/tests/fixtures/remote-model/packages/domain/package.json b/packages/typert/generator/tests/fixtures/remote-model/packages/domain/package.json new file mode 100644 index 0000000000..bf6b2bd110 --- /dev/null +++ b/packages/typert/generator/tests/fixtures/remote-model/packages/domain/package.json @@ -0,0 +1,9 @@ +{ + "name": "@fixture/domain", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts", + "./types": "./src/types.ts" + } +} diff --git a/packages/typert/generator/tests/fixtures/remote-model/packages/domain/src/index.ts b/packages/typert/generator/tests/fixtures/remote-model/packages/domain/src/index.ts new file mode 100644 index 0000000000..e5c2850cf2 --- /dev/null +++ b/packages/typert/generator/tests/fixtures/remote-model/packages/domain/src/index.ts @@ -0,0 +1,19 @@ +import type { TypeRTContext, TypeRTLookup } from '@deepseek-ai/dsh-type-meta' +import type { AgentId } from './types.ts' + +/** Host-only live Agent object. */ +export class Agent { + constructor(readonly id: AgentId) {} +} + +declare module '@deepseek-ai/dsh-type-meta' { + interface TypeRTLookupMap { + agent: TypeRTLookup + } + + interface TypeRTContextMap { + agent: TypeRTContext + } +} + +export type { AgentId } from './types.ts' diff --git a/packages/typert/generator/tests/fixtures/remote-model/packages/domain/src/types.ts b/packages/typert/generator/tests/fixtures/remote-model/packages/domain/src/types.ts new file mode 100644 index 0000000000..944201e82a --- /dev/null +++ b/packages/typert/generator/tests/fixtures/remote-model/packages/domain/src/types.ts @@ -0,0 +1,2 @@ +/** Stable Agent identity crossing the Remote boundary. */ +export type AgentId = string diff --git a/packages/typert/generator/tests/fixtures/remote-model/packages/domain/tsconfig.json b/packages/typert/generator/tests/fixtures/remote-model/packages/domain/tsconfig.json new file mode 100644 index 0000000000..1ddc9b1a60 --- /dev/null +++ b/packages/typert/generator/tests/fixtures/remote-model/packages/domain/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types", + "noEmit": false, + "declaration": true, + "emitDeclarationOnly": true + }, + "include": ["src"] +} diff --git a/packages/typert/generator/tests/fixtures/remote-model/packages/remote/package.json b/packages/typert/generator/tests/fixtures/remote-model/packages/remote/package.json new file mode 100644 index 0000000000..b7e0631a0a --- /dev/null +++ b/packages/typert/generator/tests/fixtures/remote-model/packages/remote/package.json @@ -0,0 +1,24 @@ +{ + "name": "@fixture/remote", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts", + "./types": "./src/types.ts", + "./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" + } + }, + "files": [ + "lib/typert.host.js", + "lib/typert.host.d.ts", + "lib/typert.remote-client.js", + "lib/typert.remote-client.d.ts", + "lib/typert.remote-client.d.ts.map" + ] +} diff --git a/packages/typert/generator/tests/fixtures/remote-model/packages/remote/src/index.ts b/packages/typert/generator/tests/fixtures/remote-model/packages/remote/src/index.ts new file mode 100644 index 0000000000..816a13a5a7 --- /dev/null +++ b/packages/typert/generator/tests/fixtures/remote-model/packages/remote/src/index.ts @@ -0,0 +1,30 @@ +import { Remote, RemoteContext, bindTypeRTGateway } from '@deepseek-ai/dsh-type-meta' +import type { Agent } from '@fixture/domain' +import type { + CreateGoalRequest, + CreateGoalResult, + RenameGoalRequest, + RenameGoalResult, +} from './types.ts' + +/** Remote-only business Service with no Cordis declaration merge. */ +export class GoalService { + readonly typertGateway = bindTypeRTGateway(this, 'goals') + + @Remote + async create(agent: Agent, request: CreateGoalRequest): Promise { + return { ref: `${agent.id}:${request.title}` } + } + + @RemoteContext('agent') + rename(request: RenameGoalRequest): RenameGoalResult { + return { renamed: request.title.length > 0 } + } +} + +export type { + CreateGoalRequest, + CreateGoalResult, + RenameGoalRequest, + RenameGoalResult, +} from './types.ts' diff --git a/packages/typert/generator/tests/fixtures/remote-model/packages/remote/src/types.ts b/packages/typert/generator/tests/fixtures/remote-model/packages/remote/src/types.ts new file mode 100644 index 0000000000..88493325f8 --- /dev/null +++ b/packages/typert/generator/tests/fixtures/remote-model/packages/remote/src/types.ts @@ -0,0 +1,20 @@ +/** Input accepted by Goal creation. */ +export interface CreateGoalRequest { + readonly title: string +} + +/** Wire-safe Goal creation result. */ +export interface CreateGoalResult { + readonly ref: string +} + +/** Input accepted by scoped Goal renaming. */ +export interface RenameGoalRequest { + readonly ref: string + readonly title: string +} + +/** Wire-safe Goal rename result. */ +export interface RenameGoalResult { + readonly renamed: boolean +} diff --git a/packages/typert/generator/tests/fixtures/remote-model/packages/remote/tsconfig.json b/packages/typert/generator/tests/fixtures/remote-model/packages/remote/tsconfig.json new file mode 100644 index 0000000000..534b3c3d75 --- /dev/null +++ b/packages/typert/generator/tests/fixtures/remote-model/packages/remote/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types", + "noEmit": false, + "declaration": true, + "emitDeclarationOnly": true + }, + "include": ["src"], + "references": [ + { "path": "../domain" } + ] +} diff --git a/packages/typert/generator/tests/fixtures/remote-model/tsconfig.base.json b/packages/typert/generator/tests/fixtures/remote-model/tsconfig.base.json new file mode 100644 index 0000000000..4aaf57160d --- /dev/null +++ b/packages/typert/generator/tests/fixtures/remote-model/tsconfig.base.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2024", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "composite": true, + "noEmit": true, + "allowImportingTsExtensions": true, + "ignoreDeprecations": "6.0", + "paths": { + "@deepseek-ai/dsh-type-meta": ["./type-meta.d.ts"], + "@fixture/domain": ["./packages/domain/src/index.ts"], + "@fixture/domain/*": ["./packages/domain/src/*"], + "@fixture/remote": ["./packages/remote/src/index.ts"], + "@fixture/remote/*": ["./packages/remote/src/*"] + }, + "skipLibCheck": true + } +} diff --git a/packages/typert/generator/tests/fixtures/remote-model/tsconfig.host.json b/packages/typert/generator/tests/fixtures/remote-model/tsconfig.host.json new file mode 100644 index 0000000000..7797b7ff29 --- /dev/null +++ b/packages/typert/generator/tests/fixtures/remote-model/tsconfig.host.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.base.json", + "files": [], + "references": [ + { "path": "./packages/domain" }, + { "path": "./packages/remote" } + ] +} diff --git a/packages/typert/generator/tests/fixtures/remote-model/type-meta.d.ts b/packages/typert/generator/tests/fixtures/remote-model/type-meta.d.ts new file mode 100644 index 0000000000..f8e84bbe90 --- /dev/null +++ b/packages/typert/generator/tests/fixtures/remote-model/type-meta.d.ts @@ -0,0 +1,45 @@ +declare module '@deepseek-ai/dsh-type-meta' { + export interface TypeRTLookup { + readonly host: Host + readonly wire: Wire + } + + export interface TypeRTContext { + readonly wire: Wire + } + + export interface TypeRTLookupMap {} + export interface TypeRTContextMap {} + export interface TypeRTRemoteMap {} + export interface TypeRTRemoteContextMap {} + + export type TypeRTRemoteNamespace = { + [Endpoint in keyof TypeRTRemoteMap as Endpoint extends `${Namespace}/${infer Method}` + ? Method + : never]: TypeRTRemoteMap[Endpoint] + } + + export interface TypeRTRemoteNamespaceMap {} + + export interface TypeRTRemoteContribution { + readonly package: string + readonly descriptors: readonly unknown[] + } + + export function bindTypeRTGateway( + service: Service, + serviceKey: string, + options?: { readonly namespace?: string }, + ): { readonly service: Service; readonly serviceKey: string; readonly namespace: string } + + export function Remote( + method: (this: This, ...args: Args) => Result, + context: ClassMethodDecoratorContext Result>, + ): void + + export function RemoteContext(key: Extract): + ( + method: (this: This, ...args: Args) => Result, + context: ClassMethodDecoratorContext Result>, + ) => void +} diff --git a/packages/typert/generator/tests/remote-model.spec.ts b/packages/typert/generator/tests/remote-model.spec.ts new file mode 100644 index 0000000000..90056e673e --- /dev/null +++ b/packages/typert/generator/tests/remote-model.spec.ts @@ -0,0 +1,486 @@ +import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import ts from 'typescript' +import { afterEach, describe, expect, it } from 'vitest' +import { WorkspaceAnalyzer } from '../src/analyzer.ts' +import type { InvocationModel } from '../src/model.ts' +import { WorkspaceTypertGenerator } from '../src/workspace.ts' + +const fixtureRoot = resolve(import.meta.dirname, 'fixtures/remote-model') +const temporaryRoots: string[] = [] + +interface RuntimeSchema { + safeParse(value: unknown): { readonly success: boolean } +} + +interface RuntimeDescriptor { + readonly id: string + readonly parameters: readonly { + readonly wire: string + readonly codec: { readonly schema: RuntimeSchema } + }[] + readonly result: { readonly schema: RuntimeSchema } +} + +interface RuntimeRemoteModule { + readonly TYPERT_REMOTE: { + readonly package: string + readonly descriptors: readonly RuntimeDescriptor[] + } +} + +interface RemoteDeclarationMap { + readonly file: string + readonly names: readonly string[] + readonly sources: readonly string[] +} + +afterEach(() => { + for (const root of temporaryRoots.splice(0)) rmSync(root, { recursive: true, force: true }) +}) + +describe('Remote model generation', { timeout: 60_000 }, () => { + it('discovers a Remote-only package and emits strict direct and Context descriptors', async () => { + const generator = new WorkspaceTypertGenerator(fixtureRoot) + + expect(generator.discover()).toEqual([{ + package: '@fixture/remote', + root: 'packages/remote', + faces: ['host'], + }]) + + const [artifact] = generator.generate() + expect(artifact).toBeDefined() + expect(artifact).toMatchObject({ + package: '@fixture/remote', + face: 'host', + packageRoot: 'packages/remote', + }) + + const model = remotePackage(fixtureRoot) + expect(model.services).toEqual([]) + expect(model.invocations).toHaveLength(2) + expect(model.invocations[0]).toMatchObject({ + id: '@fixture/remote#goals/create', + service: 'goals', + namespace: 'goals', + method: 'create', + invocation: { kind: 'direct' }, + scope: { context: 'agent', wire: 'agentId' }, + parameters: [ + { + name: 'agent', + wire: 'agentId', + source: 'lookup', + lookup: 'agent', + boundary: { typeSymbol: '@fixture/domain/types#AgentId' }, + }, + { + name: 'request', + wire: 'request', + source: 'json', + boundary: { typeSymbol: '@fixture/remote/types#CreateGoalRequest' }, + }, + ], + result: { typeSymbol: '@fixture/remote/types#CreateGoalResult' }, + }) + expect(model.invocations[1]).toMatchObject({ + id: '@fixture/remote#goals/rename', + service: 'goals', + namespace: 'goals', + method: 'rename', + invocation: { + kind: 'context', + context: 'agent', + wire: 'agentId', + boundary: { typeSymbol: '@fixture/domain/types#AgentId' }, + }, + parameters: [{ + name: 'request', + wire: 'request', + source: 'json', + boundary: { typeSymbol: '@fixture/remote/types#RenameGoalRequest' }, + }], + result: { typeSymbol: '@fixture/remote/types#RenameGoalResult' }, + }) + + expect(artifact?.js).toContain('invocations: [') + expect(artifact?.remote?.dts).toContain( + "'goals/create': (agentId: AgentId, request: CreateGoalRequest) => Promise", + ) + expect(artifact?.remote?.dts).toContain('interface TypeRTRemoteNamespace$676f616c73 {\n create:') + expect(artifact?.remote?.dts).toContain("'goals': TypeRTRemoteNamespace$676f616c73") + expect(artifact?.remote?.dts).toContain( + "'agent:goals/create': (request: CreateGoalRequest) => Promise", + ) + expect(artifact?.remote?.dts).toContain( + "'agent:goals/rename': (request: RenameGoalRequest) => Promise", + ) + + const remoteJs = artifact?.remote?.js + if (remoteJs === undefined) throw new Error('Remote fixture emitted no Host-for-Client JavaScript') + const executable = remoteJs.replace("from 'zod'", `from ${JSON.stringify(import.meta.resolve('zod'))}`) + const generated = await import(`data:text/javascript,${encodeURIComponent(executable)}`) as RuntimeRemoteModule + expect(generated.TYPERT_REMOTE.package).toBe('@fixture/remote') + const create = generated.TYPERT_REMOTE.descriptors[0] + expect(create?.parameters[1]?.codec.schema.safeParse({ title: 'ship' }).success).toBe(true) + expect(create?.parameters[1]?.codec.schema.safeParse({ title: 1 }).success).toBe(false) + expect(create?.result.schema.safeParse({ ref: 'goal-1' }).success).toBe(true) + expect(create?.result.schema.safeParse({ ref: 1 }).success).toBe(false) + + const declarationMap = JSON.parse(artifact?.remote?.dtsMap ?? '') as RemoteDeclarationMap + expect(declarationMap).toMatchObject({ + file: 'typert.remote-client.d.ts', + sources: ['../src/index.ts'], + }) + expect(declarationMap.names).toContain('create') + + assertRemoteConsumerTypechecks(artifact?.remote?.dts, artifact?.remote?.dtsMap) + }) + + it('evaluates declaration-merged mapped and conditional boundaries for codecs without widening consumer types', async () => { + const root = copyFixture() + editFile(root, 'packages/remote/src/types.ts', source => `${source} + +/** Recursive JSON fixture used by the concrete codec projection. */ +export type Json = null | boolean | number | string | Json[] | { [key: string]: Json } + +/** Merge-extensible operation table represented by concrete fixture entries. */ +export interface GenericRemoteMap { + ship: { + readonly request: { readonly count: number; readonly meta: Json } + readonly result: { readonly accepted: boolean } + } + cancel: { + readonly request: { readonly reason: string } + readonly result: { readonly cancelled: boolean } + } +} + +type GenericRemoteKey = Extract +type RequestOf = GenericRemoteMap[K] extends { readonly request: infer Request } + ? Request + : never +type ResultOf = GenericRemoteMap[K] extends { readonly result: infer Result } + ? Result + : never + +/** Strict request union retained in the generated Client declaration. */ +export type GenericRequest = { + [K in GenericRemoteKey]: { readonly kind: K; readonly payload: RequestOf } +}[GenericRemoteKey] + +/** Strict result union retained in the generated Client declaration. */ +export type GenericResult = { + [K in GenericRemoteKey]: { readonly kind: K; readonly value: ResultOf } +}[GenericRemoteKey] +`) + editFile(root, 'packages/remote/src/index.ts', source => source + .replace( + ' RenameGoalResult,\n', + ' RenameGoalResult,\n GenericRequest,\n GenericResult,\n', + ) + .replace( + ' rename(request: RenameGoalRequest): RenameGoalResult {\n return { renamed: request.title.length > 0 }\n }\n}', + ` rename(request: RenameGoalRequest): RenameGoalResult { + return { renamed: request.title.length > 0 } + } + + @Remote + dispatch(request: GenericRequest): GenericResult { + if (request.kind === 'ship') return { kind: 'ship', value: { accepted: request.payload.count > 0 } } + return { kind: 'cancel', value: { cancelled: request.payload.reason.length > 0 } } + } +}`, + )) + + const [artifact] = new WorkspaceTypertGenerator(root).generate() + expect(artifact?.remote?.dts).toContain( + "'goals/dispatch': (request: GenericRequest) => Promise", + ) + const remoteJs = artifact?.remote?.js + if (remoteJs === undefined) throw new Error('generic Remote fixture emitted no Host-for-Client JavaScript') + const executable = remoteJs.replace("from 'zod'", `from ${JSON.stringify(import.meta.resolve('zod'))}`) + const generated = await import(`data:text/javascript,${encodeURIComponent(executable)}`) as RuntimeRemoteModule + const dispatch = generated.TYPERT_REMOTE.descriptors.find(descriptor => descriptor.id.endsWith('/dispatch')) + const schema = dispatch?.parameters[0]?.codec.schema + expect(schema?.safeParse({ kind: 'ship', payload: { count: 2, meta: { nested: [true, null] } } }).success).toBe(true) + expect(schema?.safeParse({ kind: 'ship', payload: { count: '2', meta: {} } }).success).toBe(false) + expect(schema?.safeParse({ kind: 'cancel', payload: { reason: 'obsolete' } }).success).toBe(true) + expect(schema?.safeParse({ kind: 'unknown', payload: {} }).success).toBe(false) + expect(dispatch?.result.schema.safeParse({ kind: 'ship', value: { accepted: true } }).success).toBe(true) + expect(dispatch?.result.schema.safeParse({ kind: 'ship', value: { cancelled: true } }).success).toBe(false) + }) + + it.each([ + { + name: 'missing binding', + edit: (source: string) => source.replace(" readonly typertGateway = bindTypeRTGateway(this, 'goals')\n\n", ''), + message: 'Remote methods require readonly typertGateway', + }, + { + name: 'private method', + edit: (source: string) => source.replace(' async create(', ' private async create('), + message: 'Remote decorators require a public instance method', + }, + { + name: 'static method', + edit: (source: string) => source.replace(' async create(', ' static async create('), + message: 'Remote decorators require a public instance method', + }, + { + name: 'abstract method', + edit: (source: string) => source + .replace('export class GoalService', 'export abstract class GoalService') + .replace( + ' async create(agent: Agent, request: CreateGoalRequest): Promise {\n return { ref: `${agent.id}:${request.title}` }\n }', + ' abstract create(agent: Agent, request: CreateGoalRequest): Promise', + ), + message: 'Remote methods must have a concrete implementation', + }, + { + name: 'generic method', + edit: (source: string) => source.replace(' async create(', ' async create('), + message: 'generic Remote methods are not supported', + }, + { + name: 'destructured parameter', + edit: (source: string) => source.replace('request: CreateGoalRequest', '{ title }: CreateGoalRequest'), + message: 'Remote parameters must use identifier bindings', + }, + { + name: 'rest parameter', + edit: (source: string) => source.replace('request: CreateGoalRequest', '...request: [CreateGoalRequest]'), + message: 'Remote parameters cannot be rest parameters', + }, + { + name: 'default parameter', + edit: (source: string) => source.replace( + 'request: CreateGoalRequest', + "request: CreateGoalRequest = { title: '' }", + ), + message: 'Remote parameters cannot have default values', + }, + { + name: 'optional parameter', + edit: (source: string) => source.replace('request: CreateGoalRequest', 'request?: CreateGoalRequest'), + message: 'Remote parameters cannot be optional', + }, + ])('rejects $name', ({ edit, message }) => { + const root = copyFixture() + editFile(root, 'packages/remote/src/index.ts', edit) + + expect(() => analyzeRemote(root, false)).toThrow(new RegExp(message)) + }) + + it('rejects a workspace class parameter without a lookup declaration', () => { + const root = copyFixture() + editFile(root, 'packages/domain/src/index.ts', source => source.replace( + ' interface TypeRTLookupMap {\n agent: TypeRTLookup\n }\n\n', + '', + )) + + expect(() => analyzeRemote(root, false)).toThrow(/non-JSON class parameter Agent requires a TypeRTLookupMap entry/) + }) + + it('rejects a Remote Context without a static Context declaration', () => { + const root = copyFixture() + editFile(root, 'packages/remote/src/index.ts', source => source.replace("@RemoteContext('agent')", "@RemoteContext('missing')")) + + expect(() => analyzeRemote(root, false)).toThrow(/Remote Context missing has no TypeRTContextMap entry/) + }) + + it('rejects a direct scoped projection whose Context and lookup wire symbols differ', () => { + const root = copyFixture() + editFile(root, 'packages/domain/src/types.ts', source => `${source}\n/** Deliberately distinct Context identity for the failure fixture. */\nexport type OtherAgentId = string\n`) + editFile(root, 'packages/domain/src/index.ts', source => source + .replace("import type { AgentId } from './types.ts'", "import type { AgentId, OtherAgentId } from './types.ts'") + .replace('agent: TypeRTContext', 'agent: TypeRTContext')) + + expect(() => analyzeRemote(root, false)).toThrow(/Remote scope agent wire type .* does not match lookup wire type/) + }) + + it('rejects duplicate endpoints across Remote services', () => { + const root = copyFixture() + editFile(root, 'packages/remote/src/index.ts', source => `${source} +export class DuplicateGoalService { + readonly typertGateway = bindTypeRTGateway(this, 'duplicate', { namespace: 'goals' }) + + @Remote + create(request: CreateGoalRequest): CreateGoalResult { + return { ref: request.title } + } +} +`) + + expect(() => analyzeRemote(root, false)).toThrow(/Remote endpoint goals\/create conflicts/) + }) +}) + +function analyzeRemote(root: string, checkDiagnostics = true): ReturnType { + return new WorkspaceAnalyzer({ root, checkDiagnostics }).analyze() +} + +function remotePackage(root: string): { + readonly services: readonly unknown[] + readonly invocations: readonly InvocationModel[] +} { + const host = analyzeRemote(root).faces.find(face => face.face === 'host') + const packageModel = host?.packages.find(candidate => candidate.name === '@fixture/remote') + if (packageModel === undefined) throw new Error('Remote fixture package was not modeled on the host face') + return packageModel +} + +function copyFixture(): string { + const root = mkdtempSync(join(tmpdir(), 'dsh-typert-remote-model-')) + cpSync(fixtureRoot, root, { recursive: true }) + temporaryRoots.push(root) + return root +} + +function editFile(root: string, relativePath: string, edit: (source: string) => string): void { + const path = join(root, relativePath) + const source = readFileSync(path, 'utf8') + const result = edit(source) + if (result === source) throw new Error(`fixture edit made no change to ${relativePath}`) + writeFileSync(path, result) +} + +function assertRemoteConsumerTypechecks(dts: string | undefined, dtsMap: string | undefined): void { + if (dts === undefined) throw new Error('Remote fixture emitted no Host-for-Client declaration') + if (dtsMap === undefined) throw new Error('Remote fixture emitted no Host-for-Client declaration map') + const consumerRoot = copyFixture() + const declarationPath = join(consumerRoot, 'packages/remote/lib/typert.remote-client.d.ts') + const declarationMapPath = `${declarationPath}.map` + const consumerPath = join(consumerRoot, 'consumer.ts') + mkdirSync(join(consumerRoot, 'packages/remote/lib'), { recursive: true }) + writeFileSync(declarationPath, dts, { flush: true }) + writeFileSync(declarationMapPath, dtsMap, { flush: true }) + assertRemoteConsumerWithoutImportHasNoNamespace(consumerRoot) + const consumerSource = ` +import remote from '@fixture/remote/remote' +import type { + TypeRTRemoteContribution, + TypeRTRemoteContextMap, + TypeRTRemoteMap, + TypeRTRemoteNamespaceMap, +} from '@deepseek-ai/dsh-type-meta' +import type { CreateGoalResult, RenameGoalResult } from '@fixture/remote/types' + +const contribution: TypeRTRemoteContribution = remote +declare const create: TypeRTRemoteMap['goals/create'] +declare const createScoped: TypeRTRemoteContextMap['agent:goals/create'] +declare const rename: TypeRTRemoteContextMap['agent:goals/rename'] +const created: Promise = create('agent-1', { title: 'ship' }) +const createdScoped: Promise = createScoped({ title: 'ship' }) +const renamed: Promise = rename({ ref: 'goal-1', title: 'land' }) +declare const ctx: { api: TypeRTRemoteNamespaceMap } +const navigated: Promise = ctx.api.goals.create('agent-1', { title: 'navigate' }) +void contribution +void created +void createdScoped +void renamed +void navigated +` + writeFileSync(consumerPath, consumerSource) + const configPath = join(consumerRoot, 'tsconfig.consumer.json') + writeFileSync(configPath, JSON.stringify({ + extends: './tsconfig.base.json', + compilerOptions: { + composite: false, + skipLibCheck: false, + paths: { + '@deepseek-ai/dsh-type-meta': ['./type-meta.d.ts'], + '@fixture/domain/types': ['./packages/domain/src/types.ts'], + '@fixture/remote/types': ['./packages/remote/src/types.ts'], + '@fixture/remote/remote': ['./packages/remote/lib/typert.remote-client.d.ts'], + }, + }, + files: ['./consumer.ts'], + }, null, 2)) + const config = ts.readConfigFile(configPath, file => ts.sys.readFile(file)) + if (config.error !== undefined) throw new Error(formatDiagnostics([config.error])) + const parsed = ts.parseJsonConfigFileContent(config.config, ts.sys, consumerRoot, undefined, configPath) + const program = ts.createProgram(parsed.fileNames, parsed.options) + const diagnostics = ts.getPreEmitDiagnostics(program) + expect(diagnostics, formatDiagnostics(diagnostics)).toEqual([]) + + const languageService = ts.createLanguageService({ + getCompilationSettings: () => parsed.options, + getCurrentDirectory: () => consumerRoot, + getDefaultLibFileName: options => ts.getDefaultLibFilePath(options), + getScriptFileNames: () => parsed.fileNames, + getScriptSnapshot: (fileName) => { + const source = ts.sys.readFile(fileName) + return source === undefined ? undefined : ts.ScriptSnapshot.fromString(source) + }, + getScriptVersion: () => '0', + directoryExists: path => ts.sys.directoryExists(path), + fileExists: path => ts.sys.fileExists(path), + getDirectories: path => ts.sys.getDirectories(path), + readDirectory: (path, extensions, exclude, include, depth) => + ts.sys.readDirectory(path, extensions, exclude, include, depth), + readFile: path => ts.sys.readFile(path), + realpath: path => ts.sys.realpath?.(path) ?? path, + }) + const navigation = 'ctx.api.goals.create' + const position = consumerSource.indexOf(navigation) + navigation.lastIndexOf('create') + 1 + const definitions = languageService.getDefinitionAtPosition(consumerPath, position) + const generatedDefinition = definitions?.find(candidate => candidate.fileName === declarationPath) + if (generatedDefinition === undefined) { + throw new Error(`generated Remote definition not found: ${JSON.stringify(definitions, null, 2)}`) + } + const sourceMapper = (languageService as unknown as { + getSourceMapper(): { + tryGetSourcePosition(location: { readonly fileName: string; readonly pos: number }): + { readonly fileName: string; readonly pos: number } | undefined + } + }).getSourceMapper() + const definition = sourceMapper.tryGetSourcePosition({ + fileName: generatedDefinition.fileName, + pos: generatedDefinition.textSpan.start, + }) + languageService.dispose() + if (definition === undefined || !definition.fileName.endsWith('/packages/remote/src/index.ts')) { + throw new Error(`generated Remote definition did not map to its Host source: ${JSON.stringify(definition)}`) + } + const hostSource = readFileSync(join(consumerRoot, 'packages/remote/src/index.ts'), 'utf8') + expect(hostSource.slice(definition.pos, definition.pos + generatedDefinition.textSpan.length)).toBe('create') +} + +function assertRemoteConsumerWithoutImportHasNoNamespace(consumerRoot: string): void { + const consumerPath = join(consumerRoot, 'consumer-without-remote.ts') + writeFileSync(consumerPath, ` +import type { TypeRTRemoteNamespaceMap } from '@deepseek-ai/dsh-type-meta' +declare const ctx: { api: TypeRTRemoteNamespaceMap } +ctx.api.goals.create('agent-1', { title: 'must not compile' }) +`) + const configPath = join(consumerRoot, 'tsconfig.consumer-without-remote.json') + writeFileSync(configPath, JSON.stringify({ + extends: './tsconfig.base.json', + compilerOptions: { + composite: false, + skipLibCheck: false, + paths: { + '@deepseek-ai/dsh-type-meta': ['./type-meta.d.ts'], + }, + }, + files: ['./consumer-without-remote.ts'], + }, null, 2)) + const config = ts.readConfigFile(configPath, file => ts.sys.readFile(file)) + if (config.error !== undefined) throw new Error(formatDiagnostics([config.error])) + const parsed = ts.parseJsonConfigFileContent(config.config, ts.sys, consumerRoot, undefined, configPath) + const diagnostics = ts.getPreEmitDiagnostics(ts.createProgram(parsed.fileNames, parsed.options)) + expect(diagnostics).toHaveLength(1) + expect(diagnostics[0]?.code).toBe(2339) + expect(ts.flattenDiagnosticMessageText(diagnostics[0]?.messageText ?? '', '\n')).toContain("Property 'goals' does not exist") +} + +function formatDiagnostics(diagnostics: readonly ts.Diagnostic[]): string { + return ts.formatDiagnosticsWithColorAndContext(diagnostics, { + getCanonicalFileName: file => file, + getCurrentDirectory: () => process.cwd(), + getNewLine: () => '\n', + }) +} diff --git a/packages/typert/generator/tests/schema-emitter.spec.ts b/packages/typert/generator/tests/schema-emitter.spec.ts index 7457c4b85f..16ac97b7ca 100644 --- a/packages/typert/generator/tests/schema-emitter.spec.ts +++ b/packages/typert/generator/tests/schema-emitter.spec.ts @@ -7,6 +7,7 @@ import type { FaceModel, KeywordTypeName, MemberModel, + SignatureMemberModel, SignatureModel, TypeDeclarationModel, TypeNodeModel, @@ -356,6 +357,149 @@ describe('SchemaEmitter supported projection matrix', () => { expect(inheritedSchema.safeParse({ current: 1 }).success).toBe(false) }) + it('instantiates generic aliases, nested references, defaults, and recursive declarations', async () => { + const box = declaration('Box', 'interface', { + typeParameters: [{ id: 'box:value', name: 'Value', const: false }], + members: [property('value', 'box:value-reference')], + }) + const wrapper = declaration('Wrapper', 'alias', { + typeParameters: [ + { id: 'wrapper:value', name: 'Value', const: false }, + { id: 'wrapper:items', name: 'Items', const: false, default: 'wrapper:default-items' }, + ], + type: 'wrapper:box-reference', + }) + const recursive = declaration('Recursive', 'interface', { + typeParameters: [{ id: 'recursive:value', name: 'Value', const: false }], + members: [ + property('value', 'recursive:value-reference'), + property('next', 'recursive:self-reference', { optional: true }), + ], + }) + const schema = await loadSchema(emit([ + { + id: 'root', + kind: 'object', + members: [ + property('wrapped', 'root:wrapper-reference'), + property('recursive', 'root:recursive-reference'), + ], + }, + { + id: 'root:wrapper-reference', + kind: 'reference', + name: 'Wrapper', + target: { kind: 'declaration', symbol: 'Wrapper' }, + arguments: ['string'], + }, + { + id: 'root:recursive-reference', + kind: 'reference', + name: 'Recursive', + target: { kind: 'declaration', symbol: 'Recursive' }, + arguments: ['number'], + }, + { + id: 'wrapper:box-reference', + kind: 'reference', + name: 'Box', + target: { kind: 'declaration', symbol: 'Box' }, + arguments: ['wrapper:items-reference'], + }, + { + id: 'wrapper:default-items', + kind: 'reference', + name: 'ReadonlyArray', + target: { kind: 'standard', name: 'ReadonlyArray' }, + arguments: ['wrapper:value-reference'], + }, + { + id: 'wrapper:value-reference', + kind: 'reference', + name: 'Value', + target: { kind: 'type-parameter', parameter: 'wrapper:value' }, + arguments: [], + }, + { + id: 'wrapper:items-reference', + kind: 'reference', + name: 'Items', + target: { kind: 'type-parameter', parameter: 'wrapper:items' }, + arguments: [], + }, + { + id: 'box:value-reference', + kind: 'reference', + name: 'Value', + target: { kind: 'type-parameter', parameter: 'box:value' }, + arguments: [], + }, + { + id: 'recursive:value-reference', + kind: 'reference', + name: 'Value', + target: { kind: 'type-parameter', parameter: 'recursive:value' }, + arguments: [], + }, + { + id: 'recursive:self-reference', + kind: 'reference', + name: 'Recursive', + target: { kind: 'declaration', symbol: 'Recursive' }, + arguments: ['recursive:value-reference'], + }, + keyword('string', 'string'), + keyword('number', 'number'), + ], undefined, [box, wrapper, recursive])) + + expect(schema.safeParse({ + wrapped: { value: ['one', 'two'] }, + recursive: { value: 1, next: { value: 2 } }, + }).success).toBe(true) + expect(schema.safeParse({ + wrapped: { value: [1] }, + recursive: { value: 1 }, + }).success).toBe(false) + expect(schema.safeParse({ + wrapped: { value: ['one'] }, + recursive: { value: 'one' }, + }).success).toBe(false) + }) + + it('erases unique-symbol nominal members without naming a branding utility', async () => { + const nominal = declaration('Nominal', 'alias', { + typeParameters: [{ id: 'nominal:brand', name: 'Brand', const: false }], + type: 'nominal:intersection', + }) + const symbolMember = { + ...property('[TOKEN]', 'nominal:brand-reference', { readonly: true }), + computed: 'symbol', + } as const + const schema = await loadSchema(emit([ + { + id: 'root', + kind: 'reference', + name: 'Nominal', + target: { kind: 'declaration', symbol: 'Nominal' }, + arguments: ['brand'], + }, + { id: 'brand', kind: 'literal', value: 'Fixture', text: "'Fixture'" }, + { id: 'nominal:intersection', kind: 'intersection', types: ['string', 'nominal:marker'] }, + keyword('string', 'string'), + { id: 'nominal:marker', kind: 'object', members: [symbolMember] }, + { + id: 'nominal:brand-reference', + kind: 'reference', + name: 'Brand', + target: { kind: 'type-parameter', parameter: 'nominal:brand' }, + arguments: [], + }, + ], undefined, [nominal])) + + expect(schema.safeParse('fixture-id').success).toBe(true) + expect(schema.safeParse(1).success).toBe(false) + }) + it('classifies every TypeNode kind and executes every supported kind', () => { const expected = Object.entries(ZOD_NODE_SUPPORT) .filter(([, support]) => support === 'supported') @@ -373,7 +517,6 @@ describe('SchemaEmitter unsupported projection matrix', () => { }) it.each([ - ['type-parameter', { kind: 'type-parameter', parameter: 'parameter' }], ['cross-face', { kind: 'cross-face', face: 'client', package: '@fixture/client', subpath: '.', name: 'Value' }], ['external', { kind: 'external', module: 'external', subpath: '.', name: 'Value' }], ] as const)('rejects %s references explicitly', (kind, target) => { @@ -386,7 +529,33 @@ describe('SchemaEmitter unsupported projection matrix', () => { }])).toThrow(`typert Zod emitter: Value: ${kind} reference has no Zod projection`) }) - it('rejects unsupported standard references, generic declarations, and enums', () => { + it('rejects unbound type parameters, incomplete generic applications, and generic schema exports', () => { + expect(() => emit([{ + id: 'root', + kind: 'reference', + name: 'Value', + target: { kind: 'type-parameter', parameter: 'parameter' }, + arguments: [], + }])).toThrow('type parameter has no schema substitution') + + const generic = declaration('Generic', 'interface', { + typeParameters: [{ id: 'parameter', name: 'Value', const: false }], + }) + expect(() => emit([{ + id: 'root', + kind: 'reference', + name: 'Generic', + target: { kind: 'declaration', symbol: 'Generic' }, + arguments: [], + }], undefined, [generic])).toThrow('missing type argument Value') + + const genericRoot = declaration('Root', 'interface', { + typeParameters: [{ id: 'root:parameter', name: 'Value', const: false }], + }) + expect(() => emit([], genericRoot)).toThrow('generic schema exports require a concrete declaration') + }) + + it('rejects unsupported standard references and enums', () => { const intrinsic = { id: 'root', kind: 'keyword', name: 'intrinsic' } as unknown as TypeNodeModel expect(() => emit([intrinsic])) .toThrow('keyword intrinsic has no Zod projection') @@ -399,17 +568,6 @@ describe('SchemaEmitter unsupported projection matrix', () => { arguments: [], }])).toThrow('standard type Promise has no Zod projection') - const generic = declaration('Generic', 'interface', { - typeParameters: [{ id: 'parameter', name: 'Value', const: false }], - }) - expect(() => emit([{ - id: 'root', - kind: 'reference', - name: 'Generic', - target: { kind: 'declaration', symbol: 'Generic' }, - arguments: [], - }], undefined, [generic])).toThrow('generic declarations require a schema-factory projection') - const enumeration = declaration('Enumeration', 'enum', { enumMembers: [{ ...documentation, name: 'Value', initializer: "'value'", location }], }) @@ -481,6 +639,7 @@ describe('SchemaEmitter unsupported projection matrix', () => { }], objects: [], schemas: [], + invocations: [], }], } expect(() => new FaceModelEmitter(eventFace).emit('@fixture/schema')) @@ -513,6 +672,7 @@ describe('SchemaEmitter unsupported projection matrix', () => { }], objects: [], schemas: [], + invocations: [], }], } @@ -555,7 +715,33 @@ describe('SchemaEmitter unsupported projection matrix', () => { expect(artifact.dts).toContain("from '@fixture/schema/secondary'") }) - it.each(['method', 'getter', 'setter', 'call', 'construct', 'index'] as const)( + it('emits JSON index signatures as record schemas', async () => { + const root = declaration('Root', 'interface', { + members: [indexMember('key', 'value')], + }) + const schema = await loadSchema(emit([ + keyword('key', 'string'), + keyword('value', 'number'), + ], root)) + + expect(schema.safeParse({ one: 1, two: 2 }).success).toBe(true) + expect(schema.safeParse({ one: '1' }).success).toBe(false) + }) + + it('rejects more than one JSON index signature', () => { + const root = declaration('Root', 'interface', { + members: [indexMember('key', 'value'), indexMember('other-key', 'other-value')], + }) + + expect(() => emit([ + keyword('key', 'string'), + keyword('value', 'number'), + keyword('other-key', 'string'), + keyword('other-value', 'boolean'), + ], root)).toThrow('object type has more than one JSON index signature') + }) + + it.each(['method', 'getter', 'setter', 'call', 'construct'] as const)( 'rejects %s members on data-schema objects', (kind) => { expect(() => emit([ @@ -608,6 +794,10 @@ function property( } } +function signatureMember(kind: 'index'): SignatureMemberModel +function signatureMember( + kind: Exclude, +): MemberModel function signatureMember(kind: Exclude): MemberModel { return { ...documentation, @@ -626,6 +816,24 @@ function signatureMember(kind: Exclude): Member } } +function indexMember(key: string, value: string): SignatureMemberModel { + return { + ...signatureMember('index'), + signature: { + typeParameters: [], + parameters: [{ + name: 'key', + binding: 'identifier', + type: key, + optional: false, + rest: false, + receiver: false, + }], + returns: value, + }, + } +} + function declaration( name: string, kind: TypeDeclarationModel['kind'], @@ -684,6 +892,7 @@ function emit( symbol: 'Root', type: 'schema-reference', }], + invocations: [], }], } return new FaceModelEmitter(face).emit('@fixture/schema').js @@ -710,6 +919,7 @@ function schemaFace( symbol, type: 'root', }], + invocations: [], }], } } diff --git a/packages/typert/generator/tests/tools-catalog.spec.ts b/packages/typert/generator/tests/tools-catalog.spec.ts index 29193e66c1..95c1ab09de 100644 --- a/packages/typert/generator/tests/tools-catalog.spec.ts +++ b/packages/typert/generator/tests/tools-catalog.spec.ts @@ -62,7 +62,7 @@ describe('model-driven dsh-tools generation', () => { TYPE_API.find(type => type.name === 'ToolDefinition'), ) - dispose() + await dispose() expect(ctx.typert.getPackage('@deepseek-ai/dsh-tools', 'host')).toBeUndefined() }) }) diff --git a/packages/typert/generator/tests/tsdown-plugin.spec.ts b/packages/typert/generator/tests/tsdown-plugin.spec.ts index 9b4057beee..655636aa79 100644 --- a/packages/typert/generator/tests/tsdown-plugin.spec.ts +++ b/packages/typert/generator/tests/tsdown-plugin.spec.ts @@ -12,6 +12,11 @@ const generated = vi.hoisted(() => vi.fn(() => [ exports: [], js: 'export const host = true\n', dts: 'export declare const host: true\n', + remote: { + js: 'export const remote = true\n', + dts: 'export declare const remote: true\n//# sourceMappingURL=typert.remote-client.d.ts.map\n', + dtsMap: '{"version":3}\n', + }, }, { package: '@deepseek-ai/dsh-tools', @@ -21,10 +26,30 @@ const generated = vi.hoisted(() => vi.fn(() => [ js: 'export const client = true\n', dts: 'export declare const client: true\n', }, + { + package: '@fixture/remote-only', + packageRoot: 'packages/remote-only', + face: 'host' as const, + exports: [], + js: 'export const local = true\n', + dts: 'export declare const local: true\n', + remote: { + js: 'export const remoteOnly = true\n', + dts: 'export declare const remoteOnly: true\n//# sourceMappingURL=typert.remote-client.d.ts.map\n', + dtsMap: '{"version":3}\n', + }, + }, +])) + +const discovered = vi.hoisted(() => vi.fn(() => [ + { package: '@deepseek-ai/dsh-tools', root: 'packages/core/tools', faces: ['host'] }, + { package: '@fixture/ignored', root: 'packages/ignored', faces: ['host'] }, + { package: '@fixture/remote-only', root: 'packages/remote-only', faces: ['host'] }, ])) vi.mock('../src/workspace.ts', () => ({ WorkspaceTypertGenerator: class { + discover = discovered generate = generated }, })) @@ -33,6 +58,7 @@ const { typertPlugin } = await import('../src/tsdown-plugin.ts') const roots: string[] = [] afterEach(() => { + discovered.mockClear() generated.mockClear() for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) }) @@ -80,9 +106,64 @@ describe('typertPlugin', () => { expect(readFileSync(join(packageLib, 'typert.host.d.ts'), 'utf8')).toBe('export declare const host: true\n') expect(readFileSync(join(packageLib, 'typert.client.js'), 'utf8')).toBe('export const client = true\n') expect(existsSync(join(packageLib, 'typert.client.d.ts'))).toBe(true) + expect(readFileSync(join(packageLib, 'typert.remote-client.js'), 'utf8')).toBe('export const remote = true\n') + expect(readFileSync(join(packageLib, 'typert.remote-client.d.ts'), 'utf8')) + .toBe('export declare const remote: true\n//# sourceMappingURL=typert.remote-client.d.ts.map\n') + expect(readFileSync(join(packageLib, 'typert.remote-client.d.ts.map'), 'utf8')) + .toBe('{"version":3}\n') expect(readFileSync(join(root, 'packages/client-tools/lib/typert.client.js'), 'utf8')) .toBe('export const client = true\n') }) + + it('generates a package opted in only through its Remote export', async () => { + const root = await workspace() + const output = await packageOutput(root, 'remote-only', { + name: '@fixture/remote-only', + exports: { './remote': './lib/typert.remote-client.js' }, + }) + + typertPlugin().writeBundle({ dir: output }) + + const packageLib = join(root, 'packages', 'remote-only', 'lib') + expect(generated).toHaveBeenCalledOnce() + expect(readFileSync(join(packageLib, 'typert.remote-client.js'), 'utf8')) + .toBe('export const remoteOnly = true\n') + expect(readFileSync(join(packageLib, 'typert.remote-client.d.ts'), 'utf8')) + .toBe('export declare const remoteOnly: true\n//# sourceMappingURL=typert.remote-client.d.ts.map\n') + expect(readFileSync(join(packageLib, 'typert.remote-client.d.ts.map'), 'utf8')) + .toBe('{"version":3}\n') + }) + + it('emits every explicit workspace contributor once from a host-only prepass', async () => { + const root = await workspace() + const trigger = await packageOutput(root, 'generator', { name: '@deepseek-ai/dsh-typert-generator' }) + await packageOutput(root, 'core/tools', { + name: '@deepseek-ai/dsh-tools', + exports: { './typert': './lib/typert.host.js' }, + }) + await packageOutput(root, 'ignored', { name: '@fixture/ignored' }) + await packageOutput(root, 'remote-only', { + name: '@fixture/remote-only', + exports: { './remote': './lib/typert.remote-client.js' }, + }) + + const plugin = typertPlugin({ mode: 'workspace', faces: ['host'] }) + plugin.writeBundle({ dir: trigger }) + plugin.writeBundle({ dir: join(root, 'packages/core/tools/lib/dev') }) + + expect(discovered).toHaveBeenCalledOnce() + expect(discovered).toHaveBeenCalledWith(['host']) + expect(generated).toHaveBeenCalledOnce() + expect(generated).toHaveBeenCalledWith( + ['@deepseek-ai/dsh-tools', '@fixture/remote-only'], + ['host'], + ) + expect(readFileSync(join(root, 'packages/core/tools/lib/typert.host.js'), 'utf8')) + .toBe('export const host = true\n') + expect(readFileSync(join(root, 'packages/remote-only/lib/typert.remote-client.js'), 'utf8')) + .toBe('export const remoteOnly = true\n') + expect(existsSync(join(root, 'packages/ignored/lib/typert.host.js'))).toBe(false) + }) }) async function workspace(): Promise { diff --git a/packages/typert/generator/tests/type-model.spec.ts b/packages/typert/generator/tests/type-model.spec.ts index 923c254d0f..ca37cd1bbe 100644 --- a/packages/typert/generator/tests/type-model.spec.ts +++ b/packages/typert/generator/tests/type-model.spec.ts @@ -201,6 +201,53 @@ describe('WorkspaceAnalyzer', { timeout: 60_000 }, () => { expect(batched).toEqual(direct) }) + it('discovers an explicitly keyed service implementation without a Context merge', () => { + const root = copyFixture('explicit-service-') + addExplicitServicePackage(root, 'service detached') + const analyzer = new WorkspaceAnalyzer({ root }) + + expect(analyzer.discoverPackages()).toContainEqual({ + package: '@fixture/explicit-service', + root: 'packages/explicit-service', + faces: ['host'], + }) + const model = new WorkspaceAnalyzer({ root, packages: ['@fixture/explicit-service'] }).analyze() + const service = model.faces[0]?.packages[0]?.services[0] + expect(service).toMatchObject({ key: 'detached', export: { name: 'DetachedService' } }) + }) + + it('prefers an explicitly keyed implementation over its protocol Context merge', () => { + const root = copyFixture('explicit-service-protocol-') + addExplicitServicePackage(root, 'service detached', true) + const model = new WorkspaceAnalyzer({ + root, + packages: ['@fixture/explicit-service'], + }).analyze() + const service = model.faces[0]?.packages[0]?.services[0] + + expect(service).toMatchObject({ + key: 'detached', + export: { name: 'DetachedService' }, + location: { file: 'packages/explicit-service/src/index.ts' }, + }) + }) + + it('rejects an explicit service implementation without one valid key', () => { + const missing = copyFixture('explicit-service-missing-') + addExplicitServicePackage(missing, 'service') + expect(() => new WorkspaceAnalyzer({ + root: missing, + packages: ['@fixture/explicit-service'], + }).analyze()).toThrow('@typert service requires exactly one nonempty Cordis service key') + + const invalid = copyFixture('explicit-service-invalid-') + addExplicitServicePackage(invalid, 'service bad/key') + expect(() => new WorkspaceAnalyzer({ + root: invalid, + packages: ['@fixture/explicit-service'], + }).analyze()).toThrow('@typert service requires exactly one nonempty Cordis service key') + }) + it('indexes authored top-level exports without promoting them to graph roots', () => { const declarations = new WorkspaceAnalyzer({ root: fixtureRoot }).indexSourceDeclarations() const agent = declarations.find(declaration => declaration.name === 'Agent') @@ -1178,6 +1225,57 @@ function addSameFacePackage(root: string, specifier: string, importedName: strin writeFileSync(aggregatePath, `${JSON.stringify(aggregate, null, 2)}\n`) } +function addExplicitServicePackage(root: string, annotation: string, withProtocol = false): void { + const packageRoot = join(root, 'packages/explicit-service') + mkdirSync(join(packageRoot, 'src'), { recursive: true }) + writeFileSync(join(packageRoot, 'package.json'), JSON.stringify({ + name: '@fixture/explicit-service', + private: true, + type: 'module', + exports: { + '.': { + types: './lib/types/index.d.ts', + default: './lib/index.js', + }, + }, + }, null, 2)) + writeFileSync(join(packageRoot, 'tsconfig.json'), JSON.stringify({ + extends: '../../tsconfig.base.json', + compilerOptions: { rootDir: 'src', outDir: 'lib/types' }, + include: ['src'], + }, null, 2)) + if (withProtocol) { + writeFileSync(join(packageRoot, 'src/types.ts'), [ + '/** Public detached Service protocol. */', + 'export interface DetachedProtocol {', + ' /** Report protocol readiness. */', + ' ready(): boolean', + '}', + "declare module 'cordis' {", + ' interface Context { detached: DetachedProtocol }', + '}', + '', + ].join('\n')) + } + writeFileSync(join(packageRoot, 'src/index.ts'), [ + "import { Service } from 'cordis'", + ...(withProtocol ? ["export type { DetachedProtocol } from './types.ts'"] : []), + '/**', + ' * Service implementation discovered independently of its protocol package.', + ` * @typert ${annotation}`, + ' */', + 'export class DetachedService extends Service {', + ' /** Report readiness. */', + ' ready(): boolean { return true }', + '}', + '', + ].join('\n')) + const aggregatePath = join(root, 'tsconfig.host.json') + const aggregate = JSON.parse(readFileSync(aggregatePath, 'utf8')) as { references: { path: string }[] } + aggregate.references.push({ path: './packages/explicit-service' }) + writeFileSync(aggregatePath, `${JSON.stringify(aggregate, null, 2)}\n`) +} + describe('FaceModelEmitter', { timeout: 60_000 }, () => { it('emits runnable Zod JavaScript, precise declarations, and runtime package metadata', async () => { const model = new WorkspaceAnalyzer({ root: fixtureRoot }).analyze() diff --git a/packages/typert/loader/src/index.ts b/packages/typert/loader/src/index.ts index 9485a76f05..fee1098340 100644 --- a/packages/typert/loader/src/index.ts +++ b/packages/typert/loader/src/index.ts @@ -135,6 +135,11 @@ export function validateTypertManifest(pkgName: string, exported: unknown): Type requireMembers(pkgName, object.members, `object "${object.name as string}"`) requireTypes(pkgName, object.types, `object "${object.name as string}"`) } + if (manifest.invocations !== undefined) { + for (const value of requireArray(pkgName, manifest.invocations, 'TYPERT.invocations')) { + requireInvocation(pkgName, value) + } + } return manifest as unknown as TypertContribution } @@ -184,6 +189,88 @@ function requireTypes(pkgName: string, value: unknown, subject: string): void { } } +function requireInvocation(pkgName: string, value: unknown): void { + const invocation = requireObject(pkgName, value, 'invocation') + for (const key of ['id', 'service', 'namespace', 'method'] as const) { + requireString(pkgName, invocation, key, 'invocation') + } + const id = invocation.id as string + const receiver = requireObject(pkgName, invocation.invocation, `invocation "${id}" receiver`) + if (receiver.kind === 'context') { + requireString(pkgName, receiver, 'context', `invocation "${id}" Context receiver`) + requireString(pkgName, receiver, 'wire', `invocation "${id}" Context receiver`) + requireStrictCodec(pkgName, receiver.codec, `invocation "${id}" Context codec`) + } else if (receiver.kind !== 'direct') { + throw new Error(`typert-loader: ${pkgName} invocation "${id}" receiver kind must be "direct" or "context"`) + } + const wires = new Set() + const parameters = new Map>() + let lookupCount = 0 + for (const valueParameter of requireArray(pkgName, invocation.parameters, `invocation "${id}" parameters`)) { + const parameter = requireObject(pkgName, valueParameter, `invocation "${id}" parameter`) + requireString(pkgName, parameter, 'name', `invocation "${id}" parameter`) + requireString(pkgName, parameter, 'wire', `invocation "${id}" parameter`) + const wire = parameter.wire as string + if (wires.has(wire)) { + throw new Error(`typert-loader: ${pkgName} invocation "${id}" repeats wire field "${wire}"`) + } + wires.add(wire) + if (parameter.source === 'lookup') { + lookupCount += 1 + requireString(pkgName, parameter, 'lookup', `invocation "${id}" lookup parameter`) + } else if (parameter.source === 'json') { + if (parameter.lookup !== undefined) { + throw new Error(`typert-loader: ${pkgName} invocation "${id}" JSON parameter declares a lookup`) + } + } else { + throw new Error(`typert-loader: ${pkgName} invocation "${id}" parameter source must be "json" or "lookup"`) + } + parameters.set(wire, parameter) + requireStrictCodec(pkgName, parameter.codec, `invocation "${id}" parameter codec`) + } + if (invocation.scope !== undefined) { + if (receiver.kind !== 'direct') { + throw new Error(`typert-loader: ${pkgName} invocation "${id}" Context receiver cannot declare a direct scope projection`) + } + const scope = requireObject(pkgName, invocation.scope, `invocation "${id}" scope`) + requireString(pkgName, scope, 'context', `invocation "${id}" scope`) + requireString(pkgName, scope, 'wire', `invocation "${id}" scope`) + const parameter = parameters.get(scope.wire as string) + if (lookupCount !== 1 || parameter?.source !== 'lookup' || parameter.lookup !== scope.context) { + throw new Error( + `typert-loader: ${pkgName} invocation "${id}" scope wire "${scope.wire as string}" must select its only lookup parameter`, + ) + } + } + if (receiver.kind === 'context' && wires.has(receiver.wire as string)) { + throw new Error(`typert-loader: ${pkgName} invocation "${id}" repeats Context wire field "${receiver.wire as string}"`) + } + requireStrictCodec(pkgName, invocation.result, `invocation "${id}" result codec`) + if (invocation.sourceLocation !== undefined) { + const location = requireObject(pkgName, invocation.sourceLocation, `invocation "${id}" sourceLocation`) + requireString(pkgName, location, 'file', `invocation "${id}" sourceLocation`) + for (const key of ['line', 'column'] as const) { + if (!Number.isInteger(location[key]) || (location[key] as number) < 1) { + throw new Error(`typert-loader: ${pkgName} invocation "${id}" sourceLocation.${key} must be a positive integer`) + } + } + } +} + +function requireStrictCodec(pkgName: string, value: unknown, subject: string): void { + const codec = requireObject(pkgName, value, subject) + if (codec.mode !== 'strict') { + throw new Error(`typert-loader: ${pkgName} ${subject} must use a strict codec`) + } + requireString(pkgName, codec, 'typeSymbol', subject) + if (typeof codec.schema !== 'object' + || codec.schema === null + || !('_zod' in codec.schema) + || typeof (codec.schema as { parse?: unknown }).parse !== 'function') { + throw new Error(`typert-loader: ${pkgName} ${subject} is not backed by a zod v4 schema`) + } +} + /** * Scan current Loader entries during activation, then follow entry mounts and * unmounts for this plugin's lifetime. @@ -202,7 +289,7 @@ export async function apply(ctx: Context, config: Config): Promise { const configured = new Set((config as ResolvedConfig).packages) // Registered contributions by entry name; the disposer withdraws the entry's registration. - const registered = new Map void>() + const registered = new Map Promise>() // In-flight import/register tasks by entry name. const pending = new Map>() // Artifact paths by package name. Negative verdicts (unresolvable specifier — @@ -279,7 +366,7 @@ export async function apply(ctx: Context, config: Config): Promise { const dispose = registered.get(entryName) if (dispose !== undefined) { registered.delete(entryName) - dispose() + return dispose() } return undefined } diff --git a/packages/typert/loader/tests/loader.spec.ts b/packages/typert/loader/tests/loader.spec.ts index 3b126f1e76..1e7e553605 100644 --- a/packages/typert/loader/tests/loader.spec.ts +++ b/packages/typert/loader/tests/loader.spec.ts @@ -1,4 +1,5 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { createRequire } from 'node:module' import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' @@ -8,6 +9,7 @@ import Loader from '@cordisjs/plugin-loader' import TypertRegistry from '@deepseek-ai/dsh-typert-registry' import * as typertLoader from '@deepseek-ai/dsh-typert-loader' import { validateTypertManifest } from '@deepseek-ai/dsh-typert-loader' +import { z } from 'zod' let root: string | undefined let context: Context | undefined @@ -63,12 +65,45 @@ function typertSource(pkgName: string, entryName: string): string { ].join('\n') } +function invocationTypertSource(pkgName: string): string { + return [ + 'import { z } from \'zod\'', + 'const Text = z.string()', + 'export const TYPERT = {', + ` package: '${pkgName}',`, + ' face: \'host\',', + ' schemas: [],', + ' model: { services: [], events: [], objects: [] },', + ' invocations: [{', + ` id: '${pkgName}#goals/create',`, + ' service: \'goals\', namespace: \'goals\', method: \'create\',', + ' invocation: { kind: \'direct\' },', + ' parameters: [{', + ' name: \'request\', wire: \'request\', source: \'json\',', + ` codec: { mode: 'strict', typeSymbol: '${pkgName}/types#Request', schema: Text },`, + ' }],', + ` result: { mode: 'strict', typeSymbol: '${pkgName}/types#Result', schema: Text },`, + ' sourceLocation: { file: \'src/index.ts\', line: 8, column: 3 },', + ' }],', + '}', + '', + ].join('\n') +} + /** Boot a real Loader over a fixture root; plugin modules resolve from its node_modules. */ async function boot(): Promise { context = new Context() context.baseUrl = pathToFileURL(join(root as string, 'cordis.yml')).href await context.plugin(TypertRegistry) await context.plugin(Loader) + const fixtureRequire = createRequire(context.baseUrl) + context.loader.internal = { + version: 'v2', + async import(specifier: string) { + const module: unknown = await import(pathToFileURL(fixtureRequire.resolve(specifier)).href) + return module + }, + } as unknown as NonNullable // zod must be resolvable from the fixture packages; link the workspace copy. await mkdir(join(root as string, 'node_modules'), { recursive: true }) return context @@ -105,6 +140,33 @@ describe('typert loader', () => { expect(ctx.typert.getPackage('@fixture/nested')).toBeUndefined() }) + it('registers a strict invocation into the local registry and withdraws it with the loader', LOADER_TEST_TIMEOUT, async () => { + root = await mkdtemp(join(tmpdir(), 'dsh-typert-loader-')) + await linkZod(root) + await writePackage(root, '@fixture/invocation', { + typertSource: invocationTypertSource('@fixture/invocation'), + }) + const ctx = await boot() + + const fiber = mountTypertLoader(ctx, { packages: ['@fixture/invocation'] }) + await fiber + + const descriptor = ctx.typert.local.get('goals/create') + expect(descriptor).toMatchObject({ + id: '@fixture/invocation#goals/create', + invocation: { kind: 'direct' }, + parameters: [{ wire: 'request', source: 'json' }], + sourceLocation: { file: 'src/index.ts', line: 8, column: 3 }, + }) + expect(descriptor?.parameters[0]?.codec.mode).toBe('strict') + if (descriptor?.parameters[0]?.codec.mode === 'strict') { + expect(descriptor.parameters[0].codec.schema.parse('request')).toBe('request') + } + + await fiber.dispose() + expect(ctx.typert.local.get('goals/create')).toBeUndefined() + }) + it('fails loud when an explicit package is absent or has no Typert export', LOADER_TEST_TIMEOUT, async () => { root = await mkdtemp(join(tmpdir(), 'dsh-typert-loader-')) await writePackage(root, '@fixture/plain') @@ -427,8 +489,156 @@ describe('validateTypertManifest', () => { model: { ...complete.model, objects: [{ ...complete.model.objects[0], exportName: '' }] }, })).toThrow('object has a missing or empty exportName') }) + + it('validates strict invocation descriptors and accepts legacy manifests without them', () => { + const legacy = completeManifest(zodish) + expect(validateTypertManifest('pkg', legacy)).toBe(legacy) + + const descriptor = strictInvocation() + const manifest = { ...legacy, invocations: [descriptor] } + expect(validateTypertManifest('pkg', manifest)).toBe(manifest) + const scoped = { + ...descriptor, + scope: { context: 'agent', wire: 'agentId' }, + parameters: [{ + name: 'agent', + wire: 'agentId', + source: 'lookup', + lookup: 'agent', + codec: strictCodec('pkg#AgentId'), + }, ...descriptor.parameters], + } + expect(validateTypertManifest('pkg', { ...legacy, invocations: [scoped] }).invocations) + .toEqual([scoped]) + + expect(() => validateTypertManifest('pkg', { ...legacy, invocations: {} })) + .toThrow('TYPERT.invocations must be an array') + expect(() => validateTypertManifest('pkg', { + ...legacy, + invocations: [{ ...descriptor, invocation: { kind: 'future' } }], + })).toThrow('receiver kind must be "direct" or "context"') + expect(() => validateTypertManifest('pkg', { + ...legacy, + invocations: [{ ...descriptor, result: { mode: 'src-json' } }], + })).toThrow('result codec must use a strict codec') + expect(() => validateTypertManifest('pkg', { + ...legacy, + invocations: [{ ...descriptor, result: { mode: 'strict', typeSymbol: 'pkg#Result', schema: zodish } }], + })).toThrow('result codec is not backed by a zod v4 schema') + expect(() => validateTypertManifest('pkg', { + ...legacy, + invocations: [{ + ...descriptor, + parameters: [{ ...descriptor.parameters[0], source: 'future' }], + }], + })).toThrow('parameter source must be "json" or "lookup"') + expect(() => validateTypertManifest('pkg', { + ...legacy, + invocations: [{ + ...descriptor, + parameters: [{ ...descriptor.parameters[0], source: 'lookup' }], + }], + })).toThrow('lookup parameter has a missing or empty lookup') + expect(() => validateTypertManifest('pkg', { + ...legacy, + invocations: [{ + ...descriptor, + parameters: [{ ...descriptor.parameters[0], lookup: 'agent' }], + }], + })).toThrow('JSON parameter declares a lookup') + expect(() => validateTypertManifest('pkg', { + ...legacy, + invocations: [{ + ...descriptor, + parameters: [descriptor.parameters[0], { ...descriptor.parameters[0], name: 'again' }], + }], + })).toThrow('repeats wire field "request"') + expect(() => validateTypertManifest('pkg', { + ...legacy, + invocations: [{ + ...descriptor, + invocation: { + kind: 'context', + context: 'agent', + wire: 'request', + codec: strictCodec('pkg#AgentId'), + }, + }], + })).toThrow('repeats Context wire field "request"') + expect(() => validateTypertManifest('pkg', { + ...legacy, + invocations: [{ ...scoped, scope: null }], + })).toThrow('scope must be an object') + expect(() => validateTypertManifest('pkg', { + ...legacy, + invocations: [{ ...scoped, scope: { wire: 'agentId' } }], + })).toThrow('scope has a missing or empty context') + expect(() => validateTypertManifest('pkg', { + ...legacy, + invocations: [{ ...scoped, scope: { context: 'agent' } }], + })).toThrow('scope has a missing or empty wire') + expect(() => validateTypertManifest('pkg', { + ...legacy, + invocations: [{ + ...scoped, + invocation: { + kind: 'context', + context: 'agent', + wire: 'scopeId', + codec: strictCodec('pkg#AgentId'), + }, + }], + })).toThrow('Context receiver cannot declare a direct scope projection') + expect(() => validateTypertManifest('pkg', { + ...legacy, + invocations: [{ ...scoped, scope: { context: 'agent', wire: 'missingId' } }], + })).toThrow('must select its only lookup parameter') + expect(() => validateTypertManifest('pkg', { + ...legacy, + invocations: [{ + ...scoped, + parameters: [...scoped.parameters, { + name: 'other', + wire: 'otherId', + source: 'lookup', + lookup: 'agent', + codec: strictCodec('pkg#AgentId'), + }], + }], + })).toThrow('must select its only lookup parameter') + expect(() => validateTypertManifest('pkg', { + ...legacy, + invocations: [{ ...scoped, scope: { context: 'other', wire: 'agentId' } }], + })).toThrow('must select its only lookup parameter') + expect(() => validateTypertManifest('pkg', { + ...legacy, + invocations: [{ ...descriptor, sourceLocation: { file: 'src/index.ts', line: 0, column: 1 } }], + })).toThrow('sourceLocation.line must be a positive integer') + }) }) +function strictCodec(typeSymbol: string) { + return { mode: 'strict', typeSymbol, schema: z.string() } +} + +function strictInvocation() { + return { + id: 'pkg#goals/create', + service: 'goals', + namespace: 'goals', + method: 'create', + invocation: { kind: 'direct' }, + parameters: [{ + name: 'request', + wire: 'request', + source: 'json', + codec: strictCodec('pkg#Request'), + }], + result: strictCodec('pkg#Result'), + sourceLocation: { file: 'src/index.ts', line: 1, column: 1 }, + } +} + function completeManifest(zodish: object) { const member = { name: 'member', signature: 'member(): void', kind: 'method' } const type = { name: 'Value', declaration: 'export interface Value {}' } diff --git a/packages/typert/registry/package.json b/packages/typert/registry/package.json index b543589dc6..e912808293 100644 --- a/packages/typert/registry/package.json +++ b/packages/typert/registry/package.json @@ -15,6 +15,10 @@ "types": "./lib/types/invariant.d.ts", "default": "./lib/invariant.js" }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, "./types": { "types": "./lib/types/types.d.ts", "default": "./lib/types/types.js" @@ -22,14 +26,25 @@ "./src/*": "./src/*", "./package.json": "./package.json" }, + "dshClient": { + "inject": [], + "platform": "web", + "immediately": true + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, "files": [ "lib/index.js", "lib/invariant.js", + "lib/client.js", "lib/types/**/*.js", "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "dependencies": { + "@deepseek-ai/dsh-type-meta": "workspace:^", "zod": "^4.4.3" }, "peerDependencies": { diff --git a/packages/typert/registry/src/client/index.ts b/packages/typert/registry/src/client/index.ts new file mode 100644 index 0000000000..e468e78999 --- /dev/null +++ b/packages/typert/registry/src/client/index.ts @@ -0,0 +1,15 @@ +/** Browser face of the shared TypeRT runtime registry. */ + +import type { Context } from 'cordis' +import { TypertRegistry } from '../service.ts' + +/** Required services: none; this is the Client reflection root. */ +export const inject: string[] = [] + +/** + * Install the same registry implementation used by the Host face. + * @param ctx - Client Cordis root. + */ +export function apply(ctx: Context): void { + new TypertRegistry(ctx) +} diff --git a/packages/typert/registry/src/index.ts b/packages/typert/registry/src/index.ts index 91a8383594..3619c02dff 100644 --- a/packages/typert/registry/src/index.ts +++ b/packages/typert/registry/src/index.ts @@ -1,12 +1,7 @@ -/** - * Runtime registry for generated Typert contributions. It owns live Zod - * instances and generated package reflection, but performs no TypeScript - * analysis or schema generation. - * @module @deepseek-ai/dsh-typert-registry - */ +/** Host entry for the shared TypeRT runtime registry. */ -import { Context, Service } from 'cordis' -import { z } from 'zod' +import type { z } from 'zod' +import type { TypeRTDisposer } from '@deepseek-ai/dsh-type-meta' import type { TypertContribution, TypertFace, @@ -16,204 +11,17 @@ import type { TypertSchemaRecord, } from './types.ts' -export type { - TypertContribution, - TypertDocTag, - TypertDocumentation, - TypertEventModel, - TypertFace, - TypertMemberModel, - TypertObjectModel, - TypertPackageFilter, - TypertPackageModel, - TypertPackageRecord, - TypertSchema, - TypertSchemaFilter, - TypertSchemaRecord, - TypertServiceModel, - TypertTypeModel, -} from './types.ts' +export { default, TypertRegistry, typertEndpoint, typertKey, typertPackageKey } from './service.ts' +export type * from './types.ts' -declare module 'cordis' { - interface Context { - typert: TypertRegistry +declare module '@deepseek-ai/dsh-type-meta' { + interface TypeRTService { + register(contribution: TypertContribution): TypeRTDisposer + get(key: string): TypertSchemaRecord | undefined + resolve(key: string): TypertSchemaRecord + list(filter?: TypertSchemaFilter): TypertSchemaRecord[] + getPackage(packageName: string, face?: TypertFace): TypertPackageRecord | undefined + listPackages(filter?: TypertPackageFilter): TypertPackageRecord[] + toJSONSchema(key: string, params?: z.core.ToJSONSchemaParams): z.core.JSONSchema.BaseSchema } } - -/** - * Compose the global key of one generated schema. - * @param packageName - contributing npm package. - * @param name - schema export name. - * @returns `#`. - */ -export function typertKey(packageName: string, name: string): string { - return `${packageName}#${name}` -} - -/** - * Compose the identity of one package-face model. - * @param packageName - contributing npm package. - * @param face - independently compiled face. - * @returns `#`. - */ -export function typertPackageKey(packageName: string, face: TypertFace): string { - return `${packageName}#${face}` -} - -/** - * Registry of generated schemas and package reflection. - * @typert service - */ -export class TypertRegistry extends Service { - private readonly schemas = new Map() - private readonly packages = new Map() - - constructor(ctx: Context) { - super(ctx, 'typert') - } - - /** - * Register one generated contribution atomically for the calling fiber. - * Duplicate package-face identities or schema keys reject the whole batch. - * @param contribution - generated schemas and package metadata. - * @returns the exact effect disposer that removes this contribution. - */ - register(contribution: TypertContribution): () => void { - const packageRecord = this.validatePackage(contribution) - const schemaRecords = this.validateSchemas(contribution) - const { schemas, packages } = this - const dispose = this.ctx.effect(function* () { - packages.set(packageRecord.key, packageRecord) - for (const record of schemaRecords) schemas.set(record.key, record) - yield () => { - packages.delete(packageRecord.key) - for (const record of schemaRecords) schemas.delete(record.key) - } - }, 'typert.register()') - // oxlint-disable-next-line typescript/no-misused-promises -- synchronous cleanup; preserve Cordis disposer identity - return dispose - } - - /** - * Look up one schema by `#`. - * @param key - global schema key. - * @returns the live schema record, or `undefined` when absent. - */ - get(key: string): TypertSchemaRecord | undefined { - return this.schemas.get(key) - } - - /** - * Resolve one required schema. - * @param key - global schema key. - * @returns the live schema record. - * @throws when the key is malformed, the package face is absent, or the schema is not contributed. - */ - resolve(key: string): TypertSchemaRecord { - const record = this.schemas.get(key) - if (record !== undefined) return record - const hash = key.indexOf('#') - if (hash <= 0 || hash === key.length - 1) { - throw new Error(`typert: invalid schema key "${key}" — expected "#"`) - } - const packageName = key.slice(0, hash) - if ([...this.packages.values()].some(candidate => candidate.package === packageName)) { - throw new Error( - `typert: cannot resolve "${key}" — package "${packageName}" is registered but contributes no schema named "${key.slice(hash + 1)}"`, - ) - } - throw new Error(`typert: cannot resolve "${key}" — package "${packageName}" has no registered contribution`) - } - - /** - * Enumerate live schemas in registration order. - * @param filter - optional package and face restriction. - * @returns matching schema records. - */ - list(filter: TypertSchemaFilter = {}): TypertSchemaRecord[] { - return [...this.schemas.values()].filter(record => matches(record, filter)) - } - - /** - * Look up generated reflection for one package face. - * @param packageName - exact npm package name. - * @param face - face to query; defaults to the host runtime. - * @returns the live package record, or `undefined` when absent. - */ - getPackage(packageName: string, face: TypertFace = 'host'): TypertPackageRecord | undefined { - return this.packages.get(typertPackageKey(packageName, face)) - } - - /** - * Enumerate generated package reflection in registration order. - * @param filter - optional package and face restriction. - * @returns matching package records. - */ - listPackages(filter: TypertPackageFilter = {}): TypertPackageRecord[] { - return [...this.packages.values()].filter(record => matches(record, filter)) - } - - /** - * Project a live Zod schema to JSON Schema without caching the result. - * @param key - global schema key. - * @param params - Zod projection parameters. - * @returns a fresh JSON Schema document. - */ - toJSONSchema(key: string, params?: z.core.ToJSONSchemaParams): z.core.JSONSchema.BaseSchema { - return z.toJSONSchema(this.resolve(key).schema, params) - } - - private validatePackage(contribution: TypertContribution): TypertPackageRecord { - validateSegment('package name', contribution.package) - const face: unknown = contribution.face - if (face !== 'host' && face !== 'client') { - throw new Error(`typert: invalid face ${JSON.stringify(face)} — expected "host" or "client"`) - } - const key = typertPackageKey(contribution.package, contribution.face) - if (this.packages.has(key)) { - throw new Error(`typert: package face "${key}" is already registered`) - } - return { - package: contribution.package, - face, - key, - model: contribution.model, - } - } - - private validateSchemas(contribution: TypertContribution): TypertSchemaRecord[] { - const records: TypertSchemaRecord[] = [] - const batch = new Set() - for (const schema of contribution.schemas) { - validateSegment('schema name', schema.name) - const key = typertKey(contribution.package, schema.name) - if (batch.has(key) || this.schemas.has(key)) { - throw new Error(`typert: schema "${key}" is already registered`) - } - batch.add(key) - records.push({ - ...schema, - package: contribution.package, - face: contribution.face, - key, - }) - } - return records - } -} - -function matches( - record: { readonly package: string; readonly face: TypertFace }, - filter: { readonly package?: string; readonly face?: TypertFace }, -): boolean { - return (filter.package === undefined || record.package === filter.package) - && (filter.face === undefined || record.face === filter.face) -} - -function validateSegment(subject: string, value: string): void { - if (value.length === 0 || value.includes('#')) { - throw new Error(`typert: invalid ${subject} "${value}" — must be nonempty and must not contain "#"`) - } -} - -export default TypertRegistry diff --git a/packages/typert/registry/src/service.ts b/packages/typert/registry/src/service.ts new file mode 100644 index 0000000000..16160a3860 --- /dev/null +++ b/packages/typert/registry/src/service.ts @@ -0,0 +1,584 @@ +/** + * Runtime registry for generated TypeRT reflection, Remote invocations, and + * dependency-inverted lookup/Context providers. It performs no TypeScript + * analysis or schema generation. + * @module @deepseek-ai/dsh-typert-registry + */ + +import { Context, Service } from 'cordis' +import { z } from 'zod' +import type { + InvocationDescriptor, + TypeRTClientContextBinder, + TypeRTContextMap, + TypeRTContextRegistry, + TypeRTContextWire, + TypeRTDisposer, + TypeRTHostContextProvider, + TypeRTLocalRegistry, + TypeRTLookupHost, + TypeRTLookupMap, + TypeRTLookupProvider, + TypeRTLookupRegistry, + TypeRTLookupWire, + TypeRTRemoteContribution, + TypeRTRemoteRegistry, + TypeRTRegistryChange, + TypeRTRegistryListener, + TypeRTService, +} from '@deepseek-ai/dsh-type-meta' +import type { + TypertContribution, + TypertFace, + TypertPackageFilter, + TypertPackageRecord, + TypertSchemaFilter, + TypertSchemaRecord, +} from './types.ts' + +/** + * Compose the global key of one generated schema. + * @param packageName - contributing npm package. + * @param name - schema export name. + * @returns `#`. + */ +export function typertKey(packageName: string, name: string): string { + return `${packageName}#${name}` +} + +/** + * Compose the identity of one package-face model. + * @param packageName - contributing npm package. + * @param face - independently compiled face. + * @returns `#`. + */ +export function typertPackageKey(packageName: string, face: TypertFace): string { + return `${packageName}#${face}` +} + +/** + * Compose the endpoint key used by local and Remote invocation registries. + * @param descriptor - invocation whose namespace and method form the endpoint. + * @returns `/`. + */ +export function typertEndpoint(descriptor: Pick): string { + return `${descriptor.namespace}/${descriptor.method}` +} + +interface DescriptorEntry { + readonly descriptor: InvocationDescriptor + readonly owner: object +} + +interface ProviderEntry { + readonly provider: Provider + readonly owner: object +} + +type ReportObserverError = (change: TypeRTRegistryChange, error: unknown) => void + +class ChangeSource { + private readonly listeners = new Set() + + constructor(private readonly report: ReportObserverError) {} + + subscribe(ctx: Context, listener: TypeRTRegistryListener): TypeRTDisposer { + const { listeners } = this + return ctx.effect(function* () { + listeners.add(listener) + yield () => { listeners.delete(listener) } + }, 'typert registry subscription') + } + + emit(change: TypeRTRegistryChange): void { + for (const listener of [...this.listeners]) { + try { + listener(change) + } catch (error) { + this.report(change, error) + } + } + } +} + +class DescriptorStore { + private readonly entries = new Map() + private readonly ids = new Map() + private readonly history = new Set() + private readonly changes: ChangeSource + + constructor( + private readonly kind: 'local' | 'remote', + report: ReportObserverError, + ) { + this.changes = new ChangeSource(report) + } + + validate(descriptors: readonly InvocationDescriptor[]): void { + const endpoints = new Set() + const ids = new Set() + for (const descriptor of descriptors) { + validateInvocation(descriptor) + const endpoint = typertEndpoint(descriptor) + if (endpoints.has(endpoint) || this.entries.has(endpoint)) { + throw new Error(`typert: ${this.kind} endpoint "${endpoint}" is already registered`) + } + if (ids.has(descriptor.id) || this.ids.has(descriptor.id)) { + throw new Error(`typert: ${this.kind} invocation id "${descriptor.id}" is already registered`) + } + endpoints.add(endpoint) + ids.add(descriptor.id) + } + } + + commit(owner: object, descriptors: readonly InvocationDescriptor[]): void { + for (const descriptor of descriptors) { + const entry = { descriptor, owner } + const endpoint = typertEndpoint(descriptor) + this.entries.set(endpoint, entry) + this.ids.set(descriptor.id, entry) + this.history.add(endpoint) + } + for (const descriptor of descriptors) { + this.changes.emit({ kind: this.kind, key: typertEndpoint(descriptor) }) + } + } + + withdraw(owner: object, descriptors: readonly InvocationDescriptor[]): void { + const removed: string[] = [] + for (const descriptor of descriptors) { + const endpoint = typertEndpoint(descriptor) + const entry = this.entries.get(endpoint) + if (entry?.owner !== owner) continue + this.entries.delete(endpoint) + if (this.ids.get(descriptor.id) === entry) this.ids.delete(descriptor.id) + removed.push(endpoint) + } + for (const endpoint of removed) this.changes.emit({ kind: this.kind, key: endpoint }) + } + + get(endpoint: string): InvocationDescriptor | undefined { + return this.entries.get(endpoint)?.descriptor + } + + hasSeen(endpoint: string): boolean { + return this.history.has(endpoint) + } + + list(): readonly InvocationDescriptor[] { + return [...this.entries.values()].map(entry => entry.descriptor) + } + + subscribe(ctx: Context, listener: TypeRTRegistryListener): TypeRTDisposer { + return this.changes.subscribe(ctx, listener) + } +} + +class RemoteStore { + private readonly packages = new Map() + + constructor(private readonly descriptors: DescriptorStore) {} + + view(ctx: Context): TypeRTRemoteRegistry { + return { + register: contribution => this.register(ctx, contribution), + get: endpoint => this.descriptors.get(endpoint), + list: () => this.descriptors.list(), + subscribe: listener => this.descriptors.subscribe(ctx, listener), + } + } + + private register(ctx: Context, contribution: TypeRTRemoteContribution): TypeRTDisposer { + validateSegment('Remote package name', contribution.package) + if (this.packages.has(contribution.package)) { + throw new Error(`typert: Remote package "${contribution.package}" is already registered`) + } + this.descriptors.validate(contribution.descriptors) + const owner = {} + const { packages, descriptors } = this + return ctx.effect(function* () { + packages.set(contribution.package, owner) + descriptors.commit(owner, contribution.descriptors) + yield () => { + if (packages.get(contribution.package) === owner) packages.delete(contribution.package) + descriptors.withdraw(owner, contribution.descriptors) + } + }, `typert.remotes.register(${JSON.stringify(contribution.package)})`) + } +} + +class LookupStore { + private readonly providers = new Map>() + private readonly changes: ChangeSource + + constructor(report: ReportObserverError) { + this.changes = new ChangeSource(report) + } + + view(ctx: Context): TypeRTLookupRegistry { + return { + register: >( + key: K, + provider: TypeRTLookupProvider< + TypeRTLookupHost, + TypeRTLookupWire + >, + ) => this.register(ctx, key, provider), + get: key => this.providers.get(key)?.provider, + keys: () => [...this.providers.keys()], + subscribe: listener => this.changes.subscribe(ctx, listener), + } + } + + private register(ctx: Context, key: string, provider: TypeRTLookupProvider): TypeRTDisposer { + validateSegment('lookup key', key) + validateSegment('lookup parameter', provider.parameter) + validateWireName('lookup wire field', provider.wire) + validateNonempty('lookup Host type symbol', provider.hostTypeSymbol) + validateNonempty('lookup wire type symbol', provider.wireTypeSymbol) + if (this.providers.has(key)) throw new Error(`typert: lookup "${key}" is already registered`) + const owner = {} + const entry: ProviderEntry = { provider, owner } + const { providers, changes } = this + return ctx.effect(function* () { + providers.set(key, entry) + changes.emit({ kind: 'lookup', key }) + yield () => { + if (providers.get(key) !== entry) return + providers.delete(key) + changes.emit({ kind: 'lookup', key }) + } + }, `typert.lookups.register(${JSON.stringify(key)})`) + } +} + +class ContextStore { + private readonly hosts = new Map>() + private readonly clients = new Map>() + private readonly changes: ChangeSource + + constructor(report: ReportObserverError) { + this.changes = new ChangeSource(report) + } + + view(ctx: Context): TypeRTContextRegistry { + return { + registerHost: >( + key: K, + provider: TypeRTHostContextProvider>, + ) => this.registerHost(ctx, key, provider), + registerClient: >( + key: K, + binder: TypeRTClientContextBinder>, + ) => this.registerClient(ctx, key, binder), + getHost: key => this.hosts.get(key)?.provider, + getClient: key => this.clients.get(key)?.provider, + subscribe: listener => this.changes.subscribe(ctx, listener), + } + } + + private registerHost(ctx: Context, key: string, provider: TypeRTHostContextProvider): TypeRTDisposer { + validateSegment('Context key', key) + validateWireName('Context wire field', provider.wire) + validateNonempty('Context wire type symbol', provider.wireTypeSymbol) + return this.registerProvider(ctx, this.hosts, 'host-context', key, provider) + } + + private registerClient(ctx: Context, key: string, binder: TypeRTClientContextBinder): TypeRTDisposer { + validateSegment('Context key', key) + return this.registerProvider(ctx, this.clients, 'client-context', key, binder) + } + + private registerProvider( + ctx: Context, + table: Map>, + kind: 'host-context' | 'client-context', + key: string, + provider: Provider, + ): TypeRTDisposer { + if (table.has(key)) throw new Error(`typert: ${kind} provider "${key}" is already registered`) + const entry: ProviderEntry = { provider, owner: {} } + const { changes } = this + return ctx.effect(function* () { + table.set(key, entry) + changes.emit({ kind, key }) + yield () => { + if (table.get(key) !== entry) return + table.delete(key) + changes.emit({ kind, key }) + } + }, `typert.contexts.register(${JSON.stringify(key)})`) + } +} + +/** + * Registry of generated schemas, package reflection, invocations, and Remote + * dependency providers. + * @typert service typert + */ +export class TypertRegistry extends Service implements TypeRTService { + private readonly schemas = new Map() + private readonly packages = new Map() + private readonly localStore: DescriptorStore + private readonly remoteStore: RemoteStore + private readonly lookupStore: LookupStore + private readonly contextStore: ContextStore + + constructor(ctx: Context) { + super(ctx, 'typert') + const report: ReportObserverError = (change, error) => { + ctx.logger.warn(`typert: ${change.kind} observer for "${change.key}" failed`) + ctx.logger.warn(error) + } + this.localStore = new DescriptorStore('local', report) + this.remoteStore = new RemoteStore(new DescriptorStore('remote', report)) + this.lookupStore = new LookupStore(report) + this.contextStore = new ContextStore(report) + } + + /** Current-environment invocation definitions. */ + get local(): TypeRTLocalRegistry { + const ctx = this.ctx + return { + get: endpoint => this.localStore.get(endpoint), + hasSeen: endpoint => this.localStore.hasSeen(endpoint), + list: () => this.localStore.list(), + subscribe: listener => this.localStore.subscribe(ctx, listener), + } + } + + /** Consumer-selected Remote definitions. */ + get remotes(): TypeRTRemoteRegistry { + return this.remoteStore.view(this.ctx) + } + + /** Host object lookup providers. */ + get lookups(): TypeRTLookupRegistry { + return this.lookupStore.view(this.ctx) + } + + /** Host Context providers and Client Context binders. */ + get contexts(): TypeRTContextRegistry { + return this.contextStore.view(this.ctx) + } + + /** + * Register one generated contribution atomically for the calling fiber. + * Duplicate package-face identities, schemas, invocation ids, or endpoints + * reject the whole batch. + * @param contribution - generated schemas, reflection, and Host invocations. + * @returns the exact effect disposer that removes this contribution. + */ + register(contribution: TypertContribution): TypeRTDisposer { + const packageRecord = this.validatePackage(contribution) + const schemaRecords = this.validateSchemas(contribution) + const invocations = contribution.invocations ?? [] + this.localStore.validate(invocations) + const owner = {} + const { schemas, packages, localStore } = this + return this.ctx.effect(function* () { + packages.set(packageRecord.key, packageRecord) + for (const record of schemaRecords) schemas.set(record.key, record) + localStore.commit(owner, invocations) + yield () => { + if (packages.get(packageRecord.key) === packageRecord) packages.delete(packageRecord.key) + for (const record of schemaRecords) { + if (schemas.get(record.key) === record) schemas.delete(record.key) + } + localStore.withdraw(owner, invocations) + } + }, 'typert.register()') + } + + /** + * Look up one schema by `#`. + * @param key - global schema key. + * @returns the live schema record, or `undefined` when absent. + */ + get(key: string): TypertSchemaRecord | undefined { + return this.schemas.get(key) + } + + /** + * Resolve one required schema. + * @param key - global schema key. + * @returns the live schema record. + * @throws when the key is malformed, the package face is absent, or the schema is not contributed. + */ + resolve(key: string): TypertSchemaRecord { + const record = this.schemas.get(key) + if (record !== undefined) return record + const hash = key.indexOf('#') + if (hash <= 0 || hash === key.length - 1) { + throw new Error(`typert: invalid schema key "${key}" — expected "#"`) + } + const packageName = key.slice(0, hash) + if ([...this.packages.values()].some(candidate => candidate.package === packageName)) { + throw new Error( + `typert: cannot resolve "${key}" — package "${packageName}" is registered but contributes no schema named "${key.slice(hash + 1)}"`, + ) + } + throw new Error(`typert: cannot resolve "${key}" — package "${packageName}" has no registered contribution`) + } + + /** + * Enumerate live schemas in registration order. + * @param filter - optional package and face restriction. + * @returns matching schema records. + */ + list(filter: TypertSchemaFilter = {}): TypertSchemaRecord[] { + return [...this.schemas.values()].filter(record => matches(record, filter)) + } + + /** + * Look up generated reflection for one package face. + * @param packageName - exact npm package name. + * @param face - face to query; defaults to the host runtime. + * @returns the live package record, or `undefined` when absent. + */ + getPackage(packageName: string, face: TypertFace = 'host'): TypertPackageRecord | undefined { + return this.packages.get(typertPackageKey(packageName, face)) + } + + /** + * Enumerate generated package reflection in registration order. + * @param filter - optional package and face restriction. + * @returns matching package records. + */ + listPackages(filter: TypertPackageFilter = {}): TypertPackageRecord[] { + return [...this.packages.values()].filter(record => matches(record, filter)) + } + + /** + * Project a live Zod schema to JSON Schema without caching the result. + * @param key - global schema key. + * @param params - Zod projection parameters. + * @returns a fresh JSON Schema document. + */ + toJSONSchema(key: string, params?: z.core.ToJSONSchemaParams): z.core.JSONSchema.BaseSchema { + return z.toJSONSchema(this.resolve(key).schema, params) + } + + private validatePackage(contribution: TypertContribution): TypertPackageRecord { + validateSegment('package name', contribution.package) + const face: unknown = contribution.face + if (face !== 'host' && face !== 'client') { + throw new Error(`typert: invalid face ${JSON.stringify(face)} — expected "host" or "client"`) + } + const key = typertPackageKey(contribution.package, contribution.face) + if (this.packages.has(key)) { + throw new Error(`typert: package face "${key}" is already registered`) + } + return { + package: contribution.package, + face, + key, + model: contribution.model, + } + } + + private validateSchemas(contribution: TypertContribution): TypertSchemaRecord[] { + const records: TypertSchemaRecord[] = [] + const batch = new Set() + for (const schema of contribution.schemas) { + validateSegment('schema name', schema.name) + const key = typertKey(contribution.package, schema.name) + if (batch.has(key) || this.schemas.has(key)) { + throw new Error(`typert: schema "${key}" is already registered`) + } + batch.add(key) + records.push({ + ...schema, + package: contribution.package, + face: contribution.face, + key, + }) + } + return records + } +} + +function matches( + record: { readonly package: string; readonly face: TypertFace }, + filter: { readonly package?: string; readonly face?: TypertFace }, +): boolean { + return (filter.package === undefined || record.package === filter.package) + && (filter.face === undefined || record.face === filter.face) +} + +function validateInvocation(descriptor: InvocationDescriptor): void { + validateNonempty('invocation id', descriptor.id) + validateSegment('invocation service key', descriptor.service) + validateWireName('invocation namespace', descriptor.namespace) + validateWireName('invocation method', descriptor.method) + if (descriptor.implementation !== undefined) { + validateWireName('invocation implementation method', descriptor.implementation) + } + validateCodec(descriptor.result, `${descriptor.id} result`) + const wires = new Set() + for (const parameter of descriptor.parameters) { + validateWireName('parameter name', parameter.name) + validateWireName('parameter wire field', parameter.wire) + if (wires.has(parameter.wire)) { + throw new Error(`typert: invocation "${descriptor.id}" repeats wire field "${parameter.wire}"`) + } + wires.add(parameter.wire) + if (parameter.source === 'lookup') { + if (parameter.lookup === undefined) { + throw new Error(`typert: invocation "${descriptor.id}" lookup parameter "${parameter.name}" has no lookup key`) + } + validateSegment('lookup key', parameter.lookup) + } else if (parameter.lookup !== undefined) { + throw new Error(`typert: invocation "${descriptor.id}" JSON parameter "${parameter.name}" declares a lookup key`) + } + validateCodec(parameter.codec, `${descriptor.id} parameter ${parameter.name}`) + } + if (descriptor.scope !== undefined) { + if (descriptor.invocation.kind !== 'direct') { + throw new Error(`typert: invocation "${descriptor.id}" Context receiver cannot declare a direct scope projection`) + } + validateSegment('scope Context key', descriptor.scope.context) + validateWireName('scope wire field', descriptor.scope.wire) + const lookups = descriptor.parameters.filter(candidate => candidate.source === 'lookup') + const parameter = lookups.length === 1 ? lookups[0] : undefined + if (parameter === undefined || parameter.wire !== descriptor.scope.wire + || parameter.lookup !== descriptor.scope.context) { + throw new Error( + `typert: invocation "${descriptor.id}" scope wire "${descriptor.scope.wire}" must select its only lookup parameter`, + ) + } + } + if (descriptor.invocation.kind === 'context') { + validateSegment('Context key', descriptor.invocation.context) + validateWireName('Context wire field', descriptor.invocation.wire) + if (wires.has(descriptor.invocation.wire)) { + throw new Error(`typert: invocation "${descriptor.id}" repeats wire field "${descriptor.invocation.wire}"`) + } + validateCodec(descriptor.invocation.codec, `${descriptor.id} Context`) + } +} + +function validateCodec(codec: InvocationDescriptor['result'], subject: string): void { + if (codec.mode === 'src-json') return + validateNonempty(`${subject} type symbol`, codec.typeSymbol) + if (typeof codec.schema.parse !== 'function') { + throw new Error(`typert: ${subject} strict codec has no parse() method`) + } +} + +function validateWireName(subject: string, value: string): void { + validateSegment(subject, value) + if (value.includes('/')) throw new Error(`typert: invalid ${subject} "${value}" — must not contain "/"`) +} + +function validateSegment(subject: string, value: string): void { + if (value.length === 0 || value.includes('#')) { + throw new Error(`typert: invalid ${subject} "${value}" — must be nonempty and must not contain "#"`) + } +} + +function validateNonempty(subject: string, value: string): void { + if (value.length === 0) throw new Error(`typert: invalid ${subject} — must be nonempty`) +} + +export default TypertRegistry diff --git a/packages/typert/registry/src/types.ts b/packages/typert/registry/src/types.ts index 2fb29f5024..6ba0e0f1f2 100644 --- a/packages/typert/registry/src/types.ts +++ b/packages/typert/registry/src/types.ts @@ -5,6 +5,7 @@ */ import type { z } from 'zod' +import type { InvocationDescriptor } from '@deepseek-ai/dsh-type-meta' /** Independently compiled side that produced a contribution. */ export type TypertFace = 'host' | 'client' @@ -82,6 +83,13 @@ export interface TypertContribution { readonly face: TypertFace readonly schemas: readonly TypertSchema[] readonly model: TypertPackageModel + /** Host invocation definitions; absent on artifacts generated before Remote support. */ + readonly invocations?: readonly InvocationDescriptor[] +} + +/** Generated Host contribution with strict Remote invocation definitions. */ +export interface TypertLocalContribution extends TypertContribution { + readonly invocations: readonly InvocationDescriptor[] } /** A live schema plus its contribution identity. */ diff --git a/packages/typert/registry/tests/typert.spec.ts b/packages/typert/registry/tests/typert.spec.ts index 06eb9c107e..a98f99f912 100644 --- a/packages/typert/registry/tests/typert.spec.ts +++ b/packages/typert/registry/tests/typert.spec.ts @@ -2,10 +2,27 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import { z } from 'zod' import TypertRegistry, { + typertEndpoint, typertKey, typertPackageKey, type TypertContribution, } from '@deepseek-ai/dsh-typert-registry' +import type { + InvocationDescriptor, + TypeRTContext, + TypeRTLookup, + TypeRTRemoteContribution, +} from '@deepseek-ai/dsh-type-meta' + +declare module '@deepseek-ai/dsh-type-meta' { + interface TypeRTLookupMap { + fixture: TypeRTLookup<{ readonly id: string }, string> + } + + interface TypeRTContextMap { + registryFixture: TypeRTContext + } +} async function makeCtx(): Promise { const ctx = new Context() @@ -42,6 +59,42 @@ function toolsContribution(schema: z.ZodType = z.object({ name: z.string() })): } } +function invocation(id = '@fixture/remote#goals/create'): InvocationDescriptor { + return { + id, + service: 'goals', + namespace: 'goals', + method: 'create', + invocation: { kind: 'direct' }, + parameters: [{ + name: 'request', + wire: 'request', + source: 'json', + codec: { mode: 'src-json' }, + }], + result: { mode: 'src-json' }, + } +} + +function scopedInvocation(): InvocationDescriptor { + return { + ...invocation('@fixture/remote#goals/create-scoped'), + scope: { context: 'fixture', wire: 'agentId' }, + parameters: [{ + name: 'agent', + wire: 'agentId', + source: 'lookup', + lookup: 'fixture', + codec: { mode: 'src-json' }, + }, { + name: 'request', + wire: 'request', + source: 'json', + codec: { mode: 'src-json' }, + }], + } +} + describe('TypertRegistry', () => { it('registers and queries generated schemas separately from package reflection', async () => { const ctx = await makeCtx() @@ -69,7 +122,7 @@ describe('TypertRegistry', () => { const dispose = ctx.typert.register(toolsContribution()) expect(ctx.typert.getPackage('@deepseek-ai/dsh-tools')).toBeDefined() - dispose() + await dispose() expect(ctx.typert.get('@deepseek-ai/dsh-tools#ToolInput')).toBeUndefined() expect(ctx.typert.getPackage('@deepseek-ai/dsh-tools')).toBeUndefined() @@ -145,4 +198,133 @@ describe('TypertRegistry', () => { expect(projected).toMatchObject({ type: 'object', properties: { name: { type: 'string' } } }) expect(ctx.typert.toJSONSchema('@deepseek-ai/dsh-tools#ToolInput')).not.toBe(projected) }) + + it('registers local invocations atomically with generated reflection', async () => { + const ctx = await makeCtx() + const descriptor = invocation() + const contribution = { ...toolsContribution(), invocations: [descriptor] } + const changes: string[] = [] + ctx.typert.local.subscribe((change) => { changes.push(`${change.kind}:${change.key}`) }) + + expect(ctx.typert.local.hasSeen('goals/create')).toBe(false) + const dispose = ctx.typert.register(contribution) + + expect(typertEndpoint(descriptor)).toBe('goals/create') + expect(ctx.typert.local.get('goals/create')).toBe(descriptor) + expect(ctx.typert.local.hasSeen('goals/create')).toBe(true) + expect(ctx.typert.local.list()).toEqual([descriptor]) + expect(changes).toEqual(['local:goals/create']) + + await dispose() + expect(ctx.typert.local.list()).toEqual([]) + expect(ctx.typert.local.hasSeen('goals/create')).toBe(true) + expect(ctx.typert.getPackage('@deepseek-ai/dsh-tools')).toBeUndefined() + expect(changes).toEqual(['local:goals/create', 'local:goals/create']) + }) + + it('mounts Remote contributions in the calling fiber and withdraws them exactly', async () => { + const ctx = await makeCtx() + const descriptor = invocation() + const contribution: TypeRTRemoteContribution = { + package: '@fixture/remote', + descriptors: [descriptor], + } + const changes: string[] = [] + ctx.typert.remotes.subscribe((change) => { changes.push(`${change.kind}:${change.key}`) }) + const fiber = ctx.plugin(Object.assign( + (child: Context) => { child.typert.remotes.register(contribution) }, + { inject: ['typert'] }, + )) + await fiber + + expect(ctx.typert.remotes.get('goals/create')).toBe(descriptor) + expect(() => ctx.typert.remotes.register(contribution)).toThrow('Remote package') + + await fiber.dispose() + expect(ctx.typert.remotes.list()).toEqual([]) + expect(changes).toEqual(['remote:goals/create', 'remote:goals/create']) + }) + + it('accepts only a direct scope selecting its unique lookup parameter', async () => { + const ctx = await makeCtx() + const descriptor = scopedInvocation() + const dispose = ctx.typert.remotes.register({ package: '@fixture/scoped', descriptors: [descriptor] }) + expect(ctx.typert.remotes.get('goals/create')).toBe(descriptor) + await dispose() + + const cases: readonly [InvocationDescriptor, string][] = [ + [{ + ...descriptor, + invocation: { + kind: 'context', + context: 'fixture', + wire: 'scopeId', + codec: { mode: 'src-json' }, + }, + }, 'Context receiver cannot declare a direct scope projection'], + [{ ...descriptor, scope: { context: 'fixture', wire: 'missingId' } }, 'must select its only lookup parameter'], + [{ + ...descriptor, + parameters: [...descriptor.parameters, { + name: 'other', + wire: 'otherId', + source: 'lookup', + lookup: 'fixture', + codec: { mode: 'src-json' }, + }], + }, 'must select its only lookup parameter'], + [{ ...descriptor, scope: { context: 'other', wire: 'agentId' } }, 'must select its only lookup parameter'], + ] + for (const [index, [candidate, message]] of cases.entries()) { + expect(() => ctx.typert.remotes.register({ + package: `@fixture/rejected-${String(index)}`, + descriptors: [candidate], + })).toThrow(message) + } + expect(ctx.typert.remotes.list()).toEqual([]) + }) + + it('registers lookup and Context providers without domain branches', async () => { + const ctx = await makeCtx() + const object = { id: 'agent-1' } + const scoped = ctx.extend() + const disposeLookup = ctx.typert.lookups.register('fixture', { + parameter: 'agent', + wire: 'agentId', + hostTypeSymbol: '@fixture/agent#Agent', + wireTypeSymbol: '@fixture/session#SessionId', + resolve: id => id === object.id ? object : undefined, + }) + const disposeHost = ctx.typert.contexts.registerHost('registryFixture', { + wire: 'agentId', + wireTypeSymbol: '@fixture/session#SessionId', + resolve: id => id === object.id ? scoped : undefined, + }) + const disposeClient = ctx.typert.contexts.registerClient('registryFixture', { + identity: candidate => candidate === scoped ? object.id : undefined, + }) + + expect(ctx.typert.lookups.get('fixture')?.resolve('agent-1')).toBe(object) + expect(ctx.typert.contexts.getHost('registryFixture')?.resolve('agent-1')).toBe(scoped) + expect(ctx.typert.contexts.getClient('registryFixture')?.identity(scoped)).toBe('agent-1') + + await Promise.all([disposeClient(), disposeHost(), disposeLookup()]) + expect(ctx.typert.lookups.keys()).toEqual([]) + expect(ctx.typert.contexts.getHost('registryFixture')).toBeUndefined() + expect(ctx.typert.contexts.getClient('registryFixture')).toBeUndefined() + }) + + it('contains change-listener failures and still notifies later listeners', async () => { + const ctx = await makeCtx() + const warnings: unknown[] = [] + ctx.logger.warn = ((message: unknown) => { warnings.push(message) }) as typeof ctx.logger.warn + let observed = 0 + ctx.typert.remotes.subscribe(() => { throw new Error('observer failed') }) + ctx.typert.remotes.subscribe(() => { observed += 1 }) + + ctx.typert.remotes.register({ package: '@fixture/remote', descriptors: [invocation()] }) + + expect(observed).toBe(1) + expect(warnings.map(String)).toContain('Error: observer failed') + }) }) diff --git a/packages/typert/registry/tsconfig.json b/packages/typert/registry/tsconfig.json index 9966c8ca8a..311dfa4b6d 100644 --- a/packages/typert/registry/tsconfig.json +++ b/packages/typert/registry/tsconfig.json @@ -16,6 +16,9 @@ }, { "path": "../../support/invariants" + }, + { + "path": "../type-meta" } ] } diff --git a/packages/typert/registry/tsdown.config.ts b/packages/typert/registry/tsdown.config.ts index 144513225b..e494104c4d 100644 --- a/packages/typert/registry/tsdown.config.ts +++ b/packages/typert/registry/tsdown.config.ts @@ -1,25 +1,3 @@ -import { defineConfig } from 'tsdown' +import { clientBundle } from '../../client/tsdown.client.ts' -/** Build the registry and its invariant companion as independent bundles. */ -export default defineConfig([ - { - entry: ['lib/types/index.js'], - outDir: 'lib', - format: ['esm'], - platform: 'node', - target: 'es2024', - fixedExtension: false, - dts: false, - clean: false, - }, - { - entry: ['lib/types/invariant.js'], - outDir: 'lib', - format: ['esm'], - platform: 'node', - target: 'es2024', - fixedExtension: false, - dts: false, - clean: false, - }, -]) +export default clientBundle('@deepseek-ai/dsh-typert-registry', ['lib/types/index.js', 'lib/types/invariant.js']) diff --git a/packages/typert/type-meta/README.i18n.yaml b/packages/typert/type-meta/README.i18n.yaml new file mode 100644 index 0000000000..90d93152b7 --- /dev/null +++ b/packages/typert/type-meta/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/typert/type-meta/README.md +README.md: 9dd8dadd07b219c7471c8851262958d4d9e96a43 +README.zh.md: 5716f56d988c6d2dd9cd237346c3b02ec9ae7c4e diff --git a/packages/typert/type-meta/README.md b/packages/typert/type-meta/README.md new file mode 100644 index 0000000000..9dd8dadd07 --- /dev/null +++ b/packages/typert/type-meta/README.md @@ -0,0 +1,33 @@ +# @deepseek-ai/dsh-type-meta + +English | [中文](README.zh.md) + +Compiler-independent declarations shared by business packages, generated TypeRT artifacts, the Host Gateway, and Client API. This package owns Remote decorators, the explicit Service binding, merge-extensible protocol maps, invocation descriptors, codecs, and provider contracts; it does not run TypeScript analysis or provide a Cordis service. + +## Remote declarations + +- `@Remote` marks a public instance method for direct invocation on its registered Cordis Service. +- `@RemoteContext(key)` marks a method whose receiver is selected from a merge-declared scoped Context kind. +- `bindTypeRTGateway(this, serviceKey, options?)` creates the visible, frozen binding between a Service instance, its exact Cordis key, and its wire namespace. +- `remoteMethods(service)` returns a detached declaration-order snapshot used by the Gateway's SRC fallback. + +Decorator initializers retain markers in a module-private `WeakMap` keyed by the Service prototype. They do not add constructor symbols, prototype properties, parameter metadata, or runtime reflection fields. The Service opts in explicitly through its `typertGateway` binding field. + +## TypeRT protocol + +Business packages extend `TypeRTLookupMap` and `TypeRTContextMap` to associate Host objects or scoped Contexts with their wire identities. Generated artifacts extend `TypeRTRemoteMap`, `TypeRTRemoteContextMap`, and `TypeRTRemoteNamespaceMap` so Client imports expose only selected Remote methods. `InvocationDescriptor` is the shared runtime form consumed by the registry, Gateway, and Client API. + +Lookup and Context packages own both sides of their contract: declaration merging supplies the static association, while runtime providers register identity resolution with `ctx.typert`. Strict codecs carry generated schemas; `src-json` codecs identify the weaker source-launch path. + +## Model Experience + +None, as this protocol package declares application reflection and registers no model surface. + +#### KV Cache effect + +No direct effect. + +## Known Limitations and Deferred Work + +- Decorator markers contain only the method name and direct or Context invocation mode. Parameter, result, lookup, and schema reflection require the TypeRT build pipeline. +- Remote decorators accept only public, non-static instance methods with string names. SRC execution cannot represent overloaded, destructured, defaulted, or rest-parameter signatures. diff --git a/packages/typert/type-meta/README.zh.md b/packages/typert/type-meta/README.zh.md new file mode 100644 index 0000000000..5716f56d98 --- /dev/null +++ b/packages/typert/type-meta/README.zh.md @@ -0,0 +1,33 @@ +# @deepseek-ai/dsh-type-meta + +[English](README.md) | 中文 + +该包提供不依赖编译器的声明,由业务包、生成的 TypeRT 产物、Host Gateway 和 Client API 共享。它负责 Remote 装饰器、显式服务绑定、可通过声明合并扩展的协议映射、调用描述符、编解码器和提供方契约;它不执行 TypeScript 分析,也不提供 Cordis 服务。 + +## Remote 声明 + +- `@Remote` 将公开实例方法标记为可在其注册的 Cordis 服务上直接调用。 +- `@RemoteContext(key)` 标记接收者选自合并声明的作用域 Context 类型的方法。 +- `bindTypeRTGateway(this, serviceKey, options?)` 在服务实例、其准确的 Cordis key 与协议命名空间之间创建可见且冻结的绑定。 +- `remoteMethods(service)` 返回按声明顺序排列、与内部状态分离的快照,供 Gateway 的 SRC 回退路径使用。 + +装饰器初始化器将标记保存在以服务 prototype 为键的模块私有 `WeakMap` 中。它们不会在构造函数上添加 symbol,也不会添加 prototype 属性、参数元数据或运行时反射字段。服务通过自身的 `typertGateway` 绑定字段显式接入。 + +## TypeRT 协议 + +业务包扩展 `TypeRTLookupMap` 和 `TypeRTContextMap`,以关联 Host 对象或作用域 Context 与其协议身份。生成的产物扩展 `TypeRTRemoteMap`、`TypeRTRemoteContextMap` 和 `TypeRTRemoteNamespaceMap`,使 Client 导入后仅暴露选定的 Remote 方法。`InvocationDescriptor` 是供注册表、Gateway 和 Client API 使用的共享运行时形式。 + +查找包与 Context 包同时负责其契约的两侧:声明合并提供静态关联,运行时提供方则向 `ctx.typert` 注册身份解析。严格编解码器携带生成的 schema;`src-json` 编解码器标识约束更弱的源码启动路径。 + +## 模型体验 + +无,因为该协议包声明应用反射,不注册任何模型接口。 + +#### KV Cache 影响 + +无直接影响。 + +## 已知限制与延期工作 + +- 装饰器标记仅包含方法名,以及直接调用或 Context 调用模式。参数、结果、查找和 schema 反射需要 TypeRT 构建流水线。 +- Remote 装饰器只接受具有字符串名称的公开、非静态实例方法。SRC 执行无法表示重载签名,以及包含解构参数、默认参数或剩余参数的方法签名。 diff --git a/packages/typert/type-meta/package.json b/packages/typert/type-meta/package.json new file mode 100644 index 0000000000..2ffcd6c5ed --- /dev/null +++ b/packages/typert/type-meta/package.json @@ -0,0 +1,42 @@ +{ + "name": "@deepseek-ai/dsh-type-meta", + "description": "Compiler-independent Remote metadata and TypeRT provider protocols", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./types": { + "types": "./lib/types/types.d.ts", + "default": "./lib/types/types.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/typert/type-meta/src/index.ts b/packages/typert/type-meta/src/index.ts new file mode 100644 index 0000000000..1e79bb2e55 --- /dev/null +++ b/packages/typert/type-meta/src/index.ts @@ -0,0 +1,223 @@ +/** + * Remote decorators and explicit Gateway bindings backed only by private + * module state. Strict reflection remains a TypeRT compiler responsibility. + * @module @deepseek-ai/dsh-type-meta + */ + +import type { TypeRTContextMap } from './types.ts' + +export type { + InvocationDescriptor, + InvocationParameterDescriptor, + InvocationSourceLocation, + TypeRTClientContextBinder, + TypeRTCodec, + TypeRTContext, + TypeRTContextMap, + TypeRTContextRegistry, + TypeRTContextWire, + TypeRTDisposer, + TypeRTHostContextProvider, + TypeRTLocalRegistry, + TypeRTLookup, + TypeRTLookupHost, + TypeRTLookupMap, + TypeRTLookupProvider, + TypeRTLookupRegistry, + TypeRTLookupWire, + TypeRTRemoteContextApi, + TypeRTRemoteContextMap, + TypeRTRemoteContextNamespace, + TypeRTRemoteContribution, + TypeRTRemoteMap, + TypeRTRemoteNamespace, + TypeRTRemoteNamespaceMap, + TypeRTRemoteRegistry, + TypeRTRegistryChange, + TypeRTRegistryListener, + TypeRTSchema, + TypeRTService, +} from './types.ts' + +/** Options for an explicit Service-to-Gateway binding. */ +export interface TypeRTGatewayBindingOptions { + /** Wire namespace; defaults to the Cordis service key. */ + readonly namespace?: string +} + +/** Visible declaration that one Service participates in TypeRT Gateway export. */ +export interface TypeRTGatewayBinding { + readonly service: Service + readonly serviceKey: string + readonly namespace: string +} + +/** Invocation mode recorded by a Remote method decorator. */ +export type RemoteInvocationMarker = + | { readonly kind: 'direct' } + | { readonly kind: 'context'; readonly context: string } + +/** One decorator marker discovered for a live Service instance. */ +export interface RemoteMethodMarker { + /** Public instance method carrying the implementation. */ + readonly method: string + /** Endpoint method when it differs from the implementation member. */ + readonly exportName?: string + readonly invocation: RemoteInvocationMarker +} + +type RemoteMethodDecorator = ( + method: (this: This, ...args: Args) => Result, + context: ClassMethodDecoratorContext Result>, +) => void + +interface RemoteInitializerContext { + readonly private: boolean + readonly static: boolean + readonly name: string | symbol + addInitializer(initializer: (this: This) => void): void +} + +interface StoredRemoteMethodMarker { + readonly exportName?: string + readonly invocation: RemoteInvocationMarker +} + +const markers = new WeakMap>() + +/** + * Bind one visible Service field to a Cordis key and Remote namespace. + * @param service - owning Service instance, normally `this`. + * @param serviceKey - exact Cordis service key. + * @param options - optional distinct wire namespace. + * @returns a frozen, inspectable binding with no compiler-injected metadata. + */ +export function bindTypeRTGateway( + service: Service, + serviceKey: string, + options: TypeRTGatewayBindingOptions = {}, +): TypeRTGatewayBinding { + validateName('service key', serviceKey) + const namespace = options.namespace ?? serviceKey + validateName('namespace', namespace) + return Object.freeze({ service, serviceKey, namespace }) +} + +/** + * Mark one public instance method as a direct Remote invocation. + * @param _method - decorated method; retained only by the class itself. + * @param context - standard decorator context used to schedule private marking. + */ +export function Remote( + _method: (this: This, ...args: Args) => Result, + context: ClassMethodDecoratorContext Result>, +): void +/** + * Mark one public instance method under a distinct exported method name. + * @param exportName - Remote endpoint method, without a namespace or slash. + * @returns a standard method decorator. + */ +export function Remote(exportName: string): RemoteMethodDecorator +export function Remote( + methodOrExportName: string | ((this: This, ...args: Args) => Result), + context?: ClassMethodDecoratorContext Result>, +): void | RemoteMethodDecorator { + if (typeof methodOrExportName === 'string') { + validateName('Remote export name', methodOrExportName) + return function ( + _method: (this: DecoratorThis, ...args: DecoratorArgs) => DecoratorResult, + decoratorContext: ClassMethodDecoratorContext< + DecoratorThis, + (this: DecoratorThis, ...args: DecoratorArgs) => DecoratorResult + >, + ): void { + addMarkerInitializer(decoratorContext, { kind: 'direct' }, methodOrExportName) + } + } + if (context === undefined) throw new TypeError('type-meta: Remote decorator context is missing') + addMarkerInitializer(context, { kind: 'direct' }) +} + +/** + * Create a decorator for a method resolved from one scoped Remote Context. + * @param key - merge-declared Context key. + * @param exportName - optional Remote export name; defaults to the method name. + * @returns a standard method decorator that records only private module state. + */ +export function RemoteContext( + key: Extract, + exportName?: string, +): RemoteMethodDecorator { + validateName('Context key', key) + if (exportName !== undefined) validateName('Remote export name', exportName) + return function ( + _method: (this: This, ...args: Args) => Result, + context: ClassMethodDecoratorContext Result>, + ): void { + addMarkerInitializer(context, { kind: 'context', context: key }, exportName) + } +} + +/** + * Read Remote markers attached to a live Service by decorator initializers. + * The returned snapshot cannot mutate the private marker table. + * @param service - live Service instance. + * @returns markers in class declaration order. + */ +export function remoteMethods(service: object): readonly RemoteMethodMarker[] { + const prototype = Object.getPrototypeOf(service) as object | null + if (prototype === null) return [] + return [...(markers.get(prototype) ?? [])].map(([method, marker]) => ({ method, ...marker })) +} + +function addMarkerInitializer( + context: RemoteInitializerContext, + invocation: RemoteInvocationMarker, + exportName?: string, +): void { + if (context.private || context.static || typeof context.name !== 'string') { + throw new TypeError('type-meta: Remote decorators require a public instance method with a string name') + } + const method = context.name + context.addInitializer(function (this: This) { + const prototype = Object.getPrototypeOf(this) as object | null + if (prototype === null) { + throw new TypeError(`type-meta: cannot mark Remote method "${method}" on an object without a prototype`) + } + mark(prototype, method, invocation, exportName) + }) +} + +function mark( + prototype: object, + method: string, + invocation: RemoteInvocationMarker, + exportName?: string, +): void { + let table = markers.get(prototype) + if (table === undefined) { + table = new Map() + markers.set(prototype, table) + } + const marker: StoredRemoteMethodMarker = { + ...(exportName === undefined || exportName === method ? {} : { exportName }), + invocation: Object.freeze(invocation), + } + const current = table.get(method) + if (current !== undefined) { + if (current.exportName === marker.exportName && sameInvocation(current.invocation, invocation)) return + throw new Error(`type-meta: Remote method "${method}" has conflicting invocation markers`) + } + table.set(method, Object.freeze(marker)) +} + +function sameInvocation(left: RemoteInvocationMarker, right: RemoteInvocationMarker): boolean { + return left.kind === right.kind + && (left.kind === 'direct' || (right.kind === 'context' && left.context === right.context)) +} + +function validateName(subject: string, value: string): void { + if (value.length === 0 || value.includes('/')) { + throw new TypeError(`type-meta: ${subject} must be nonempty and must not contain "/"`) + } +} diff --git a/packages/typert/type-meta/src/invariant.ts b/packages/typert/type-meta/src/invariant.ts new file mode 100644 index 0000000000..22dc290a1e --- /dev/null +++ b/packages/typert/type-meta/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-type-meta`. + * @module @deepseek-ai/dsh-type-meta/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-type-meta' + +/** Cordis companion plugin name. */ +export const name = 'type-meta-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: decorators retain private immutable declarations and + * bindings are frozen values with no independent event stream to cross-check. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/typert/type-meta/src/types.ts b/packages/typert/type-meta/src/types.ts new file mode 100644 index 0000000000..87ab091075 --- /dev/null +++ b/packages/typert/type-meta/src/types.ts @@ -0,0 +1,358 @@ +/** + * Compiler-independent TypeRT protocol shared by business packages, generated + * Remote artifacts, the Host Gateway, and Client API implementations. + * @module @deepseek-ai/dsh-type-meta/types + */ + +import type { Context } from 'cordis' + +declare const LOOKUP_HOST: unique symbol +declare const LOOKUP_WIRE: unique symbol +declare const CONTEXT_WIRE: unique symbol + +/** Type-level association between a Host object and its wire identity. */ +export interface TypeRTLookup { + readonly [LOOKUP_HOST]: Host + readonly [LOOKUP_WIRE]: Wire +} + +/** Extract the Host object associated with one lookup declaration. */ +export type TypeRTLookupHost = Lookup extends TypeRTLookup ? Host : never + +/** Extract the wire identity associated with one lookup declaration. */ +export type TypeRTLookupWire = Lookup extends TypeRTLookup ? Wire : never + +/** Type-level association between a scoped Context kind and its wire identity. */ +export interface TypeRTContext { + readonly [CONTEXT_WIRE]: Wire +} + +/** Extract the wire identity associated with one scoped Context declaration. */ +export type TypeRTContextWire = ContextType extends TypeRTContext ? Wire : never + +/** Merge-extensible Host object lookup declarations. */ +export interface TypeRTLookupMap {} + +/** Merge-extensible scoped Context declarations. */ +export interface TypeRTContextMap {} + +/** Merge-extensible direct Remote method signatures generated for consumers. */ +export interface TypeRTRemoteMap {} + +/** Merge-extensible scoped Remote method signatures generated for consumers. */ +export interface TypeRTRemoteContextMap {} + +/** + * Resolve one direct Remote namespace from the generated flat endpoint map. + * @template Namespace - wire namespace before the endpoint slash. + */ +export type TypeRTRemoteNamespace = { + [Endpoint in keyof TypeRTRemoteMap as Endpoint extends `${Namespace}/${infer Method}` + ? Method + : never]: TypeRTRemoteMap[Endpoint] +} + +/** + * Resolve one scoped Remote namespace across every generated Context kind. + * The calling Cordis Context supplies the concrete identity at runtime. + * @template Namespace - wire namespace between the Context prefix and method. + */ +export type TypeRTRemoteContextNamespace< + Namespace extends string, + ContextKey extends string = string, +> = { + [Endpoint in keyof TypeRTRemoteContextMap as Endpoint extends `${ContextKey}:${Namespace}/${infer Method}` + ? Method + : never]: TypeRTRemoteContextMap[Endpoint] +} + +type TypeRTRemoteContextNamespaceKey< + ContextKey extends string, + Endpoint = keyof TypeRTRemoteContextMap, +> = Endpoint extends `${ContextKey}:${infer Namespace}/${string}` ? Namespace : never + +/** Generated scoped Remote namespaces available to one Context kind. */ +export type TypeRTRemoteContextApi = { + [Namespace in TypeRTRemoteContextNamespaceKey]: + TypeRTRemoteContextNamespace +} + +/** Merge-extensible direct namespace surface generated for Client API services. */ +export interface TypeRTRemoteNamespaceMap {} + +/** Awaitable disposer returned by Cordis-owned TypeRT registrations. */ +export type TypeRTDisposer = () => Promise + +type StringKeyOf = Extract + +/** Minimal runtime-schema capability carried by strict generated codecs. */ +export interface TypeRTSchema { + /** + * Parse and validate one boundary value. + * @param value - untrusted boundary value. + * @returns the validated value. + */ + parse(value: unknown): Output +} + +/** Codec attached to one invocation parameter or result. */ +export type TypeRTCodec = + | { + readonly mode: 'strict' + readonly typeSymbol: string + readonly schema: TypeRTSchema + } + | { + readonly mode: 'src-json' + } + +/** One ordered business parameter in a Remote invocation. */ +export interface InvocationParameterDescriptor { + /** Source-level parameter name. */ + readonly name: string + /** Required key in the wire `args` object. */ + readonly wire: string + /** Whether the value is JSON or requires a registered Host lookup. */ + readonly source: 'json' | 'lookup' + /** Lookup key when `source` is `lookup`. */ + readonly lookup?: string + /** Boundary codec for the wire representation. */ + readonly codec: TypeRTCodec +} + +/** Source position retained for diagnostics from generated definitions. */ +export interface InvocationSourceLocation { + readonly file: string + readonly line: number + readonly column: number +} + +/** Carrier-independent description of one exported method invocation. */ +export interface InvocationDescriptor { + /** Globally stable generated identity. */ + readonly id: string + /** Cordis service key owning the method. */ + readonly service: string + /** Wire namespace, defaulting to the service key. */ + readonly namespace: string + /** Public instance method name. */ + readonly method: string + /** Service member invoked when the exported method name is an alias. */ + readonly implementation?: string + /** Receiver selection mode. */ + readonly invocation: + | { readonly kind: 'direct' } + | { + readonly kind: 'context' + readonly context: string + readonly wire: string + readonly codec: TypeRTCodec + } + /** Optional consuming-Context projection for one direct lookup parameter. */ + readonly scope?: { + /** Context kind whose Client binder supplies the identity. */ + readonly context: string + /** Lookup parameter wire field replaced by the Context identity. */ + readonly wire: string + } + /** Ordered business parameters. */ + readonly parameters: readonly InvocationParameterDescriptor[] + /** Codec for the resolved method result. */ + readonly result: TypeRTCodec + /** Source declaration used only for diagnostics. */ + readonly sourceLocation?: InvocationSourceLocation +} + +/** Generated Host contract selected explicitly by a Client assembly. */ +export interface TypeRTRemoteContribution { + /** npm package that owns the Remote methods. */ + readonly package: string + /** Consumer-side invocation descriptors generated from that package. */ + readonly descriptors: readonly InvocationDescriptor[] +} + +/** Runtime resolver 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 + /** Canonical Host type symbol used by strict generation. */ + readonly hostTypeSymbol: string + /** Canonical wire type symbol used by strict generation. */ + readonly wireTypeSymbol: string + /** + * Resolve a wire identity to the current live Host object. + * @param id - validated wire identity. + * @returns the live object, or `undefined` when it is unavailable. + */ + resolve(id: Wire): Host | undefined +} + +/** Host resolver for one scoped Remote Context kind. */ +export interface TypeRTHostContextProvider { + /** Wire field carrying the Context identity. */ + readonly wire: string + /** Canonical wire type symbol used by strict generation. */ + readonly wireTypeSymbol: string + /** + * Resolve a wire identity to its live scoped Context. + * @param id - validated wire identity. + * @returns the scoped Context, or `undefined` when unavailable. + */ + resolve(id: Wire): Context | undefined +} + +/** Client resolver for the identity carried by the calling scoped Context. */ +export interface TypeRTClientContextBinder { + /** + * Read the Remote identity represented by a calling Context. + * @param ctx - Context rebound by the Cordis service tracker. + * @returns the wire identity, or `undefined` when the Context has the wrong scope. + */ + identity(ctx: Context): Wire | undefined +} + +/** Notification emitted after a TypeRT runtime registry changes. */ +export interface TypeRTRegistryChange { + readonly kind: 'local' | 'remote' | 'lookup' | 'host-context' | 'client-context' + readonly key: string +} + +/** Listener for one TypeRT runtime registry. */ +export type TypeRTRegistryListener = (change: TypeRTRegistryChange) => void + +/** Current-environment invocation definitions. */ +export interface TypeRTLocalRegistry { + /** + * Look up one invocation by `/`. + * @param endpoint - canonical endpoint. + * @returns the live descriptor, or `undefined` when absent. + */ + get(endpoint: string): InvocationDescriptor | undefined + /** + * Report whether a strict definition has existed during this TypeRT Service lifetime. + * @param endpoint - canonical endpoint. + * @returns `true` after the endpoint has been registered at least once, even if withdrawn. + */ + hasSeen(endpoint: string): boolean + /** @returns a registration-order snapshot of local descriptors. */ + list(): readonly InvocationDescriptor[] + /** + * Observe later local-definition changes. + * @param listener - synchronous contained observer. + * @returns disposer for this subscription. + */ + subscribe(listener: TypeRTRegistryListener): TypeRTDisposer +} + +/** Consumer-selected Remote contribution registry. */ +export interface TypeRTRemoteRegistry { + /** + * Register one generated contribution for the calling Cordis fiber. + * @param contribution - generated Remote descriptors. + * @returns disposer withdrawing the exact contribution. + */ + register(contribution: TypeRTRemoteContribution): TypeRTDisposer + /** + * Look up one Remote descriptor by endpoint. + * @param endpoint - canonical endpoint. + * @returns the descriptor, or `undefined` when unmounted. + */ + get(endpoint: string): InvocationDescriptor | undefined + /** @returns a registration-order snapshot of Remote descriptors. */ + list(): readonly InvocationDescriptor[] + /** + * Observe later Remote contribution changes. + * @param listener - synchronous contained observer. + * @returns disposer for this subscription. + */ + subscribe(listener: TypeRTRegistryListener): TypeRTDisposer +} + +/** Runtime registry for Host object lookup providers. */ +export interface TypeRTLookupRegistry { + /** + * Register one provider under its merge-declared key. + * @param key - lookup key. + * @param provider - owning package's live resolver. + * @returns disposer withdrawing the exact provider. + */ + register>( + key: K, + provider: TypeRTLookupProvider< + TypeRTLookupHost, + TypeRTLookupWire + >, + ): TypeRTDisposer + /** + * Look up one provider by runtime key. + * @param key - descriptor lookup key. + * @returns the live provider, or `undefined` when absent. + */ + get(key: string): TypeRTLookupProvider | undefined + /** @returns a snapshot of registered provider keys. */ + keys(): readonly string[] + /** + * Observe later lookup changes. + * @param listener - synchronous contained observer. + * @returns disposer for this subscription. + */ + subscribe(listener: TypeRTRegistryListener): TypeRTDisposer +} + +/** Runtime registry for Host Context resolvers and Client Context binders. */ +export interface TypeRTContextRegistry { + /** + * Register a Host Context resolver. + * @param key - merge-declared Context key. + * @param provider - owning package's Host resolver. + * @returns disposer withdrawing the exact provider. + */ + registerHost>( + key: K, + provider: TypeRTHostContextProvider>, + ): TypeRTDisposer + /** + * Register a Client Context identity binder. + * @param key - merge-declared Context key. + * @param binder - Client scope identity resolver. + * @returns disposer withdrawing the exact binder. + */ + registerClient>( + key: K, + binder: TypeRTClientContextBinder>, + ): TypeRTDisposer + /** + * Look up a Host Context resolver. + * @param key - descriptor Context key. + * @returns the provider, or `undefined` when absent. + */ + getHost(key: string): TypeRTHostContextProvider | undefined + /** + * Look up a Client Context binder. + * @param key - descriptor Context key. + * @returns the binder, or `undefined` when absent. + */ + getClient(key: string): TypeRTClientContextBinder | undefined + /** + * Observe later Context provider changes. + * @param listener - synchronous contained observer. + * @returns disposer for this subscription. + */ + subscribe(listener: TypeRTRegistryListener): TypeRTDisposer +} + +/** Minimal TypeRT runtime consumed through dependency inversion. */ +export interface TypeRTService { + readonly local: TypeRTLocalRegistry + readonly remotes: TypeRTRemoteRegistry + readonly lookups: TypeRTLookupRegistry + readonly contexts: TypeRTContextRegistry +} + +declare module 'cordis' { + interface Context { + typert: TypeRTService + } +} diff --git a/packages/typert/type-meta/tests/fixtures/source-launch.ts b/packages/typert/type-meta/tests/fixtures/source-launch.ts new file mode 100644 index 0000000000..68f886dff1 --- /dev/null +++ b/packages/typert/type-meta/tests/fixtures/source-launch.ts @@ -0,0 +1,29 @@ +import { + bindTypeRTGateway, + Remote, + RemoteContext, + remoteMethods, +} from '@deepseek-ai/dsh-type-meta' + +class Goals { + readonly typertGateway = bindTypeRTGateway(this, 'goals') + + @Remote + create(value: string): string { + return value + } + + @RemoteContext('agent') + scoped(value: string): string { + return value + } +} + +const methods = remoteMethods(new Goals()) +const actual = JSON.stringify(methods) +const expected = JSON.stringify([ + { method: 'create', invocation: { kind: 'direct' } }, + { method: 'scoped', invocation: { kind: 'context', context: 'agent' } }, +]) +if (actual !== expected) throw new Error(`unexpected Remote declarations: ${actual}`) +process.stdout.write(actual) diff --git a/packages/typert/type-meta/tests/type-meta.spec.ts b/packages/typert/type-meta/tests/type-meta.spec.ts new file mode 100644 index 0000000000..1eab5a6ca3 --- /dev/null +++ b/packages/typert/type-meta/tests/type-meta.spec.ts @@ -0,0 +1,132 @@ +import { execFileSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { + bindTypeRTGateway, + Remote, + RemoteContext, + remoteMethods, + type TypeRTContext, +} from '@deepseek-ai/dsh-type-meta' + +declare module '@deepseek-ai/dsh-type-meta' { + interface TypeRTContextMap { + metaFixture: TypeRTContext + } +} + +describe('type-meta Remote declarations', () => { + it('executes standard decorator syntax through the Vitest source transform', () => { + class Goals { + readonly typertGateway = bindTypeRTGateway(this, 'goals') + + @Remote + create(value: string): string { + return value + } + + @RemoteContext('metaFixture') + scoped(value: string): string { + return value + } + } + + const goals = new Goals() + expect(remoteMethods(goals)).toEqual([ + { method: 'create', invocation: { kind: 'direct' } }, + { method: 'scoped', invocation: { kind: 'context', context: 'metaFixture' } }, + ]) + }) + + it('executes standard decorator syntax through the TSX source launcher', () => { + const fixture = fileURLToPath(new URL('./fixtures/source-launch.ts', import.meta.url)) + const output = execFileSync(process.execPath, ['--import', 'tsx/esm', fixture], { encoding: 'utf8' }) + expect(JSON.parse(output)).toEqual([ + { method: 'create', invocation: { kind: 'direct' } }, + { method: 'scoped', invocation: { kind: 'context', context: 'agent' } }, + ]) + }) + + it('keeps decorator markers in private module state', () => { + class Goals { + readonly typertGateway = bindTypeRTGateway(this, 'goals') + + create(agent: object, request: object): object { + return { agent, request } + } + + scoped(request: object): object { + return request + } + } + + const initializers: Array<(this: Goals) => void> = [] + Remote( + Reflect.get(Goals.prototype, 'create') as (this: Goals, ...args: unknown[]) => unknown, + methodContext('create', initializers), + ) + RemoteContext('metaFixture')( + Reflect.get(Goals.prototype, 'scoped') as (this: Goals, ...args: unknown[]) => unknown, + methodContext('scoped', initializers), + ) + + const goals = new Goals() + for (const initialize of initializers) initialize.call(goals) + expect(goals.typertGateway).toEqual({ service: goals, serviceKey: 'goals', namespace: 'goals' }) + expect(Object.isFrozen(goals.typertGateway)).toBe(true) + expect(remoteMethods(goals)).toEqual([ + { method: 'create', invocation: { kind: 'direct' } }, + { method: 'scoped', invocation: { kind: 'context', context: 'metaFixture' } }, + ]) + expect(Reflect.ownKeys(Goals)).toEqual(['length', 'name', 'prototype']) + expect(Reflect.ownKeys(Goals.prototype)).toEqual(['constructor', 'create', 'scoped']) + }) + + it('keeps markers idempotent across instances and returns detached snapshots', () => { + class Service { + run(value: string): string { + return value + } + } + + const initializers: Array<(this: Service) => void> = [] + Remote( + Reflect.get(Service.prototype, 'run') as (this: Service, ...args: unknown[]) => unknown, + methodContext('run', initializers), + ) + + const first = new Service() + const second = new Service() + for (const initialize of initializers) { + initialize.call(first) + initialize.call(second) + } + const snapshot = remoteMethods(first) + expect(remoteMethods(second)).toEqual(snapshot) + ;(snapshot as unknown as { method: string }[])[0]!.method = 'changed' + expect(remoteMethods(first)).toEqual([{ method: 'run', invocation: { kind: 'direct' } }]) + }) + + it('rejects ambiguous binding names', () => { + expect(() => bindTypeRTGateway({}, '')).toThrow('service key') + expect(() => bindTypeRTGateway({}, 'goals', { namespace: 'api/goals' })).toThrow('namespace') + }) +}) + +function methodContext( + name: string, + initializers: Array<(this: This) => void>, +): ClassMethodDecoratorContext unknown> { + return { + kind: 'method', + name, + static: false, + private: false, + metadata: {}, + access: { + has: object => name in object, + get: object => (object as Record)[name] as (this: This, ...args: unknown[]) => unknown, + }, + addInitializer: (initializer) => { initializers.push(initializer) }, + } +} diff --git a/packages/typert/type-meta/tsconfig.json b/packages/typert/type-meta/tsconfig.json new file mode 100644 index 0000000000..9966c8ca8a --- /dev/null +++ b/packages/typert/type-meta/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1ea3070dfa..45e8ad1803 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -916,6 +916,9 @@ importers: '@deepseek-ai/dsh-goal-session': specifier: workspace:^ version: link:../../goal/goal-session + '@deepseek-ai/dsh-host-api-gateway': + specifier: workspace:^ + version: link:../../host/api-gateway '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -1054,6 +1057,12 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools + '@deepseek-ai/dsh-typert-loader': + specifier: workspace:^ + version: link:../../typert/loader + '@deepseek-ai/dsh-typert-registry': + specifier: workspace:^ + version: link:../../typert/registry '@deepseek-ai/dsh-user-approval': specifier: workspace:^ version: link:../../ui/user-approval @@ -2790,6 +2799,12 @@ importers: '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../system-prompt + '@deepseek-ai/dsh-type-meta': + specifier: workspace:^ + version: link:../../typert/type-meta + '@deepseek-ai/dsh-typert-registry': + specifier: workspace:^ + version: link:../../typert/registry cordis: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis @@ -2854,6 +2869,12 @@ importers: '@deepseek-ai/dsh-scope': specifier: workspace:^ version: link:../scope + '@deepseek-ai/dsh-type-meta': + specifier: workspace:^ + version: link:../../typert/type-meta + '@deepseek-ai/dsh-typert-registry': + specifier: workspace:^ + version: link:../../typert/registry cordis: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis @@ -3706,6 +3727,31 @@ importers: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis + packages/host/api-gateway: + dependencies: + '@deepseek-ai/dsh-type-meta': + specifier: workspace:^ + version: link:../../typert/type-meta + devDependencies: + '@deepseek-ai/dsh-client-connection': + specifier: workspace:^ + version: link:../../client/connection + '@deepseek-ai/dsh-host-webserver': + specifier: workspace:^ + version: link:../webserver + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-typert-registry': + specifier: workspace:^ + version: link:../../typert/registry + cordis: + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + zod: + specifier: ^4.4.3 + version: 4.4.3 + packages/host/apiproxy: dependencies: '@deepseek-ai/dsh-agent': @@ -6096,6 +6142,9 @@ importers: packages/typert/generator: dependencies: + '@jridgewell/gen-mapping': + specifier: ^0.3.13 + version: 0.3.13 typescript: specifier: ^6.0.3 version: 6.0.3 @@ -6140,6 +6189,9 @@ importers: packages/typert/registry: dependencies: + '@deepseek-ai/dsh-type-meta': + specifier: workspace:^ + version: link:../type-meta zod: specifier: ^4.4.3 version: 4.4.3 @@ -6151,6 +6203,15 @@ importers: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis + packages/typert/type-meta: + devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + cordis: + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + packages/ui/app-boot: dependencies: js-yaml: diff --git a/scripts/client-bundle-purity.spec.ts b/scripts/client-bundle-purity.spec.ts index 8b10822bca..fb47f8a9c8 100644 --- a/scripts/client-bundle-purity.spec.ts +++ b/scripts/client-bundle-purity.spec.ts @@ -59,6 +59,13 @@ describe('client bundle purity gate', () => { expect(resolveId('@deepseek-ai/dsh-brand')).toBeNull() }) + it('lets exact generated Remote contributions inline without admitting their package implementation', () => { + expect(resolveId('@deepseek-ai/dsh-goal/remote')).toBeNull() + expect(() => resolveId('@deepseek-ai/dsh-goal')).toThrow(/purity/) + expect(() => resolveId('@deepseek-ai/dsh-goal/client')).toThrow(/purity/) + expect(() => resolveId('@deepseek-ai/dsh-goal/remote/nested')).toThrow(/purity/) + }) + it('throws on any other @deepseek-ai leak', () => { expect(() => resolveId('@deepseek-ai/dsh-agent')).toThrow(/purity/) expect(() => resolveId('@deepseek-ai/dsh-client-web')).toThrow(/purity/) diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index bcf90d1e82..84013225f7 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -276,6 +276,7 @@ export const TYPE_LINK_EXEMPTIONS: Readonly> = { TypertPackageRecord: 'registry package record is owned by packages/typert/registry/README.md', TypertSchemaFilter: 'registry schema query filter is owned by packages/typert/registry/README.md', TypertSchemaRecord: 'registry schema record is owned by packages/typert/registry/README.md', + TypeRTDisposer: 'TypeRT lifecycle contract is owned by packages/typert/type-meta/README.md', 'z.core.JSONSchema.BaseSchema': 'zod projection output is owned by the zod v4 API', 'z.core.ToJSONSchemaParams': 'zod projection parameters are owned by the zod v4 API', InvariantInstaller: 'service-local contribution contract is owned by packages/support/invariants/README.md', @@ -287,6 +288,7 @@ export const TYPE_LINK_EXEMPTIONS: Readonly> = { ThemeTokens: 'service-local token dictionary is owned by packages/client/ui-theme/src/index.ts', Translate: 'service-local bound translator is owned by packages/client/i18n/src/index.ts', InvariantRegistration: 'service-local lifecycle handle is owned by packages/support/invariants/README.md', + InvokeRemoteRequest: 'gateway invocation contract is owned by packages/host/api-gateway/README.md', PresetOption: 'deployment menu metadata is owned by packages/ui/permission/README.md', PresetSpec: 'deployment preset composition is owned by packages/ui/permission/README.md', KnobState: 'projection unit state shape is owned by packages/ui/permission/README.md', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 1c7e2c3a0c..151aadb278 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -140,8 +140,15 @@ const SERVICE_ROLES: ServiceRole[] = [ pkg: 'typert-registry', title: 'Runtime type registry', mode: 'core', - consumers: ['typert-loader'], - note: 'Plugins register live zod contributions directly or through dsh-typert-loader; runtime consumers query schemas and reflection metadata at their own edges.', + consumers: ['typert-loader', 'api-gateway'], + note: '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.', + }, + { + key: 'typertGateway', + pkg: 'api-gateway', + title: 'TypeRT Host invocation gateway', + 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: 'sessionPersistence', diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 53491b89fb..7e81b30e07 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -125,6 +125,8 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/support/loader-smoke': { kind: 'none', reason: 'The test harness observes child-process streams without changing live requests.' }, 'packages/support/llm-mock-server': { kind: 'none', reason: 'The test server substitutes provider wire behavior without invoking a real model.' }, 'packages/support/llm-replay': { kind: 'none', reason: 'The keyless adapter invokes no provider model.' }, + 'packages/host/api-gateway': { kind: 'none', reason: 'Remote dispatch infrastructure; invoked business methods own any model-visible effect.' }, + 'packages/typert/type-meta': { kind: 'none', reason: 'Compiler-independent Remote protocol declarations; registers no model surface.' }, 'packages/typert/generator': { kind: 'none', reason: 'The build-time generator runs outside any agent runtime and touches no model request.' }, 'packages/tasks/tasks': { kind: 'indirect', reason: 'Producer and control-surface plugins own all model rendering over the task registry.' }, 'packages/tasks/tasks-local': { kind: 'indirect', reason: 'The registry backend delegates model rendering to producer plugins and dsh-tool-tasks.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index 001158afe0..ce4fca35f9 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -40,6 +40,13 @@ "@cordisjs/plugin-logger-console": ["./vendor/logger-console/src"], "@deepseek-ai/dsh-invariants": ["./packages/support/invariants/src/index.ts"], "@deepseek-ai/dsh-typert-registry": ["./packages/typert/registry/src/index.ts"], + "@deepseek-ai/dsh-typert-registry/client": ["./packages/typert/registry/src/client/index.ts"], + "@deepseek-ai/dsh-host-api-gateway": ["./packages/host/api-gateway/src/index.ts"], + "@deepseek-ai/dsh-host-api-gateway/client": ["./packages/host/api-gateway/src/client/index.ts"], + "@deepseek-ai/dsh-host-api-gateway/invariant": ["./packages/host/api-gateway/src/invariant.ts"], + "@deepseek-ai/dsh-host-api-gateway/types": ["./packages/host/api-gateway/src/types.ts"], + "@deepseek-ai/dsh-type-meta": ["./packages/typert/type-meta/src/index.ts"], + "@deepseek-ai/dsh-type-meta/types": ["./packages/typert/type-meta/src/types.ts"], "@deepseek-ai/dsh-typert-loader": ["./packages/typert/loader/src/index.ts"], "@deepseek-ai/dsh-session/invariant": ["./packages/core/session/src/invariant.ts"], "@deepseek-ai/dsh-typert-registry/types": ["./packages/typert/registry/src/types.ts"], @@ -68,7 +75,6 @@ "@deepseek-ai/dsh-tool-subagent-control/list-agents": ["./packages/subagent/tool-subagent-control/src/list-agents.ts"], "@deepseek-ai/dsh-user-approval/types": ["./packages/ui/user-approval/src/types.ts"], "@deepseek-ai/dsh-user-interaction/types": ["./packages/ui/user-interaction/src/types.ts"], - "@deepseek-ai/dsh-agent/brand": ["./packages/core/agent/src/brand.ts"], "@deepseek-ai/dsh-agent/invariant": ["./packages/core/agent/src/invariant.ts"], "@deepseek-ai/dsh-scope/invariant": ["./packages/core/scope/src/invariant.ts"], "@deepseek-ai/dsh-agent-loop/invariant": ["./packages/core/agent-loop/src/invariant.ts"], @@ -145,6 +151,8 @@ "@deepseek-ai/dsh-client-schema-form/invariant": ["./packages/client/schema-form/src/invariant.ts"], "@deepseek-ai/dsh-client-web-react": ["./packages/client/web-react/src"], "@deepseek-ai/dsh-client-connection": ["./packages/client/connection/src"], + "@deepseek-ai/dsh-client-remotes": ["./packages/client/remotes/src"], + "@deepseek-ai/dsh-client-remotes/client": ["./packages/client/remotes/src/client/index.ts"], "@deepseek-ai/dsh-client-hmr": ["./packages/client/hmr/src"], "@deepseek-ai/dsh-client-modules": ["./packages/client/modules/src"], "@deepseek-ai/dsh-client-runtime": ["./packages/client/runtime/src"], diff --git a/tsconfig.client.json b/tsconfig.client.json index 03a2b8bb59..b0567f762e 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -51,6 +51,8 @@ { "path": "./packages/client/modules" }, { "path": "./packages/client/hmr" }, { "path": "./packages/client/connection" }, + { "path": "./packages/typert/registry" }, + { "path": "./packages/host/api-gateway" }, { "path": "./packages/client/runtime" }, { "path": "./packages/client/test-runtime" }, { "path": "./packages/client/ui-layout" }, diff --git a/tsconfig.host.json b/tsconfig.host.json index 79276dfe7e..37c20c0d5c 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -100,7 +100,9 @@ { "path": "./packages/llm/token-meter" }, { "path": "./packages/core/session" }, { "path": "./packages/core/scope" }, + { "path": "./packages/typert/type-meta" }, { "path": "./packages/typert/registry" }, + { "path": "./packages/host/api-gateway" }, { "path": "./packages/typert/loader" }, { "path": "./packages/session-persistence/session-persistence" }, { "path": "./packages/session-persistence/session-checkpoint-policy" }, diff --git a/tsdown.config.ts b/tsdown.config.ts index 41a490436f..0d503c62d3 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -1,4 +1,5 @@ import { defineConfig } from 'tsdown' +import { typertPlugin } from './packages/typert/generator/lib/types/tsdown-plugin.js' /** * JS bundling for vendored Cordis and Harness TypeScript packages. @@ -27,4 +28,7 @@ export default defineConfig({ fixedExtension: false, dts: false, clean: false, + // The final pass sees both independent TypeScript faces. Workspace mode + // writes only packages that explicitly publish a Typert/Remote subpath. + plugins: [typertPlugin({ mode: 'workspace' })], }) diff --git a/tsdown.typert-host.config.ts b/tsdown.typert-host.config.ts new file mode 100644 index 0000000000..8c8ae11dd1 --- /dev/null +++ b/tsdown.typert-host.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from 'tsdown' +import { typertPlugin } from './packages/typert/generator/lib/types/tsdown-plugin.js' + +/** + * Host-only TypeRT contract prepass. The generator and its project references + * are compiled first; the plugin then analyzes Host source and emits local and + * Host-for-Client artifacts before either aggregate consumes Remote subpaths. + */ +export default defineConfig({ + workspace: ['packages/typert/generator'], + entry: ['lib/types/{index,invariant}.js'], + outDir: 'lib', + format: ['esm'], + platform: 'node', + target: 'es2024', + fixedExtension: false, + dts: false, + clean: false, + plugins: [typertPlugin({ mode: 'workspace', faces: ['host'] })], +}) diff --git a/vitest.config.ts b/vitest.config.ts index cd37feb300..4c4c668b94 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -3,6 +3,7 @@ import { fileURLToPath } from 'node:url' import tsconfigPaths from 'vite-tsconfig-paths' import { resolvePwshPath } from './packages/bash/pwsh-local/src/resolve.ts' import { defineConfig } from 'vitest/config' +import ts from 'typescript' import { vitestExecArgv } from './vitest.shared.ts' import { COVERAGE_EXEMPT_ENV, coverageExemptHeavySuites } from './scripts/coverage-exempt.ts' @@ -17,6 +18,29 @@ const uncoveredLocationsReporter = fileURLToPath(new URL('./scripts/coverage-unc // map applies to every test file. paths must win over package exports so built // lib/ never loads a second module-singleton copy. const pathsPlugin = (): ReturnType => tsconfigPaths({ projects: ['./tsconfig.base.json'] }) +const decoratorSyntax = /^\s*@[A-Za-z_$][\w$]*/m + +const standardDecoratorPlugin = () => ({ + name: 'dsh-standard-decorators', + enforce: 'pre' as const, + transform(code: string, id: string) { + const file = id.split('?', 1)[0]! + if (!/\.[cm]?tsx?$/.test(file) || !decoratorSyntax.test(code)) return + const result = ts.transpileModule(code, { + fileName: file, + compilerOptions: { + target: ts.ScriptTarget.ES2024, + module: ts.ModuleKind.ESNext, + jsx: file.endsWith('x') ? ts.JsxEmit.ReactJSX : undefined, + sourceMap: true, + }, + }) + return { + code: result.outputText.replace(/\n?\/\/# sourceMappingURL=.*$/u, '\n'), + map: result.sourceMapText, + } + }, +}) const windowsUnsupportedPackages = process.platform === 'win32' ? [ @@ -88,7 +112,7 @@ const processBoundTests = [ ] export default defineConfig({ - plugins: [pathsPlugin()], + plugins: [pathsPlugin(), standardDecoratorPlugin()], test: { setupFiles: ['./scripts/test-invariants.ts'], // .tsx: client component specs (jsdom via per-file @vitest-environment pragma). @@ -99,7 +123,7 @@ export default defineConfig({ // always fork. projects: [ { - plugins: [pathsPlugin()], + plugins: [pathsPlugin(), standardDecoratorPlugin()], test: { name: 'thread-safe', execArgv: vitestExecArgv, @@ -119,7 +143,7 @@ export default defineConfig({ }, }, { - plugins: [pathsPlugin()], + plugins: [pathsPlugin(), standardDecoratorPlugin()], test: { name: 'process-bound', execArgv: vitestExecArgv, From 9a0a9350c44bf20e57c37daace7fb6746e5d9d00 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 5 Aug 2026 23:18:35 +0800 Subject: [PATCH 16/63] fix(typert): satisfy workspace static gates --- THIRD_PARTY_NOTICES.md | 1 + docs/cordis-catalog/services.md | 2 +- knip.json | 3 +- packages/client/connection/src/client/rpc.ts | 1 - packages/client/connection/src/http-bridge.ts | 6 +- packages/client/connection/src/rpc-host.ts | 26 +- .../connection/tests/client-apply.spec.ts | 43 +++ .../connection/tests/http-bridge.spec.ts | 2 +- .../client/connection/tests/node-half.spec.ts | 68 +++- packages/host/api-gateway/package.json | 4 +- packages/host/api-gateway/src/client/index.ts | 9 +- packages/host/api-gateway/src/index.ts | 10 +- .../host/api-gateway/tests/client.spec.ts | 138 ++++++++ .../host/api-gateway/tests/gateway.spec.ts | 294 ++++++++++++++++++ packages/typert/generator/src/emitter.ts | 27 +- .../typert/generator/src/tsdown-plugin.ts | 35 ++- .../generator/tests/tsdown-plugin.spec.ts | 7 + packages/typert/registry/src/service.ts | 7 + packages/typert/registry/tests/typert.spec.ts | 149 +++++++++ packages/typert/type-meta/package.json | 4 +- .../typert/type-meta/tests/type-meta.spec.ts | 74 +++++ pnpm-lock.yaml | 3 + python/sdk-runtime/package.json | 1 + scripts/check-workspace-constraints.ts | 34 +- scripts/dev-web.spec.ts | 8 +- scripts/dev-web.ts | 23 +- scripts/publication-payload.spec.ts | 31 +- scripts/publication-payload.ts | 36 ++- scripts/publish-npm-baseline.ts | 14 +- 29 files changed, 986 insertions(+), 74 deletions(-) diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index f70245d49d..e53dd292e4 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -39,6 +39,7 @@ External packages that a workspace package resolves at runtime. `scripts/install | [`@clack/prompts`](https://github.com/bombshell-dev/clack) | MIT | | [`@earendil-works/pi-ai`](https://github.com/earendil-works/pi) | MIT | | [`@joplin/turndown-plugin-gfm`](https://github.com/laurent22/joplin-turndown-plugin-gfm) | MIT | +| [`@jridgewell/gen-mapping`](https://github.com/jridgewell/sourcemaps) | MIT | | [`@modelcontextprotocol/sdk`](https://github.com/modelcontextprotocol/typescript-sdk) | MIT | | [`@opentelemetry/api`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 | | [`@opentelemetry/api-logs`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 | diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 0a9af0bae5..41059aebf4 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -2585,7 +2585,7 @@ listPackages(filter: TypertPackageFilter = {}): TypertPackageRecord[] toJSONSchema(key: string, params?: z.core.ToJSONSchemaParams): z.core.JSONSchema.BaseSchema ``` -Source: [`packages/typert/registry/src/service.ts:319`](../../packages/typert/registry/src/service.ts) +Source: [`packages/typert/registry/src/service.ts:324`](../../packages/typert/registry/src/service.ts) ## `ctx.typertGateway` — `TypertGatewayService` diff --git a/knip.json b/knip.json index 7a3922c8fb..32c9e20dbf 100644 --- a/knip.json +++ b/knip.json @@ -200,7 +200,8 @@ "packages/typert/generator": { "entry": [ "tests/**/*.spec.ts", - "tests/fixtures/type-model/**/*.ts" + "tests/fixtures/type-model/**/*.ts", + "tests/fixtures/remote-model/**/*.ts" ], "project": [ "src/**/*.ts", diff --git a/packages/client/connection/src/client/rpc.ts b/packages/client/connection/src/client/rpc.ts index 36e16426b2..0c12149d7b 100644 --- a/packages/client/connection/src/client/rpc.ts +++ b/packages/client/connection/src/client/rpc.ts @@ -67,7 +67,6 @@ function resolveBase(): string { function assertTarget(channel: string, endpoint: string): void { const segments = endpoint.split('/') if (!CHANNEL_PATTERN.test(channel) - || segments.length === 0 || segments.some(segment => segment === '' || segment === '.' || segment === '..' || !ENDPOINT_SEGMENT_PATTERN.test(segment))) { throw new Error(`connection: invalid RPC target ${JSON.stringify(`${channel}/${endpoint}`)}`) diff --git a/packages/client/connection/src/http-bridge.ts b/packages/client/connection/src/http-bridge.ts index 319d3e0b0b..88d577bef8 100644 --- a/packages/client/connection/src/http-bridge.ts +++ b/packages/client/connection/src/http-bridge.ts @@ -5,6 +5,10 @@ import type { IncomingMessage, ServerResponse } from 'node:http' +interface FetchHandler { + fetch(request: Request): Promise +} + /** * Bridge one node:http request to the fetch-shaped handler (client close * aborts; SSE bodies stream out chunk by chunk). @@ -12,7 +16,7 @@ import type { IncomingMessage, ServerResponse } from 'node:http' * @param res - node:http response the bridge writes and owns to completion. * @param apiHandler - fetch-shaped API carrier the request is dispatched to. */ -export async function bridge(req: IncomingMessage, res: ServerResponse, apiHandler: { fetch: typeof fetch }): Promise { +export async function bridge(req: IncomingMessage, res: ServerResponse, apiHandler: FetchHandler): Promise { const abort = new AbortController() // Client-disconnect detection MUST hang off the response, not the request: // since Node 16, IncomingMessage 'close' fires as soon as the request body is diff --git a/packages/client/connection/src/rpc-host.ts b/packages/client/connection/src/rpc-host.ts index be9eedca8f..a6fbdb0264 100644 --- a/packages/client/connection/src/rpc-host.ts +++ b/packages/client/connection/src/rpc-host.ts @@ -7,6 +7,7 @@ import { RpcId, type ClientRequest, type RpcError, + type RpcErrorDetailsMap, type RpcId as RpcIdType, type ServerResponse as RpcServerResponse, } from '@deepseek-ai/dsh-host-apiproxy/api' @@ -73,10 +74,9 @@ export class HostConnectionService extends Service implements HostConnectionHand function rpcFetchHandler( channel: string, handler: ConnectionRpcHandler, -): { fetch: typeof fetch } { +): { fetch(request: Request): Promise } { return { - async fetch(input: RequestInfo | URL, init?: RequestInit): Promise { - const request = input instanceof Request ? input : new Request(input, init) + async fetch(request: Request): Promise { const endpoint = endpointFromPath(channel, new URL(request.url).pathname) if (request.method !== 'POST' || endpoint === undefined) { return new Response('not found', { status: 404 }) @@ -96,13 +96,7 @@ function rpcFetchHandler( const envelope = clientRequestSchema.safeParse(body) if (!envelope.success) { - const rawId = (body as { rpcId?: unknown } | null)?.rpcId - const rpcId = typeof rawId === 'string' ? RpcId(rawId) : INVALID_REQUEST_RPC_ID - return errorResponse(rpcId, { - code: 'bad-request', - message: 'invalid client-request message', - details: { issues: envelope.error.issues }, - }) + return invalidEnvelopeResponse(body, envelope.error.issues) } const message: ClientRequest = envelope.data if (message.method !== endpoint) { @@ -123,11 +117,21 @@ function rpcFetchHandler( } } +function invalidEnvelopeResponse(body: unknown, issues: RpcErrorDetailsMap['bad-request']['issues']): Response { + const rawId = (body as { rpcId?: unknown } | null)?.rpcId + const rpcId = typeof rawId === 'string' ? RpcId(rawId) : INVALID_REQUEST_RPC_ID + return errorResponse(rpcId, { + code: 'bad-request', + message: 'invalid client-request message', + details: { issues }, + }) +} + function endpointFromPath(channel: string, pathname: string): string | undefined { if (!pathname.startsWith(`${channel}/`)) return undefined const endpoint = pathname.slice(channel.length + 1) const segments = endpoint.split('/') - if (segments.length === 0 || segments.some(segment => + if (segments.some(segment => segment === '' || segment === '.' || segment === '..' || !ENDPOINT_SEGMENT_PATTERN.test(segment))) { return undefined } diff --git a/packages/client/connection/tests/client-apply.spec.ts b/packages/client/connection/tests/client-apply.spec.ts index d93844a2b8..3ce8b89ecb 100644 --- a/packages/client/connection/tests/client-apply.spec.ts +++ b/packages/client/connection/tests/client-apply.spec.ts @@ -235,6 +235,49 @@ describe('connection client apply', () => { }) }) + it('validates generic RPC transport failures, correlation, and targets', async () => { + ;(globalThis as Win).location = { + hostname: 'harness.example', search: '', origin: 'https://harness.example', + } + const handle = await mount() + const original = globalThis.fetch + const abort = new AbortController() + globalThis.fetch = vi.fn().mockResolvedValue(new Response('unavailable', { status: 503 })) + try { + await expect(handle.rpc.call('/api2', 'goals/create', {}, abort.signal)) + .rejects.toThrow('HTTP 503') + expect(globalThis.fetch).toHaveBeenCalledWith( + new URL('https://harness.example/api2/goals/create'), + expect.objectContaining({ signal: abort.signal }), + ) + + ;(globalThis as Win).location = { hostname: 'localhost', search: '', origin: 'null' } + globalThis.fetch = vi.fn().mockResolvedValue(Response.json({ + type: 'server-response', + rpcId: 'different-rpc', + result: { ok: true, value: null }, + })) + await expect(handle.rpc.call('/api2', 'goals/create', {})).rejects.toThrow('rpcId mismatch') + const fetch = vi.mocked(globalThis.fetch) + expect(fetch.mock.calls[0]?.[0]).toEqual(new URL('http://dsh.internal/api2/goals/create')) + expect(fetch.mock.calls[0]?.[1]).not.toHaveProperty('signal') + } finally { + globalThis.fetch = original + } + + for (const [channel, endpoint] of [ + ['api2', 'goals/create'], + ['/api2/path', 'goals/create'], + ['/api2', ''], + ['/api2', '.'], + ['/api2', '..'], + ['/api2', 'goals//create'], + ['/api2', 'goals/create?unsafe'], + ] as const) { + await expect(handle.rpc.call(channel, endpoint, {})).rejects.toThrow('invalid RPC target') + } + }) + it('keeps generic Remote calls unavailable in the client-only fixture', async () => { ;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' } const handle = await mount() diff --git a/packages/client/connection/tests/http-bridge.spec.ts b/packages/client/connection/tests/http-bridge.spec.ts index 4607f32bae..b06834e523 100644 --- a/packages/client/connection/tests/http-bridge.spec.ts +++ b/packages/client/connection/tests/http-bridge.spec.ts @@ -28,7 +28,7 @@ describe('HTTP bridge abort', () => { let carrierSignal: AbortSignal | undefined const pending = bridge(request, response, { fetch: async (input) => { - const fetchRequest = input as Request + const fetchRequest = input carrierSignal = fetchRequest.signal resolveStarted() if (!fetchRequest.signal.aborted) { diff --git a/packages/client/connection/tests/node-half.spec.ts b/packages/client/connection/tests/node-half.spec.ts index af85d4e510..1c42a9dc88 100644 --- a/packages/client/connection/tests/node-half.spec.ts +++ b/packages/client/connection/tests/node-half.spec.ts @@ -47,6 +47,13 @@ function fakePost(headers: Record, url: string, body: unknown): return request } +/** Raw POST for malformed-body and media-type boundary cases. */ +function fakeRawPost(headers: Record, url: string, body: string): IncomingMessage { + const request = Readable.from([Buffer.from(body)]) as unknown as IncomingMessage + Object.assign(request, { url, method: 'POST', headers }) + return request +} + /** Response recorder compatible with both the fence's short-circuit and the bridge. */ function fakeResponse(): { response: ServerResponse; state: { status?: number; body?: unknown } } { const state: { status?: number; body?: unknown } = {} @@ -239,7 +246,10 @@ describe('connection node half', () => { const fiber = ctx.plugin({ inject: [...inject], apply }, { trustedHosts: ['harness.example'] }) await fiber.await() const connection = ctx.get('connection') as HostConnectionHandle - const remove = connection.rpc.handle('/api2', async () => ({ ok: true, value: null }), { + const remove = connection.rpc.handle('/api2', async (endpoint) => { + if (endpoint === 'fail') throw new Error('handler broke') + return { ok: true, value: null } + }, { authority: 'trusted-host', }) const route = routes[0]! @@ -248,14 +258,64 @@ describe('connection node half', () => { await route.handler(fakePost({ host: 'other.example' }, '/api2/goals/create', {}), denied.response) expect(denied.state).toMatchObject({ status: 403, body: 'forbidden' }) - const badEnvelope = fakeResponse() + const methodMismatch = fakeResponse() await route.handler(fakePost({ host: 'harness.example' }, '/api2/goals/create', { type: 'client-request', rpcId: 'rpc-bad', method: 'other', payload: {}, - }), badEnvelope.response) - expect(JSON.parse(String(badEnvelope.state.body))).toMatchObject({ + }), methodMismatch.response) + expect(JSON.parse(String(methodMismatch.state.body))).toMatchObject({ rpcId: 'rpc-bad', result: { ok: false, error: { code: 'bad-request' } }, }) + + for (const [request, status] of [ + [fakeRequest({ host: 'harness.example' }, '/api2/goals/create'), 404], + [fakePost({ host: 'harness.example' }, '/outside/goals/create', {}), 404], + [fakePost({ host: 'harness.example' }, '/api2/goals//create', {}), 404], + [fakeRawPost({ host: 'harness.example' }, '/api2/goals/create', '{}'), 415], + [fakeRawPost({ host: 'harness.example', 'content-type': 'text/plain' }, '/api2/goals/create', '{}'), 415], + [fakeRawPost({ host: 'harness.example', 'content-type': 'application/json; charset=utf-8' }, '/api2/goals/create', '{'), 400], + ] as const) { + const response = fakeResponse() + await route.handler(request, response.response) + expect(response.state.status).toBe(status) + } + + for (const [body, rpcId] of [ + [{ rpcId: 'retained-id' }, 'retained-id'], + [{ rpcId: 42 }, 'invalid-request'], + [null, 'invalid-request'], + ] as const) { + const response = fakeResponse() + await route.handler(fakePost({ host: 'harness.example' }, '/api2/goals/create', body), response.response) + expect(JSON.parse(String(response.state.body))).toMatchObject({ + rpcId, + result: { ok: false, error: { code: 'bad-request' } }, + }) + } + + const failed = fakeResponse() + await route.handler(fakePost({ host: 'harness.example' }, '/api2/fail', { + type: 'client-request', rpcId: 'rpc-fail', method: 'fail', payload: {}, + }), failed.response) + expect(failed.state).toMatchObject({ status: 500, body: 'handler failure: Error: handler broke' }) + + expect(() => connection.rpc.handle('/api', async () => ({ ok: true, value: null }), { + authority: 'loopback', + })).toThrow('invalid or reserved RPC channel') + expect(() => connection.rpc.handle('api3', async () => ({ ok: true, value: null }), { + authority: 'loopback', + })).toThrow('invalid or reserved RPC channel') + + const removeLoopback = connection.rpc.handle('/loopback', async () => ({ ok: true, value: null }), { + authority: 'loopback', + }) + const loopbackRoute = routes.find(candidate => candidate.path === '/loopback')! + const publicResponse = fakeResponse() + await loopbackRoute.handler(fakePost({ host: 'harness.example' }, '/loopback/read', { + type: 'client-request', rpcId: 'rpc-public', method: 'read', payload: {}, + }), publicResponse.response) + expect(publicResponse.state.status).toBe(403) + await removeLoopback() await remove() await fiber.dispose() }) diff --git a/packages/host/api-gateway/package.json b/packages/host/api-gateway/package.json index 3f3c905f1d..794ae323aa 100644 --- a/packages/host/api-gateway/package.json +++ b/packages/host/api-gateway/package.json @@ -43,9 +43,7 @@ "lib/invariant.js", "lib/client.js", "lib/types/**/*.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "dependencies": { diff --git a/packages/host/api-gateway/src/client/index.ts b/packages/host/api-gateway/src/client/index.ts index 57116db2cf..fe8fd9f1b3 100644 --- a/packages/host/api-gateway/src/client/index.ts +++ b/packages/host/api-gateway/src/client/index.ts @@ -90,7 +90,8 @@ class ClientApiService extends Service implements ClientApi { } }, `api-gateway.client.mount(${JSON.stringify(contribution.package)})`) } catch (error) { - disposeRemote().catch(() => {}) + /* v8 ignore next -- rollback disposal only rejects if Cordis teardown itself fails while handling the installation error. */ + Promise.resolve(disposeRemote()).catch(() => {}) throw error } return async () => { @@ -148,6 +149,7 @@ class ClientApiService extends Service implements ClientApi { const projection = scopedProjection(descriptor) if (projection !== undefined) installed.push(this.installScoped(descriptor, projection, token)) return () => { + /* v8 ignore next -- Cordis effect disposers are idempotent and invoke this cleanup at most once. */ if (!token.active) return token.active = false for (const dispose of installed.reverse()) dispose() @@ -173,6 +175,7 @@ class ClientApiService extends Service implements ClientApi { value: (...args: unknown[]) => this.invoke(descriptor, undefined, token, this.ownerCtx, args), }) return () => { + /* v8 ignore next -- duplicate live methods are rejected before installation, so no newer token can replace this one. */ if (namespace.tokens.get(descriptor.method) !== token) return Reflect.deleteProperty(namespace.value, descriptor.method) namespace.tokens.delete(descriptor.method) @@ -203,6 +206,7 @@ class ClientApiService extends Service implements ClientApi { namespace.tokens.set(descriptor.method, token) namespace.service.install(descriptor, projection, token) return () => { + /* v8 ignore next -- duplicate live methods are rejected before installation, so no newer token can replace this one. */ if (namespace.tokens.get(descriptor.method) !== token) return namespace.service.remove(descriptor.method) namespace.tokens.delete(descriptor.method) @@ -289,9 +293,6 @@ class ScopedRemoteNamespace extends Service { }, }) this.methods.add(method) - if (this.methods.size === 1 && this.ownerCtx.get(this.name, false) === undefined) { - this.ownerCtx.set(this.name, this) - } } remove(method: string): void { diff --git a/packages/host/api-gateway/src/index.ts b/packages/host/api-gateway/src/index.ts index ccb76e2d48..c83772261a 100644 --- a/packages/host/api-gateway/src/index.ts +++ b/packages/host/api-gateway/src/index.ts @@ -156,11 +156,10 @@ export class TypertGatewayService extends Service implements TypertGateway { private async invokeRpc(endpoint: string, payload: unknown): Promise { try { const segments = endpoint.split('/') - const namespace = segments[0] - const method = segments[1] - if (segments.length !== 2 || namespace === undefined || namespace === '' || method === undefined || method === '') { + if (segments.length !== 2 || segments[0] === '' || segments[1] === '') { throw new Error(`invalid Remote endpoint ${JSON.stringify(endpoint)}`) } + const [namespace, method] = segments as [string, string] if (!isObject(payload) || !isPlainObject(payload) || Reflect.ownKeys(payload).length !== 1 @@ -358,6 +357,7 @@ export class TypertGatewayService extends Service implements TypertGateway { const value = decode(parameter.codec, args[parameter.wire], 'input-invalid', 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) { throw new TypertGatewayError( 'lookup-unavailable', @@ -492,11 +492,11 @@ function methodParameterNames(service: object, method: string, endpoint: string) const source = Function.prototype.toString.call(implementation) const open = source.indexOf('(') const close = source.indexOf(')', open + 1) + /* v8 ignore next -- standard public class-method syntax always contains a parenthesized parameter list. */ if (open < 0 || close < 0) return invalidSignature(endpoint, method) const body = source.slice(open + 1, close).trim() if (body.length === 0) return [] const parts = body.split(',').map(part => part.trim()) - if (parts.at(-1) === '') parts.pop() const names = new Set() for (const part of parts) { if (!/^[$A-Z_a-z][$\w]*$/u.test(part) || names.has(part)) return invalidSignature(endpoint, method) @@ -579,8 +579,8 @@ function assertJsonValue(value: unknown, ancestors: Set): void { if (!isPlainObject(value)) throw new TypeError('non-plain object is not JSON-safe') if (Object.getOwnPropertySymbols(value).length > 0) throw new TypeError('symbol property is not JSON-safe') for (const key of Reflect.ownKeys(value)) { - if (typeof key !== 'string') throw new TypeError('symbol property is not JSON-safe') const descriptor = Object.getOwnPropertyDescriptor(value, key) + /* v8 ignore next -- ownKeys() just returned this key; only a hostile same-process Proxy can delete it between operations. */ if (descriptor === undefined || !descriptor.enumerable || !('value' in descriptor)) { throw new TypeError('non-data property is not JSON-safe') } diff --git a/packages/host/api-gateway/tests/client.spec.ts b/packages/host/api-gateway/tests/client.spec.ts index be0b12ed51..8c0753f3f9 100644 --- a/packages/host/api-gateway/tests/client.spec.ts +++ b/packages/host/api-gateway/tests/client.spec.ts @@ -203,6 +203,144 @@ describe('Client TypeRT API', () => { expect(ctx.typert.remotes.list()).toEqual([]) }) + it('rejects duplicate, live, scoped-service, and Context namespace collisions', async () => { + const ctx = await bench(vi.fn()) + const direct = directDescriptor() + const context = contextDescriptor() + + expect(() => ctx.api.mount({ + package: '@fixture/direct-duplicates', + descriptors: [direct, { ...direct, id: '@fixture/goals#goals/create-again' }], + })).toThrow('repeats direct method') + expect(() => ctx.api.mount({ + package: '@fixture/scoped-duplicates', + descriptors: [context, { ...context, id: '@fixture/goals#goals/rename-again' }], + })).toThrow('repeats scoped method') + + const disposeDirect = ctx.api.mount({ package: '@fixture/direct-live', descriptors: [direct] }) + expect(() => ctx.api.mount({ + package: '@fixture/direct-conflict', descriptors: [{ ...direct, id: '@fixture/other#goals/create' }], + })).toThrow('direct method goals/create is already mounted') + await disposeDirect() + + const disposeScoped = ctx.api.mount({ package: '@fixture/scoped-live', descriptors: [context] }) + expect(() => ctx.api.mount({ + package: '@fixture/scoped-conflict', descriptors: [{ ...context, id: '@fixture/other#goals/rename' }], + })).toThrow('scoped method goals/rename is already mounted') + expect(() => ctx.api.mount({ + package: '@fixture/service-method-conflict', + descriptors: [{ ...context, id: '@fixture/goals#goals/remove', method: 'remove' }], + })).toThrow('conflicts with its namespace service') + await disposeScoped() + + expect(() => ctx.api.mount({ + package: '@fixture/context-property-conflict', + descriptors: [{ ...context, namespace: 'typert' }], + })).toThrow('conflicts with an existing Context property') + + const disposeMultipleScoped = ctx.api.mount({ + package: '@fixture/multiple-scoped', + descriptors: [directDescriptor(), contextDescriptor()], + }) + await disposeMultipleScoped() + }) + + it('rejects weak parameter and Context codecs plus malformed scope projections', async () => { + const ctx = await bench(vi.fn()) + const direct = directDescriptor() + const context = contextDescriptor() + expect(() => ctx.api.mount({ + package: '@fixture/weak-parameter', + descriptors: [{ + ...direct, + parameters: direct.parameters.map((parameter, index) => index === 0 + ? { ...parameter, codec: { mode: 'src-json' } } + : parameter), + }], + })).toThrow('has no strict codec') + expect(() => ctx.api.mount({ + package: '@fixture/weak-context', + descriptors: [{ + ...context, + invocation: { ...context.invocation, codec: { mode: 'src-json' } }, + } as InvocationDescriptor], + })).toThrow('has no strict codec') + expect(() => ctx.api.mount({ + package: '@fixture/malformed-scope', + descriptors: [{ ...direct, scope: { context: 'fixture', wire: 'missingId' } }], + })).toThrow('scope must select its only lookup parameter') + expect(() => ctx.api.mount({ + package: '@fixture/ambiguous-scope', + descriptors: [{ + ...direct, + parameters: [...direct.parameters, { + name: 'other', wire: 'otherId', source: 'lookup', lookup: 'fixture', + codec: { mode: 'strict', typeSymbol: '@fixture#AgentId', schema: idSchema }, + }], + }], + })).toThrow('scope must select its only lookup parameter') + }) + + it('validates invocation arity, required binders, live Connection, and mutable descriptor codecs', async () => { + const call = vi.fn() + .mockResolvedValue({ ok: true, value: { ref: 'goal-1' } }) + const ctx = await bench(call) + const descriptor = directDescriptor() + const dispose = ctx.api.mount({ package: '@fixture/goals', descriptors: [descriptor] }) + const create = ctx.api.goals.create as unknown as (...args: unknown[]) => Promise + + await expect(create('agent-1')).rejects.toThrow('expected 2 argument(s), got 1') + await expect((ctx as FixtureContext).goals.create({ objective: 'ship' })) + .rejects.toThrow('no Client Context binder') + + ;(descriptor.parameters[0] as { codec: { mode: string } }).codec.mode = 'src-json' + await expect(ctx.api.goals.create('agent-1', { objective: 'ship' })).rejects.toThrow('has no strict codec') + ;(descriptor.parameters[0] as { codec: { mode: string } }).codec.mode = 'strict' + + ctx.set('connection', undefined) + await expect(ctx.api.goals.create('agent-1', { objective: 'ship' })).rejects.toThrow('no active Connection') + await dispose() + }) + + it('withdraws a pending invocation and preserves a direct namespace until its last method leaves', async () => { + let resolveCall!: (result: Awaited>) => void + const pending = new Promise>>((resolve) => { + resolveCall = resolve + }) + const call = vi.fn().mockReturnValue(pending) + const ctx = await bench(call) + const { scope: _scope, ...first } = directDescriptor() + const second: InvocationDescriptor = { + ...first, + id: '@fixture/goals#goals/archive', + method: 'archive', + } + const dispose = ctx.api.mount({ package: '@fixture/goals', descriptors: [first, second] }) + const invocation = ctx.api.goals.create('agent-1', { objective: 'ship' }) + await vi.waitFor(() => { expect(call).toHaveBeenCalledTimes(1) }) + await dispose() + resolveCall({ ok: true, value: { ref: 'goal-1' } }) + + await expect(invocation).rejects.toThrow('withdrawn during invocation') + expect((ctx.api as unknown as Record).goals).toBeUndefined() + }) + + it('rolls back Remote registration when concrete method installation fails', async () => { + const ctx = await bench(vi.fn()) + const defineProperty = Object.defineProperty + const spy = vi.spyOn(Object, 'defineProperty').mockImplementation((target, key, attributes) => { + if (key === 'goals') throw new Error('fixture installation failure') + return defineProperty(target, key, attributes) + }) + try { + expect(() => ctx.api.mount({ package: '@fixture/goals', descriptors: [directDescriptor()] })) + .toThrow('fixture installation failure') + await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) }) + } finally { + spy.mockRestore() + } + }) + it('throws RPC failures with the structured error as its cause', async () => { const rpcError = { code: 'internal' as const, message: 'host failed', details: {} } const ctx = await bench(vi.fn().mockResolvedValue({ ok: false, error: rpcError })) diff --git a/packages/host/api-gateway/tests/gateway.spec.ts b/packages/host/api-gateway/tests/gateway.spec.ts index 8f7c144f5e..0b550e126d 100644 --- a/packages/host/api-gateway/tests/gateway.spec.ts +++ b/packages/host/api-gateway/tests/gateway.spec.ts @@ -229,6 +229,96 @@ class WrongBindingService extends Service { } } +class ExportedMethodService extends Service { + readonly typertGateway = bindTypeRTGateway(this, 'exportedMethod', { namespace: 'exported' }) + + constructor(ctx: Context) { + super(ctx, 'exportedMethod') + } + + @Remote('execute') + run(value: string): string { + return value + } +} + +class EmptyMethodService extends Service { + readonly typertGateway = bindTypeRTGateway(this, 'emptyMethod', { namespace: 'empty' }) + + constructor(ctx: Context) { + super(ctx, 'emptyMethod') + } + + @Remote + ping(): string { + return 'pong' + } +} + +class CollidingWireService extends Service { + readonly typertGateway = bindTypeRTGateway(this, 'collidingWire', { namespace: 'colliding-wire' }) + + constructor(ctx: Context) { + super(ctx, 'collidingWire') + } + + @Remote + run(agent: FixtureAgent, agentId: string): string { + return `${agent.id}:${agentId}` + } +} + +class ContextWireService extends Service { + readonly typertGateway = bindTypeRTGateway(this, 'contextWire', { namespace: 'context-wire' }) + + constructor(ctx: Context) { + super(ctx, 'contextWire') + } + + @RemoteContext('gatewayFixture') + run(agentId: string): string { + return agentId + } +} + +class NoBindingService extends Service { + constructor(ctx: Context) { + super(ctx, 'noBinding') + } + + run(value: string): string { + return value + } +} + +class MissingMethodService extends Service { + readonly typertGateway = bindTypeRTGateway(this, 'missingMethod', { namespace: 'missing-method' }) + + constructor(ctx: Context) { + super(ctx, 'missingMethod') + } + + @Remote + run(value: string): string { + return value + } +} + +class InheritedMethodBase extends Service { + readonly typertGateway = bindTypeRTGateway(this, 'inheritedMethod', { namespace: 'inherited' }) + + constructor(ctx: Context) { + super(ctx, 'inheritedMethod') + } + + @Remote + run(value: string): string { + return value + } +} + +class InheritedMethodService extends InheritedMethodBase {} + describe('TypertGatewayService', () => { it('invokes a strict direct method with schema decoding and a live lookup', async () => { const { ctx, service } = await setup() @@ -284,6 +374,53 @@ describe('TypertGatewayService', () => { })).resolves.toEqual({ title: 'land', scope: 'agent-src' }) }) + it('derives exported, empty, inherited, and distinct-namespace SRC methods', async () => { + const ctx = await setupGateway() + await ctx.plugin(ExportedMethodService) + await ctx.plugin(EmptyMethodService) + await ctx.plugin(InheritedMethodService) + + await expect(ctx.typertGateway.invoke({ + namespace: 'exported', method: 'execute', args: { value: 'ship' }, + })).resolves.toBe('ship') + await expect(ctx.typertGateway.invoke({ + namespace: 'empty', method: 'ping', args: {}, + })).resolves.toBe('pong') + await expect(ctx.typertGateway.invoke({ + namespace: 'inherited', method: 'run', args: { value: 'land' }, + })).resolves.toBe('land') + await expectCode(ctx.typertGateway.invoke({ + namespace: 'other', method: 'absent', args: {}, + }), 'invocation-unavailable') + }) + + it('rejects SRC wire collisions and unavailable Context providers', async () => { + const colliding = await setupGateway() + await colliding.plugin(CollidingWireService) + registerAgentLookup(colliding, { id: 'agent-1' }) + await expectCode(colliding.typertGateway.invoke({ + namespace: 'colliding-wire', + method: 'run', + args: { agentId: 'agent-1' }, + }), 'signature-invalid') + + const missing = await setup() + await expectCode(missing.ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'rename', + args: { agentId: 'agent-1', request: { title: 'land' } }, + }), 'context-unavailable') + + const contextCollision = await setupGateway() + await contextCollision.plugin(ContextWireService) + contextCollision.typert.contexts.registerHost('gatewayFixture', contextProvider(contextCollision.extend())) + await expectCode(contextCollision.typertGateway.invoke({ + namespace: 'context-wire', + method: 'run', + args: { agentId: 'agent-1' }, + }), 'signature-invalid') + }) + it('re-reads Service and providers on every strict invocation', async () => { const { ctx, serviceFiber } = await setup() const agent = { id: 'agent-1' } @@ -331,6 +468,58 @@ describe('TypertGatewayService', () => { expect(error.cause).toEqual(new Error('provider failed')) }) + it('reports Context provider metadata mismatch and unresolved identities', async () => { + const { ctx } = await setup() + registerStrict(ctx, [renameDescriptor()]) + const scoped = ctx.extend() + const mismatch = ctx.typert.contexts.registerHost('gatewayFixture', { + ...contextProvider(scoped), + wire: 'differentAgentId', + }) + await expectCode(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'rename', + args: { agentId: 'agent-1', request: { title: 'land' } }, + }), 'provider-mismatch') + await mismatch() + + ctx.typert.contexts.registerHost('gatewayFixture', { + ...contextProvider(scoped), + resolve: () => undefined, + }) + await expectCode(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'rename', + args: { agentId: 'agent-1', request: { title: 'land' } }, + }), 'context-not-found') + }) + + it('contains lookup provider failures and missing identities', async () => { + const { ctx } = await setup() + registerStrict(ctx, [createDescriptor()]) + const throwing = ctx.typert.lookups.register('gatewayFixture', { + ...agentLookup({ id: 'agent-1' }), + resolve: () => { throw new Error('lookup failed') }, + }) + const failure = await expectCode(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'create', + args: { agentId: 'agent-1', request: { title: 'ship' } }, + }), 'lookup-failed') + expect(failure.cause).toEqual(new Error('lookup failed')) + await throwing() + + ctx.typert.lookups.register('gatewayFixture', { + ...agentLookup({ id: 'agent-1' }), + resolve: () => undefined, + }) + await expectCode(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'create', + args: { agentId: 'agent-1', request: { title: 'ship' } }, + }), 'lookup-not-found') + }) + it('never downgrades an observed strict endpoint after definition disposal', async () => { const { ctx } = await setup() const dispose = registerStrict(ctx, [passthroughDescriptor()]) @@ -434,6 +623,11 @@ describe('TypertGatewayService', () => { method: 'create', args: { agentId: 'agent-1', request: { title: 'ship' }, optional: true }, }), 'arguments-invalid') + await expectCode(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'create', + args: [] as unknown as Record, + }), 'arguments-invalid') expect(service.calls).toEqual([]) }) @@ -492,6 +686,31 @@ describe('TypertGatewayService', () => { }), 'result-invalid') }) + it('accepts dense JSON and rejects decorated arrays and object properties', async () => { + const { ctx } = await setup() + await expect(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'passthrough', + args: { value: [1, { nested: true }] }, + })).resolves.toEqual([1, { nested: true }]) + + const sparseWithExtra = Array(1) as unknown[] & { extra?: boolean } + sparseWithExtra.extra = true + const symbolArray = [1] + Object.defineProperty(symbolArray, Symbol('extra'), { value: true }) + const symbolObject = { value: true } + Object.defineProperty(symbolObject, Symbol('extra'), { value: true }) + const hidden = {} + Object.defineProperty(hidden, 'value', { value: true, enumerable: false }) + const accessor = {} + Object.defineProperty(accessor, 'value', { get: () => true, enumerable: true }) + for (const value of [sparseWithExtra, symbolArray, symbolObject, hidden, accessor]) { + await expectCode(ctx.typertGateway.invoke({ + namespace: 'goals', method: 'passthrough', args: { value }, + }), 'input-invalid') + } + }) + it('validates strict provider identity against generated wire metadata', async () => { const { ctx } = await setup() ctx.typert.lookups.register('gatewayFixture', { @@ -525,6 +744,61 @@ describe('TypertGatewayService', () => { }), 'method-unavailable') }) + it('requires a visible binding and supports explicitly provided plain Services', async () => { + const ctx = await setupGateway() + await ctx.plugin(NoBindingService) + registerStrict(ctx, [{ + ...passthroughDescriptor(), + id: '@fixture/gateway#no-binding/run', + service: 'noBinding', + namespace: 'no-binding', + method: 'run', + }]) + await expectCode(ctx.typertGateway.invoke({ + namespace: 'no-binding', method: 'run', args: { value: 'ship' }, + }), 'binding-invalid') + + const plain: { + typertGateway?: ReturnType + run(value: string): string + } = { run: value => value } + plain.typertGateway = bindTypeRTGateway(plain, 'plainRemote', { namespace: 'plain' }) + ctx.provide('plainRemote', plain) + ctx.typert.register({ + package: '@fixture/plain', + face: 'host', + schemas: [], + model: emptyModel, + invocations: [{ + ...passthroughDescriptor(), + id: '@fixture/plain#plain/run', + service: 'plainRemote', + namespace: 'plain', + method: 'run', + }], + }) + await expect(ctx.typertGateway.invoke({ + namespace: 'plain', method: 'run', args: { value: 'land' }, + })).resolves.toBe('land') + }) + + it('reports a SRC marker whose prototype implementation disappeared', async () => { + const ctx = await setupGateway() + await ctx.plugin(MissingMethodService) + const descriptor = Object.getOwnPropertyDescriptor(MissingMethodService.prototype, 'run')! + Object.defineProperty(MissingMethodService.prototype, 'run', { + configurable: true, + value: 42, + }) + try { + await expectCode(ctx.typertGateway.invoke({ + namespace: 'missing-method', method: 'run', args: { value: 'ship' }, + }), 'method-unavailable') + } finally { + Object.defineProperty(MissingMethodService.prototype, 'run', descriptor) + } + }) + it('preserves business exception identity after invocation begins', async () => { const { ctx, service } = await setup() const failure = new Error('business identity') @@ -575,6 +849,26 @@ describe('TypertGatewayService', () => { if (invalid.ok) throw new Error('invalid Remote payload unexpectedly succeeded') expect(invalid.error.message).toMatch(/exactly one plain-object args field/) + for (const endpoint of ['goals', '/create', 'goals/', 'goals/create/extra']) { + const result = await handler(endpoint, { args: {} }, signal) + expect(result).toMatchObject({ ok: false, error: { code: 'internal' } }) + if (result.ok) throw new Error('invalid Remote endpoint unexpectedly succeeded') + expect(result.error.message).toContain('invalid Remote endpoint') + } + for (const payload of [null, [], { args: {}, extra: true }, { only: true }, { args: null }, { args: [] }]) { + const result = await handler('goals/create', payload, signal) + expect(result).toMatchObject({ ok: false, error: { code: 'internal' } }) + if (result.ok) throw new Error('invalid Remote payload unexpectedly succeeded') + expect(result.error.message).toContain('plain-object args field') + } + + const service = rawGoalService(ctx) + service.businessError = 'non-error failure' as unknown as Error + await expect(handler('goals/fail', { args: { request: null } }, signal)).resolves.toEqual({ + ok: false, + error: { code: 'internal', message: 'non-error failure', details: {} }, + }) + await gatewayFiber.dispose() expect(connection.handler).toBeUndefined() }) diff --git a/packages/typert/generator/src/emitter.ts b/packages/typert/generator/src/emitter.ts index 3e79780593..63b1ee7ace 100644 --- a/packages/typert/generator/src/emitter.ts +++ b/packages/typert/generator/src/emitter.ts @@ -399,21 +399,9 @@ export class FaceModelEmitter { scoped: boolean, ): void { const signature = this.remoteSignature(invocation, referenceNames, scoped) - const line = ` ${signature}` - lines.push(line) - const generatedLine = lines.length const keyLength = signature.indexOf(': (') if (keyLength < 0) throw new TypertEmitError(`Remote signature ${invocation.id} has no property delimiter`) - const source = remoteDeclarationSource(packageModel, invocation) - addMapping(sourceMap, { - generated: { line: generatedLine, column: 4 }, - source, - original: { line: invocation.location.line, column: invocation.location.column - 1 }, - name: invocation.method, - }) - addMapping(sourceMap, { - generated: { line: generatedLine, column: 4 + keyLength }, - }) + this.pushMappedRemoteSignature(lines, sourceMap, packageModel, invocation, signature, keyLength) } private pushRemoteNamespaceSignature( @@ -424,6 +412,17 @@ export class FaceModelEmitter { referenceNames: ReadonlyMap, ): void { const signature = `${invocation.method}: ${this.remoteFunctionType(invocation, referenceNames, false)}` + this.pushMappedRemoteSignature(lines, sourceMap, packageModel, invocation, signature, invocation.method.length) + } + + private pushMappedRemoteSignature( + lines: string[], + sourceMap: GenMapping, + packageModel: PackageModel, + invocation: InvocationModel, + signature: string, + keyLength: number, + ): void { lines.push(` ${signature}`) const generatedLine = lines.length const source = remoteDeclarationSource(packageModel, invocation) @@ -434,7 +433,7 @@ export class FaceModelEmitter { name: invocation.method, }) addMapping(sourceMap, { - generated: { line: generatedLine, column: 4 + invocation.method.length }, + generated: { line: generatedLine, column: 4 + keyLength }, }) } diff --git a/packages/typert/generator/src/tsdown-plugin.ts b/packages/typert/generator/src/tsdown-plugin.ts index a5c6ef93e2..10cba60974 100644 --- a/packages/typert/generator/src/tsdown-plugin.ts +++ b/packages/typert/generator/src/tsdown-plugin.ts @@ -1,23 +1,27 @@ /** - * Optional tsdown (rolldown) plugin face of the typert generator. When added - * to a workspace tsdown config, it runs after each opted-in package bundle is - * written and re-emits its model-driven face artifact at the package output - * root. Packages without a Typert or Remote export are skipped. + * Optional tsdown (rolldown) plugin face of the typert generator. It lowers + * standard decorators in TypeScript dependencies before bundling, then emits + * model-driven face artifacts at the package output root. Packages without a + * Typert or Remote export are skipped. * @module @deepseek-ai/dsh-typert-generator/tsdown */ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { dirname, join, resolve } from 'node:path' +import ts from 'typescript' import { WorkspaceTypertGenerator } from './workspace.ts' import type { WorkspaceEmitResult } from './workspace.ts' import type { TypertFace } from './model.ts' -/** The subset of the rolldown output-plugin contract this plugin uses (structural; avoids a rolldown type dependency). */ +/** The subset of the rolldown plugin contract used here (structural; avoids a rolldown type dependency). */ interface TypertPlugin { name: string + transform: (code: string, id: string) => { code: string; map: string | undefined } | undefined writeBundle: (options: { dir?: string }) => void } +const DECORATOR_SYNTAX = /^\s*@[A-Za-z_$][\w$]*/m + /** Generation scope selected by a tsdown build phase. */ export interface TypertPluginOptions { /** Package mode emits only the package being bundled; workspace mode emits every explicit contributor once. */ @@ -27,15 +31,32 @@ export interface TypertPluginOptions { } /** - * Create the typert generation plugin for the root tsdown config. + * Create the decorator-lowering and typert-generation plugin for the root tsdown config. * @param pluginOptions - package/workspace emission mode and independent program faces. - * @returns a rolldown-compatible plugin that emits local face and Host-for-Client Remote artifacts. + * @returns a rolldown-compatible plugin that lowers source decorators and emits local and Host-for-Client artifacts. */ export function typertPlugin(pluginOptions: TypertPluginOptions = {}): TypertPlugin { const artifactsByRoot = new Map() const emittedWorkspaces = new Set() return { name: 'dsh-typert-generator', + transform(code, id) { + const file = id.split('?', 1)[0] ?? id + if (!/\.[cm]?tsx?$/.test(file) || !DECORATOR_SYNTAX.test(code)) return + const result = ts.transpileModule(code, { + fileName: file, + compilerOptions: { + target: ts.ScriptTarget.ES2024, + module: ts.ModuleKind.ESNext, + ...(file.endsWith('x') ? { jsx: ts.JsxEmit.ReactJSX } : {}), + sourceMap: true, + }, + }) + return { + code: result.outputText.replace(/\n?\/\/# sourceMappingURL=.*$/u, '\n'), + map: result.sourceMapText, + } + }, writeBundle(bundleOptions) { // options.dir is the package's absolute outDir (/lib); its // nearest package.json owns the bundle even when a custom config writes diff --git a/packages/typert/generator/tests/tsdown-plugin.spec.ts b/packages/typert/generator/tests/tsdown-plugin.spec.ts index 655636aa79..106b8950ff 100644 --- a/packages/typert/generator/tests/tsdown-plugin.spec.ts +++ b/packages/typert/generator/tests/tsdown-plugin.spec.ts @@ -64,6 +64,13 @@ afterEach(() => { }) describe('typertPlugin', () => { + it('lowers standard decorators in TypeScript source dependencies', () => { + const plugin = typertPlugin() + expect(plugin.transform('export const value = 1\n', '/workspace/src/plain.ts')).toBeUndefined() + expect(plugin.transform('@sealed\nexport class Example {}\n', '/workspace/src/example.ts')?.code) + .not.toContain('@sealed') + }) + it('skips outputs that do not identify a Typert contributor', async () => { const plugin = typertPlugin() expect(plugin.name).toBe('dsh-typert-generator') diff --git a/packages/typert/registry/src/service.ts b/packages/typert/registry/src/service.ts index 16160a3860..4973732fad 100644 --- a/packages/typert/registry/src/service.ts +++ b/packages/typert/registry/src/service.ts @@ -149,8 +149,10 @@ class DescriptorStore { for (const descriptor of descriptors) { const endpoint = typertEndpoint(descriptor) const entry = this.entries.get(endpoint) + /* v8 ignore next -- duplicate registration is rejected, so no later owner can replace this entry before its effect disposes. */ if (entry?.owner !== owner) continue this.entries.delete(endpoint) + /* v8 ignore next -- ids and endpoints are committed and withdrawn together under the same unique owner. */ if (this.ids.get(descriptor.id) === entry) this.ids.delete(descriptor.id) removed.push(endpoint) } @@ -200,6 +202,7 @@ class RemoteStore { packages.set(contribution.package, owner) descriptors.commit(owner, contribution.descriptors) yield () => { + /* v8 ignore else -- duplicate package registration is rejected, so this effect remains the package's unique owner. */ if (packages.get(contribution.package) === owner) packages.delete(contribution.package) descriptors.withdraw(owner, contribution.descriptors) } @@ -244,6 +247,7 @@ class LookupStore { providers.set(key, entry) changes.emit({ kind: 'lookup', key }) yield () => { + /* v8 ignore next -- duplicate registration is rejected, so this effect remains the key's unique owner. */ if (providers.get(key) !== entry) return providers.delete(key) changes.emit({ kind: 'lookup', key }) @@ -303,6 +307,7 @@ class ContextStore { table.set(key, entry) changes.emit({ kind, key }) yield () => { + /* v8 ignore next -- duplicate registration is rejected, so this effect remains the key's unique owner. */ if (table.get(key) !== entry) return table.delete(key) changes.emit({ kind, key }) @@ -381,8 +386,10 @@ export class TypertRegistry extends Service implements TypeRTService { for (const record of schemaRecords) schemas.set(record.key, record) localStore.commit(owner, invocations) yield () => { + /* v8 ignore else -- duplicate package-face registration is rejected, so this effect remains its unique owner. */ if (packages.get(packageRecord.key) === packageRecord) packages.delete(packageRecord.key) for (const record of schemaRecords) { + /* v8 ignore else -- duplicate schema registration is rejected, so this contribution remains each record's unique owner. */ if (schemas.get(record.key) === record) schemas.delete(record.key) } localStore.withdraw(owner, invocations) diff --git a/packages/typert/registry/tests/typert.spec.ts b/packages/typert/registry/tests/typert.spec.ts index a98f99f912..95f8bc871f 100644 --- a/packages/typert/registry/tests/typert.spec.ts +++ b/packages/typert/registry/tests/typert.spec.ts @@ -13,6 +13,7 @@ import type { TypeRTLookup, TypeRTRemoteContribution, } from '@deepseek-ai/dsh-type-meta' +import { apply as applyClientRegistry, inject as clientRegistryInject } from '../src/client/index.ts' declare module '@deepseek-ai/dsh-type-meta' { interface TypeRTLookupMap { @@ -222,6 +223,29 @@ describe('TypertRegistry', () => { expect(changes).toEqual(['local:goals/create', 'local:goals/create']) }) + it('rejects duplicate invocation endpoints and ids atomically', async () => { + const ctx = await makeCtx() + const first = invocation() + ctx.typert.register({ ...toolsContribution(), invocations: [first] }) + + expect(() => ctx.typert.remotes.register({ + package: '@fixture/duplicate-endpoint', + descriptors: [invocation('@fixture/remote#first'), invocation('@fixture/remote#second')], + })).toThrow('endpoint "goals/create" is already registered') + expect(() => ctx.typert.remotes.register({ + package: '@fixture/duplicate-id', + descriptors: [ + invocation('@fixture/remote#same'), + { ...invocation('@fixture/remote#same'), method: 'rename' }, + ], + })).toThrow('invocation id "@fixture/remote#same" is already registered') + expect(() => ctx.typert.register({ + ...toolsContribution(), + package: '@fixture/existing-endpoint', + invocations: [{ ...first, id: '@fixture/local#other' }], + })).toThrow('endpoint "goals/create" is already registered') + }) + it('mounts Remote contributions in the calling fiber and withdraws them exactly', async () => { const ctx = await makeCtx() const descriptor = invocation() @@ -314,6 +338,131 @@ describe('TypertRegistry', () => { expect(ctx.typert.contexts.getClient('registryFixture')).toBeUndefined() }) + it('publishes provider changes, rejects duplicate providers, and disposes subscriptions', async () => { + const ctx = await makeCtx() + const changes: string[] = [] + const disposeLookupSubscription = ctx.typert.lookups.subscribe((change) => { + changes.push(`${change.kind}:${change.key}`) + }) + const disposeContextSubscription = ctx.typert.contexts.subscribe((change) => { + changes.push(`${change.kind}:${change.key}`) + }) + const lookup = { + parameter: 'agent', + wire: 'agentId', + hostTypeSymbol: '@fixture#Agent', + wireTypeSymbol: '@fixture#AgentId', + resolve: () => undefined, + } + const host = { + wire: 'agentId', + wireTypeSymbol: '@fixture#AgentId', + resolve: () => undefined, + } + const client = { identity: () => undefined } + const disposeLookup = ctx.typert.lookups.register('fixture', lookup) + const disposeHost = ctx.typert.contexts.registerHost('registryFixture', host) + const disposeClient = ctx.typert.contexts.registerClient('registryFixture', client) + + expect(() => ctx.typert.lookups.register('fixture', lookup)).toThrow('already registered') + expect(() => ctx.typert.contexts.registerHost('registryFixture', host)).toThrow('already registered') + expect(() => ctx.typert.contexts.registerClient('registryFixture', client)).toThrow('already registered') + await Promise.all([disposeLookup(), disposeHost(), disposeClient()]) + expect(changes).toEqual([ + 'lookup:fixture', + 'host-context:registryFixture', + 'client-context:registryFixture', + 'lookup:fixture', + 'host-context:registryFixture', + 'client-context:registryFixture', + ]) + + await Promise.all([disposeLookupSubscription(), disposeContextSubscription()]) + ctx.typert.lookups.register('fixture', lookup) + expect(changes).toHaveLength(6) + }) + + it('validates every invocation and provider boundary', async () => { + const ctx = await makeCtx() + const strict = { + mode: 'strict' as const, + typeSymbol: '@fixture#Value', + schema: z.string(), + } + const strictInvocation: InvocationDescriptor = { + ...invocation('@fixture/remote#strict'), + implementation: 'remoteExportCreate', + parameters: [{ name: 'request', wire: 'request', source: 'json', codec: strict }], + result: strict, + } + const dispose = ctx.typert.remotes.register({ package: '@fixture/strict', descriptors: [strictInvocation] }) + await dispose() + + const malformed: readonly [InvocationDescriptor, string][] = [ + [{ ...invocation(), id: '' }, 'invocation id'], + [{ ...invocation(), namespace: 'bad/name' }, 'namespace'], + [{ ...invocation(), implementation: 'bad/name' }, 'implementation method'], + [{ + ...invocation(), + parameters: [ + ...invocation().parameters, + { name: 'other', wire: 'request', source: 'json', codec: { mode: 'src-json' } }, + ], + }, 'repeats wire field'], + [{ + ...invocation(), + parameters: [{ name: 'agent', wire: 'agentId', source: 'lookup', codec: { mode: 'src-json' } }], + }, 'has no lookup key'], + [{ + ...invocation(), + parameters: [{ + name: 'request', wire: 'request', source: 'json', lookup: 'fixture', codec: { mode: 'src-json' }, + }], + }, 'JSON parameter'], + [{ + ...invocation(), + invocation: { + kind: 'context', context: 'registryFixture', wire: 'request', codec: { mode: 'src-json' }, + }, + }, 'repeats wire field'], + [{ + ...invocation(), + result: { mode: 'strict', typeSymbol: '', schema: z.string() }, + }, 'type symbol'], + [{ + ...invocation(), + result: { mode: 'strict', typeSymbol: '@fixture#Broken', schema: {} as z.ZodType }, + }, 'has no parse'], + ] + for (const [index, [descriptor, message]] of malformed.entries()) { + expect(() => ctx.typert.remotes.register({ + package: `@fixture/malformed-${String(index)}`, + descriptors: [descriptor], + })).toThrow(message) + } + + expect(() => ctx.typert.lookups.register('bad#key' as 'fixture', { + parameter: 'agent', + wire: 'agent/id', + hostTypeSymbol: '', + wireTypeSymbol: '', + resolve: () => undefined, + })).toThrow('lookup key') + expect(() => ctx.typert.lookups.register('fixture', { + parameter: 'agent', + wire: 'agent/id', + hostTypeSymbol: '@fixture#Agent', + wireTypeSymbol: '@fixture#AgentId', + resolve: () => undefined, + })).toThrow('lookup wire field') + }) + + it('installs the registry through the Client entry without importing the Host entry', async () => { + const ctx = new Context() + await ctx.plugin({ inject: clientRegistryInject, apply: applyClientRegistry }) + expect(ctx.typert.list()).toEqual([]) + }) + it('contains change-listener failures and still notifies later listeners', async () => { const ctx = await makeCtx() const warnings: unknown[] = [] diff --git a/packages/typert/type-meta/package.json b/packages/typert/type-meta/package.json index 2ffcd6c5ed..e2d7689866 100644 --- a/packages/typert/type-meta/package.json +++ b/packages/typert/type-meta/package.json @@ -26,9 +26,7 @@ "lib/index.js", "lib/invariant.js", "lib/types/**/*.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/typert/type-meta/tests/type-meta.spec.ts b/packages/typert/type-meta/tests/type-meta.spec.ts index 1eab5a6ca3..f25c367914 100644 --- a/packages/typert/type-meta/tests/type-meta.spec.ts +++ b/packages/typert/type-meta/tests/type-meta.spec.ts @@ -107,6 +107,80 @@ describe('type-meta Remote declarations', () => { expect(remoteMethods(first)).toEqual([{ method: 'run', invocation: { kind: 'direct' } }]) }) + it('supports explicit export names without exposing marker storage', () => { + class Service { + run(value: string): string { + return value + } + + scoped(value: string): string { + return value + } + } + const initializers: Array<(this: Service) => void> = [] + Remote('execute')( + Reflect.get(Service.prototype, 'run') as (this: Service, ...args: unknown[]) => unknown, + methodContext('run', initializers), + ) + RemoteContext('metaFixture', 'inspect')( + Reflect.get(Service.prototype, 'scoped') as (this: Service, ...args: unknown[]) => unknown, + methodContext('scoped', initializers), + ) + const service = new Service() + for (const initialize of initializers) initialize.call(service) + + expect(remoteMethods(service)).toEqual([ + { method: 'run', exportName: 'execute', invocation: { kind: 'direct' } }, + { method: 'scoped', exportName: 'inspect', invocation: { kind: 'context', context: 'metaFixture' } }, + ]) + expect(remoteMethods({})).toEqual([]) + const prototypeLess: object = {} + Reflect.setPrototypeOf(prototypeLess, null) + expect(remoteMethods(prototypeLess)).toEqual([]) + }) + + it('rejects malformed decorator calls and targets', () => { + const method: (this: object) => void = function (this: object): void {} + expect(() => { (Remote as unknown as (value: typeof method) => void)(method) }).toThrow('context is missing') + expect(() => Remote('bad/name')).toThrow('export name') + expect(() => RemoteContext('' as 'metaFixture')).toThrow('Context key') + expect(() => RemoteContext('metaFixture', 'bad/name')).toThrow('export name') + + for (const context of [ + { ...methodContext('run', []), private: true }, + { ...methodContext('run', []), static: true }, + { ...methodContext('run', []), name: Symbol('run') }, + ]) { + expect(() => { Remote(method, context) }) + .toThrow('public instance method') + } + }) + + it('rejects prototype-less initialization and conflicting markers', () => { + const method: (this: object) => void = function (this: object): void {} + const direct: Array<(this: object) => void> = [] + Remote(method, methodContext('run', direct)) + const prototypeLess: object = {} + Reflect.setPrototypeOf(prototypeLess, null) + expect(() => { direct[0]!.call(prototypeLess) }).toThrow('without a prototype') + + class Service { + run(): void {} + } + const conflicting: Array<(this: Service) => void> = [] + Remote( + Reflect.get(Service.prototype, 'run'), + methodContext('run', conflicting), + ) + RemoteContext('metaFixture')( + Reflect.get(Service.prototype, 'run'), + methodContext('run', conflicting), + ) + const service = new Service() + conflicting[0]!.call(service) + expect(() => { conflicting[1]!.call(service) }).toThrow('conflicting invocation markers') + }) + it('rejects ambiguous binding names', () => { expect(() => bindTypeRTGateway({}, '')).toThrow('service key') expect(() => bindTypeRTGateway({}, 'goals', { namespace: 'api/goals' })).toThrow('namespace') diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 45e8ad1803..caf1f8a5ba 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7077,6 +7077,9 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../packages/core/tools + '@deepseek-ai/dsh-type-meta': + specifier: workspace:^ + version: link:../../packages/typert/type-meta '@deepseek-ai/dsh-user-approval': specifier: workspace:^ version: link:../../packages/ui/user-approval diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index 4fd7291633..d8151de3a4 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -93,6 +93,7 @@ "@deepseek-ai/dsh-tool-web": "workspace:^", "@deepseek-ai/dsh-tool-workflow": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/dsh-type-meta": "workspace:^", "@deepseek-ai/dsh-user-approval": "workspace:^", "@deepseek-ai/dsh-user-interaction": "workspace:^", "@deepseek-ai/dsh-web": "workspace:^", diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index e0b9344cdf..9be97f53e6 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -7,7 +7,7 @@ import { existsSync, readdirSync, readFileSync } from 'node:fs' import { join, relative, resolve } from 'node:path' -import { isForbiddenPublicationFile } from './publication-payload.ts' +import { hasTypeRTRemoteNavigation, isForbiddenPublicationFile } from './publication-payload.ts' const root = resolve(import.meta.dirname, '..') // vendor/* is single-level; packages// nests one level deeper @@ -122,6 +122,7 @@ function sameStringList(actual: readonly string[] | undefined, expected: readonl function expectedDshPackageFiles(manifest: PackageManifest): readonly string[] { const extras = manifest.name ? packageFileExtras[manifest.name] ?? [] : [] + const typeRTRemoteNavigation = hasTypeRTRemoteNavigation(manifest) return [ 'lib/index.js', // Every package publishes its invariant ownership companion as a separate @@ -145,9 +146,37 @@ function expectedDshPackageFiles(manifest: PackageManifest): readonly string[] { // declarations. ...usesEmittedTreeDefaults(manifest) ? ['lib/types/**/*.js'] : [], 'lib/types/**/*.d.ts', + ...hasExportPair(manifest, './typert', './lib/typert.host.d.ts', './lib/typert.host.js') + ? ['lib/typert.host.js', 'lib/typert.host.d.ts'] + : [], + ...hasExportPair(manifest, './client/typert', './lib/typert.client.d.ts', './lib/typert.client.js') + ? ['lib/typert.client.js', 'lib/typert.client.d.ts'] + : [], + ...typeRTRemoteNavigation + ? [ + 'lib/typert.remote-client.js', + 'lib/typert.remote-client.d.ts', + 'lib/typert.remote-client.d.ts.map', + 'src', + ] + : [], ] } +/** Whether one conditional export exactly names the generated runtime and declaration pair. */ +function hasExportPair( + manifest: PackageManifest, + subpath: string, + types: string, + runtime: string, +): boolean { + const entry = manifest.exports?.[subpath] + return typeof entry === 'object' + && entry !== null + && entry.types === types + && entry.default === runtime +} + /** Runtime target of an export entry: conditional `default`, or the bare-string shorthand. */ function exportDefault(manifest: PackageManifest, subpath: string): string | undefined { const entry = manifest.exports?.[subpath] @@ -175,8 +204,9 @@ function checkWorkspace({ dir, manifest }: WorkspaceManifest): string[] { } if (manifest.name?.startsWith('@deepseek-ai/')) { + const publicationPolicy = { typeRTRemoteNavigation: hasTypeRTRemoteNavigation(manifest) } for (const file of manifest.files ?? []) { - if (isForbiddenPublicationFile(file)) { + if (isForbiddenPublicationFile(file, publicationPolicy)) { errors.push(`${label}: package.json files must not publish ${JSON.stringify(file)}`) } } diff --git a/scripts/dev-web.spec.ts b/scripts/dev-web.spec.ts index 2edbc652ab..71576caf9e 100644 --- a/scripts/dev-web.spec.ts +++ b/scripts/dev-web.spec.ts @@ -22,13 +22,7 @@ export default defineConfig({ const bundlePath = join(root, 'lib/client.js') await writeFile(sourcePath, 'export const version = "watch-v1"\n') bundles = await watchClientPlugins(root, ['.'], 50) - await expect.poll(async () => { - try { - return (await readFile(bundlePath, 'utf8')).includes('watch-v1') - } catch { - return false - } - }, { timeout: 10_000 }).toBe(true) + expect(await readFile(bundlePath, 'utf8')).toContain('watch-v1') await new Promise(resolve => setTimeout(resolve, 1_000)) await writeFile(sourcePath, `export const version = "watch-v2-${'x'.repeat(100)}"\n`) diff --git a/scripts/dev-web.ts b/scripts/dev-web.ts index aee7146487..294e38002b 100644 --- a/scripts/dev-web.ts +++ b/scripts/dev-web.ts @@ -47,21 +47,40 @@ export function discoverPluginDirs(root = repoRoot): string[] { * @param root - repository or fixture root passed to tsdown. * @param pluginDirs - workspace-relative package directories to watch. * @param pollInterval - optional source-watcher polling interval in milliseconds. - * @returns live bundles whose async disposers stop every watcher. + * @returns live bundles after every watcher has completed its initial build. */ export async function watchClientPlugins( root: string, pluginDirs: readonly string[], pollInterval?: number, ): Promise { - return build({ + let resolveInitialBuilds: (() => void) | undefined + const initialBuilds = new Promise((resolve) => { resolveInitialBuilds = resolve }) + const initialized = new WeakSet() + const readiness: { expectedBuilds?: number; initializedBuilds: number } = { initializedBuilds: 0 } + const bundles = await build({ cwd: root, workspace: [...pluginDirs], watch: true, + hooks: { + 'build:done': ({ options }) => { + if (initialized.has(options)) return + initialized.add(options) + readiness.initializedBuilds += 1 + if ( + readiness.expectedBuilds !== undefined + && readiness.initializedBuilds >= readiness.expectedBuilds + ) resolveInitialBuilds?.() + }, + }, ...pollInterval !== undefined ? { inputOptions: { watch: { watcher: { usePolling: true, pollInterval } } } } : {}, }) + readiness.expectedBuilds = bundles.length + if (readiness.initializedBuilds >= readiness.expectedBuilds) resolveInitialBuilds?.() + await initialBuilds + return bundles } const invokedPath = process.argv[1] diff --git a/scripts/publication-payload.spec.ts b/scripts/publication-payload.spec.ts index 0f403bee7c..03603ef54d 100644 --- a/scripts/publication-payload.spec.ts +++ b/scripts/publication-payload.spec.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'vitest' -import { isForbiddenPublicationFile, validateTarballPayload } from './publication-payload.ts' +import { + hasTypeRTRemoteNavigation, + isForbiddenPublicationFile, + validateTarballPayload, +} from './publication-payload.ts' function validateFixtureTarball(files: readonly string[]): () => void { return () => { @@ -51,4 +55,29 @@ describe('publication payload policy', () => { 'package/lib/styles/base.css', ])).not.toThrow() }) + + it('allows only the TypeRT declaration map and its navigable source tree when requested', () => { + const policy = { typeRTRemoteNavigation: true } + expect(isForbiddenPublicationFile('src/index.ts', policy)).toBe(false) + expect(isForbiddenPublicationFile('lib/typert.remote-client.d.ts.map', policy)).toBe(false) + expect(isForbiddenPublicationFile('lib/types/index.d.ts.map', policy)).toBe(true) + expect(() => { + validateTarballPayload([ + 'package/lib/typert.remote-client.d.ts.map', + 'package/src/index.ts', + ], 'fixture.tgz', policy) + }).not.toThrow() + }) + + it('recognizes only the canonical Host-for-Client export pair', () => { + expect(hasTypeRTRemoteNavigation({ + exports: { + './remote': { + types: './lib/typert.remote-client.d.ts', + default: './lib/typert.remote-client.js', + }, + }, + })).toBe(true) + expect(hasTypeRTRemoteNavigation({ exports: { './remote': './lib/remote.js' } })).toBe(false) + }) }) diff --git a/scripts/publication-payload.ts b/scripts/publication-payload.ts index 9c16067fe7..60f37b4f94 100644 --- a/scripts/publication-payload.ts +++ b/scripts/publication-payload.ts @@ -1,5 +1,22 @@ /** Publication payload policy shared by static manifests and packed tarballs. */ +/** Publication exceptions required for TypeRT declaration-map navigation. */ +export interface PublicationPayloadPolicy { + readonly typeRTRemoteNavigation?: boolean +} + +/** Whether a package manifest exports generated Host-for-Client metadata with source navigation. */ +export function hasTypeRTRemoteNavigation(manifest: unknown): boolean { + if (manifest === null || typeof manifest !== 'object' || Array.isArray(manifest)) return false + const exportsField = (manifest as Record).exports + if (exportsField === null || typeof exportsField !== 'object' || Array.isArray(exportsField)) return false + const remote = (exportsField as Record)['./remote'] + if (remote === null || typeof remote !== 'object' || Array.isArray(remote)) return false + const entry = remote as Record + return entry.types === './lib/typert.remote-client.d.ts' + && entry.default === './lib/typert.remote-client.js' +} + /** Normalize a package manifest path or npm tarball member to its payload-relative path. */ function payloadPath(file: string): string { const normalized = file.replaceAll('\\', '/').replace(/^\.\/+/, '').replace(/\/+$/, '') @@ -7,17 +24,30 @@ function payloadPath(file: string): string { } /** Whether a package payload path exposes source or declaration-map intermediates. */ -export function isForbiddenPublicationFile(file: string): boolean { +export function isForbiddenPublicationFile( + file: string, + policy: PublicationPayloadPolicy = {}, +): boolean { const normalized = payloadPath(file) + if (policy.typeRTRemoteNavigation === true + && (normalized === 'src' + || normalized.startsWith('src/') + || normalized === 'lib/typert.remote-client.d.ts.map')) { + return false + } return normalized === 'src' || normalized.startsWith('src/') || normalized.endsWith('.d.ts.map') } /** Reject source and declaration-map members in a packed npm tarball. */ -export function validateTarballPayload(files: readonly string[], context: string): void { +export function validateTarballPayload( + files: readonly string[], + context: string, + policy: PublicationPayloadPolicy = {}, +): void { for (const file of files) { - if (!isForbiddenPublicationFile(file)) continue + if (!isForbiddenPublicationFile(file, policy)) continue const normalized = payloadPath(file) if (normalized === 'src' || normalized.startsWith('src/')) { throw new Error(`${context} publishes source file ${file}`) diff --git a/scripts/publish-npm-baseline.ts b/scripts/publish-npm-baseline.ts index 20b9eeaea0..4a33f32e1e 100644 --- a/scripts/publish-npm-baseline.ts +++ b/scripts/publish-npm-baseline.ts @@ -18,7 +18,7 @@ import { basename, dirname, isAbsolute, join, normalize, relative, resolve, sep import { createInterface } from 'node:readline/promises' import { pathToFileURL } from 'node:url' import { parseArgs } from 'node:util' -import { validateTarballPayload } from './publication-payload.ts' +import { hasTypeRTRemoteNavigation, validateTarballPayload } from './publication-payload.ts' const DEFAULT_REGISTRY = 'https://registry.npm.harnessment.com' const DEFAULT_OUTPUT_DIRECTORY = '.artifacts/npm-baseline' @@ -320,7 +320,11 @@ class ReleaseBundle { if (expected === undefined || !missingNames.delete(artifact.name)) { throw new Error(`unexpected or duplicate packed package: ${artifact.name}`) } - if (expected.origin === 'harness') validateTarballPayload(artifact.files, tarball) + if (expected.origin === 'harness') { + validateTarballPayload(artifact.files, tarball, { + typeRTRemoteNavigation: hasTypeRTRemoteNavigation(artifact.manifest), + }) + } if (artifact.version !== version) { throw new Error(`${tarball} has version ${artifact.version}; expected ${version}`) } @@ -394,7 +398,11 @@ class ReleaseBundle { throw new Error(`tarball checksum mismatch: ${pkg.tarball}`) } const artifact = inspectTarball(path, runner) - if (pkg.origin === 'harness') validateTarballPayload(artifact.files, pkg.tarball) + if (pkg.origin === 'harness') { + validateTarballPayload(artifact.files, pkg.tarball, { + typeRTRemoteNavigation: hasTypeRTRemoteNavigation(artifact.manifest), + }) + } if (artifact.name !== pkg.name || artifact.version !== this.manifest.version) { throw new Error(`tarball identity mismatch: ${pkg.tarball}`) } From 41677c3be00557a2a741e03bbf6419956ca0c68e Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 6 Aug 2026 06:02:12 +0800 Subject: [PATCH 17/63] fix(ci): preserve TypeRT contract build order on Windows --- scripts/wine-windows-gates.sh | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/scripts/wine-windows-gates.sh b/scripts/wine-windows-gates.sh index 7706a88a4b..1f5de1dcbd 100755 --- a/scripts/wine-windows-gates.sh +++ b/scripts/wine-windows-gates.sh @@ -204,11 +204,14 @@ cat "$scratch/logs/smoke.log" grep -q '^smoke: win32 x64' "$scratch/logs/smoke.log" || { echo 'wine-windows-gates: Windows Node smoke did not report win32 x64' >&2; exit 1; } # ---- the two blocking surfaces, concurrently ------------------------------ -# The same shape run-gates gives ci-windows-blocking on native Windows: -# `build` = tsc -b then tsdown, `production site` = the VitePress build. Both -# statuses are captured so one failure cannot hide the other's result. +# The build preserves the face order from package.json: generate Host contracts +# before either aggregate typecheck, then bundle the completed workspace. +# Both statuses are captured so one failure cannot hide the other's result. build_gate() { - wine_node "$scratch/logs/tsc.log" "$tsc_js" -b --pretty false || return $? + wine_node "$scratch/logs/contracts-tsc.log" "$tsc_js" -b packages/typert/generator --pretty false || return $? + wine_node "$scratch/logs/contracts-tsdown.log" "$tsdown_js" --config tsdown.typert-host.config.ts || return $? + wine_node "$scratch/logs/host-tsc.log" "$tsc_js" -b tsconfig.host.json --pretty false || return $? + wine_node "$scratch/logs/client-tsc.log" "$tsc_js" -b tsconfig.client.json --pretty false || return $? wine_node "$scratch/logs/tsdown.log" "$tsdown_js" } site_gate() { @@ -235,7 +238,12 @@ report() { for log in "$@"; do tail -n 200 "$log" >&2 || true; done fi } -report 'build (tsc -b, tsdown)' "$build_status" "$scratch/logs/tsc.log" "$scratch/logs/tsdown.log" +report 'build (contract prepass, tsc, tsdown)' "$build_status" \ + "$scratch/logs/contracts-tsc.log" \ + "$scratch/logs/contracts-tsdown.log" \ + "$scratch/logs/host-tsc.log" \ + "$scratch/logs/client-tsc.log" \ + "$scratch/logs/tsdown.log" report 'production site (vitepress build)' "$site_status" "$scratch/logs/site.log" if (( build_status != 0 )); then exit "$build_status"; fi exit "$site_status" From 61c2c15dc46187ec44a8737781e38e656233352c Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:02:10 +0800 Subject: [PATCH 18/63] refactor(goal): own direct goal operations --- packages/goal/goal/src/domain.ts | 29 +------------ packages/goal/goal/src/index.ts | 71 ++++++++++++++++++++++++++++++-- packages/goal/goal/src/types.ts | 32 ++++++++++++++ 3 files changed, 100 insertions(+), 32 deletions(-) diff --git a/packages/goal/goal/src/domain.ts b/packages/goal/goal/src/domain.ts index fec44de2f3..8c8c4de6b6 100644 --- a/packages/goal/goal/src/domain.ts +++ b/packages/goal/goal/src/domain.ts @@ -8,22 +8,7 @@ */ import type { Agent } from '@deepseek-ai/dsh-agent' -import type { GoalId, GoalRef, GoalSnapshot } from './types.ts' - -/** Whether this live process may automatically continue an active goal. */ -export type GoalActivation = 'armed' | 'disarmed' - -/** Current goal projection, including values derived from the session log. */ -export interface GoalView extends GoalSnapshot { - /** Highest admitted round number for this goal. */ - readonly roundsStarted: number - /** Epoch milliseconds of the create mutation. */ - readonly createdAt: number - /** Epoch milliseconds of the latest mutation. */ - readonly updatedAt: number - /** Process-local continuation eligibility; never persisted. */ - readonly activation: GoalActivation -} +import type { GoalId, GoalRef, GoalSnapshot, GoalView } from './types.ts' /** Goal state-changing verbs recorded in the durable source change. */ export type GoalOperation = @@ -96,18 +81,6 @@ export interface FoldedGoal { readonly lastRef?: GoalRef } -/** Input whose omitted round cap is resolved by the service configuration. */ -export interface CreateGoalRequest { - readonly objective: string - readonly maxGoalRounds?: number -} - -/** Fields changed by an edit; at least one must be present. */ -export interface EditGoalRequest { - readonly objective?: string - readonly maxGoalRounds?: number -} - /** Live notification after one durable goal mutation commits. */ export interface GoalChanged { readonly operation: GoalOperation diff --git a/packages/goal/goal/src/index.ts b/packages/goal/goal/src/index.ts index 1cd3c6074a..87ea52018e 100644 --- a/packages/goal/goal/src/index.ts +++ b/packages/goal/goal/src/index.ts @@ -27,22 +27,23 @@ import { GoalId, } from './runtime.ts' import type { + CreateGoalRequest, + CreateGoalResult, + EditGoalRequest, + GoalActivation, GoalBlockReason, GoalPhase, GoalProjection, GoalRef, GoalSnapshot, + GoalView, } from './types.ts' import type { - CreateGoalRequest, - EditGoalRequest, - GoalActivation, GoalChangeMeta, GoalChanged, GoalClearChangeMeta, GoalOperation, GoalSnapshotChangeMeta, - GoalView, } from './domain.ts' // The pure payload outlet (./types.ts, ONE home of the `goal` projection-key @@ -568,6 +569,68 @@ export class GoalService extends Service { activation: cache.activation, } } + + /** + * Create one Goal through the remote boundary. + * @param agent - exact live Agent resolved from the wire identity. + * @param request - objective and optional round cap. + * @returns the created Goal identity. + */ + remoteExportCreate(agent: Agent, request: CreateGoalRequest): CreateGoalResult { + const view = this.create(agent, request) + return { ref: { id: view.id, revision: view.revision } } + } + + /** + * Edit one Goal through the remote boundary. + * @param agent - exact live Agent resolved from the wire identity. + * @param ref - expected current revision. + * @param request - replacement fields. + * @returns the edited Goal view. + */ + remoteExportEdit(agent: Agent, ref: GoalRef, request: EditGoalRequest): GoalView { + return this.edit(agent, ref, request) + } + + /** + * Pause one Goal through the remote boundary. + * @param agent - exact live Agent resolved from the wire identity. + * @param ref - expected current revision. + * @returns the paused Goal view. + */ + remoteExportPause(agent: Agent, ref: GoalRef): GoalView { + return this.pause(agent, ref) + } + + /** + * Resume one Goal through the remote boundary. + * @param agent - exact live Agent resolved from the wire identity. + * @param ref - expected current revision. + * @returns the resumed Goal view. + */ + remoteExportResume(agent: Agent, ref: GoalRef): GoalView { + return this.resume(agent, ref) + } + + /** + * Complete one Goal through the remote boundary. + * @param agent - exact live Agent resolved from the wire identity. + * @param ref - expected current revision. + * @returns the completed Goal view. + */ + remoteExportComplete(agent: Agent, ref: GoalRef): GoalView { + return this.complete(agent, ref) + } + + /** + * Clear one terminal Goal through the remote boundary. + * @param agent - exact live Agent resolved from the wire identity. + * @param ref - expected current revision. + * @returns the committed clear revision. + */ + remoteExportClear(agent: Agent, ref: GoalRef): GoalRef { + return this.clear(agent, ref) + } } export default GoalService diff --git a/packages/goal/goal/src/types.ts b/packages/goal/goal/src/types.ts index 25e22bd5b2..f277e8620c 100644 --- a/packages/goal/goal/src/types.ts +++ b/packages/goal/goal/src/types.ts @@ -23,6 +23,23 @@ export interface GoalRef { readonly revision: number } +/** Input whose omitted round cap is resolved by the service configuration. */ +export interface CreateGoalRequest { + readonly objective: string + readonly maxGoalRounds?: number +} + +/** Wire-safe acknowledgement of one created goal. */ +export interface CreateGoalResult { + readonly ref: GoalRef +} + +/** Fields changed by an edit; at least one must be present. */ +export interface EditGoalRequest { + readonly objective?: string + readonly maxGoalRounds?: number +} + /** Durable continuation phase. Activation is process-local and separate. */ export type GoalPhase = | 'active' @@ -50,6 +67,21 @@ export interface GoalSnapshot extends GoalRef { readonly maxGoalRounds: number } +/** Whether this live process may automatically continue an active goal. */ +export type GoalActivation = 'armed' | 'disarmed' + +/** Current goal projection, including values derived from the session log. */ +export interface GoalView extends GoalSnapshot { + /** Highest admitted round number for this goal. */ + readonly roundsStarted: number + /** Epoch milliseconds of the create mutation. */ + readonly createdAt: number + /** Epoch milliseconds of the latest mutation. */ + readonly updatedAt: number + /** Process-local continuation eligibility; never persisted. */ + readonly activation: GoalActivation +} + /** * The `goal` projection value: the current durable goal with its replay * counters, exactly as the latest `goal/change` event carried them. From 9400926bdfe8320726e64664a36a3cbde6b21b59 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 6 Aug 2026 15:28:54 +0800 Subject: [PATCH 19/63] feat(goal): add TypeRT gateway example --- docs/config-catalog.md | 3 +- docs/cordis-catalog/events.md | 2 +- docs/cordis-catalog/services.md | 53 ++- docs/event-producer-consumer.md | 2 +- docs/module-graph.md | 312 +++++++++++------- docs/persistence-catalog.md | 2 +- knip.json | 9 + packages/bundle/web-app/cordis.patch.yml | 3 + packages/bundle/web-app/package.json | 1 + packages/client/remotes/README.i18n.yaml | 6 + packages/client/remotes/README.md | 22 ++ packages/client/remotes/README.zh.md | 22 ++ packages/client/remotes/package.json | 55 +++ packages/client/remotes/src/client/index.ts | 19 ++ packages/client/remotes/src/index.ts | 4 + packages/client/remotes/src/invariant.ts | 24 ++ .../client/remotes/tests/built-lib.e2e.ts | 214 ++++++++++++ packages/client/remotes/tsconfig.json | 30 ++ packages/client/remotes/tsdown.config.ts | 3 + packages/client/runtime/package.json | 10 +- .../client/runtime/src/client/agents/scope.ts | 18 +- .../runtime/src/client/contract/sessions.ts | 6 +- packages/client/runtime/src/client/index.ts | 18 +- .../runtime/src/client/sessions/service.ts | 8 +- .../client/runtime/tests/client-apply.spec.ts | 3 + packages/client/runtime/tsconfig.json | 9 + packages/client/test-runtime/src/sessions.ts | 8 +- .../cordis/tool-cordis/src/api-catalog.ts | 28 ++ packages/goal/goal/package.json | 18 +- packages/goal/goal/src/index.ts | 10 + packages/goal/goal/tsconfig.json | 3 + pnpm-lock.yaml | 30 ++ scripts/gen-cordis-catalog.ts | 1 + scripts/type-equiv.manifest.json | 6 +- .../verify-package-readme-model-experience.ts | 1 + tsconfig.client.json | 1 + 36 files changed, 809 insertions(+), 155 deletions(-) create mode 100644 packages/client/remotes/README.i18n.yaml create mode 100644 packages/client/remotes/README.md create mode 100644 packages/client/remotes/README.zh.md create mode 100644 packages/client/remotes/package.json create mode 100644 packages/client/remotes/src/client/index.ts create mode 100644 packages/client/remotes/src/index.ts create mode 100644 packages/client/remotes/src/invariant.ts create mode 100644 packages/client/remotes/tests/built-lib.e2e.ts create mode 100644 packages/client/remotes/tsconfig.json create mode 100644 packages/client/remotes/tsdown.config.ts diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 5728ac4bed..08f26479ca 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -495,7 +495,7 @@ export interface Config { } ``` -Source: [`packages/goal/goal/src/index.ts:114`](../packages/goal/goal/src/index.ts) +Source: [`packages/goal/goal/src/index.ts:116`](../packages/goal/goal/src/index.ts) ## `@deepseek-ai/dsh-headless` @@ -2522,6 +2522,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-agent` ([`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts)) - `@deepseek-ai/dsh-client-locale` ([`packages/client/locale/src/index.ts`](../packages/client/locale/src/index.ts)) - `@deepseek-ai/dsh-client-modules` — requires `httpServer` · `loader` ([`packages/client/modules/src/index.ts`](../packages/client/modules/src/index.ts)) +- `@deepseek-ai/dsh-client-remotes` ([`packages/client/remotes/src/index.ts`](../packages/client/remotes/src/index.ts)) - `@deepseek-ai/dsh-client-runtime` ([`packages/client/runtime/src/index.ts`](../packages/client/runtime/src/index.ts)) - `@deepseek-ai/dsh-client-ui-command` ([`packages/client/ui-command/src/index.ts`](../packages/client/ui-command/src/index.ts)) - `@deepseek-ai/dsh-client-ui-conversation` ([`packages/client/ui-conversation/src/index.ts`](../packages/client/ui-conversation/src/index.ts)) diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 348d334e9f..4ad9797262 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -472,7 +472,7 @@ Goal mutation accepted by one live agent. The matching `goal/change` session eve Types: [Agent](../core-data-structures/core.md) · [GoalChanged](../core-data-structures/goal.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/goal/goal/src/domain.ts:141`](../../packages/goal/goal/src/domain.ts) +Source: [`packages/goal/goal/src/domain.ts:114`](../../packages/goal/goal/src/domain.ts) ## `llm/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 41059aebf4..9f5e66ea36 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -761,11 +761,60 @@ block(agent: Agent, ref: GoalRef, reason: GoalBlockReason): GoalView * @returns the tombstone ref whose revision is one past the cleared snapshot. */ clear(agent: Agent, ref: GoalRef): GoalRef + +/** + * Create one Goal through the remote boundary. + * @param agent - exact live Agent resolved from the wire identity. + * @param request - objective and optional round cap. + * @returns the created Goal identity. + */ +@Remote('create') remoteExportCreate(agent: Agent, request: CreateGoalRequest): CreateGoalResult + +/** + * Edit one Goal through the remote boundary. + * @param agent - exact live Agent resolved from the wire identity. + * @param ref - expected current revision. + * @param request - replacement fields. + * @returns the edited Goal view. + */ +@Remote('edit') remoteExportEdit(agent: Agent, ref: GoalRef, request: EditGoalRequest): GoalView + +/** + * Pause one Goal through the remote boundary. + * @param agent - exact live Agent resolved from the wire identity. + * @param ref - expected current revision. + * @returns the paused Goal view. + */ +@Remote('pause') remoteExportPause(agent: Agent, ref: GoalRef): GoalView + +/** + * Resume one Goal through the remote boundary. + * @param agent - exact live Agent resolved from the wire identity. + * @param ref - expected current revision. + * @returns the resumed Goal view. + */ +@Remote('resume') remoteExportResume(agent: Agent, ref: GoalRef): GoalView + +/** + * Complete one Goal through the remote boundary. + * @param agent - exact live Agent resolved from the wire identity. + * @param ref - expected current revision. + * @returns the completed Goal view. + */ +@Remote('complete') remoteExportComplete(agent: Agent, ref: GoalRef): GoalView + +/** + * Clear one terminal Goal through the remote boundary. + * @param agent - exact live Agent resolved from the wire identity. + * @param ref - expected current revision. + * @returns the committed clear revision. + */ +@Remote('clear') remoteExportClear(agent: Agent, ref: GoalRef): GoalRef ``` -Types: [Agent](../core-data-structures/core.md) · [CreateGoalRequest](../core-data-structures/goal.md) · [EditGoalRequest](../core-data-structures/goal.md) · [GoalBlockReason](../core-data-structures/goal.md) · [GoalRef](../core-data-structures/goal.md) · [GoalView](../core-data-structures/goal.md) +Types: [Agent](../core-data-structures/core.md) · [CreateGoalRequest](../core-data-structures/goal.md) · [CreateGoalResult](../core-data-structures/goal.md) · [EditGoalRequest](../core-data-structures/goal.md) · [GoalBlockReason](../core-data-structures/goal.md) · [GoalRef](../core-data-structures/goal.md) · [GoalView](../core-data-structures/goal.md) -Source: [`packages/goal/goal/src/index.ts:181`](../../packages/goal/goal/src/index.ts) +Source: [`packages/goal/goal/src/index.ts:183`](../../packages/goal/goal/src/index.ts) ## `ctx.httpServer` — `HttpServerService` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 286c0ee4c2..f5b3a0b99a 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 | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:62`](../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-policy`](../packages/fs/fs-policy) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:71`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-policy`](../packages/fs/fs-policy), [`skill-local`](../packages/skill/skill-local) | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:54`](../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-policy`](../packages/fs/fs-policy) | -| `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:141`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) | +| `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) | | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/index.ts:73`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | `apiproxy`, [`llm`](../packages/llm/llm) | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:62`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) | | `session/created` | `emit` | [`packages/core/session/src/index.ts:73`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | diff --git a/docs/module-graph.md b/docs/module-graph.md index fd9ac036a0..f659e61206 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -156,6 +156,7 @@ flowchart TD pkg_client_hmr["client-hmr"] pkg_client_locale["client-locale"] pkg_client_modules["client-modules"] + pkg_client_remotes["client-remotes"] pkg_client_runtime["client-runtime"] pkg_client_schema_form["client-schema-form"] pkg_client_test_runtime["client-test-runtime"] @@ -300,7 +301,6 @@ flowchart TD pkg_loader_smoke --> pkg_invariants pkg_base --> pkg_invariants pkg_client_modules --> pkg_invariants - pkg_client_runtime --> pkg_invariants pkg_client_schema_form --> pkg_invariants pkg_client_ui_primitives --> pkg_invariants pkg_client_ui_slots --> pkg_invariants @@ -324,22 +324,6 @@ flowchart TD pkg_client_hmr --> pkg_client_modules pkg_client_hmr --> pkg_host_webserver pkg_client_hmr --> pkg_invariants - pkg_client_locale --> pkg_client_runtime - pkg_client_locale --> pkg_client_ui_primitives - pkg_client_locale --> pkg_client_ui_slots - pkg_client_locale --> pkg_invariants - pkg_client_test_runtime --> pkg_client_runtime - pkg_client_test_runtime --> pkg_client_ui_slots - pkg_client_test_runtime --> pkg_client_web_react - pkg_client_test_runtime --> pkg_host_apiproxy - pkg_client_test_runtime --> pkg_invariants - pkg_client_ui_settings --> pkg_client_runtime - pkg_client_ui_settings --> pkg_client_ui_primitives - pkg_client_ui_settings --> pkg_client_ui_slots - pkg_client_ui_settings --> pkg_invariants - pkg_client_ui_trajectory --> pkg_client_runtime - pkg_client_ui_trajectory --> pkg_client_ui_primitives - pkg_client_ui_trajectory --> pkg_invariants pkg_credentials --> pkg_brand pkg_credentials --> pkg_invariants pkg_frontend_static --> pkg_host_webserver @@ -383,43 +367,6 @@ flowchart TD pkg_system_prompt --> pkg_scope pkg_web --> pkg_invariants pkg_web --> pkg_llm - pkg_client_ui_models --> pkg_client_connection - pkg_client_ui_models --> pkg_client_runtime - pkg_client_ui_models --> pkg_client_schema_form - pkg_client_ui_models --> pkg_client_ui_primitives - pkg_client_ui_models --> pkg_client_ui_slots - pkg_client_ui_models --> pkg_client_web_react - pkg_client_ui_models --> pkg_invariants - pkg_client_ui_question --> pkg_client_locale - pkg_client_ui_question --> pkg_invariants - pkg_client_ui_settings_general --> pkg_client_connection - pkg_client_ui_settings_general --> pkg_client_locale - pkg_client_ui_settings_general --> pkg_client_runtime - pkg_client_ui_settings_general --> pkg_client_ui_primitives - pkg_client_ui_settings_general --> pkg_client_ui_settings - pkg_client_ui_settings_general --> pkg_client_ui_slots - pkg_client_ui_settings_general --> pkg_client_web_react - pkg_client_ui_settings_general --> pkg_invariants - pkg_client_ui_sidebar --> pkg_client_locale - pkg_client_ui_sidebar --> pkg_client_runtime - pkg_client_ui_sidebar --> pkg_client_ui_primitives - pkg_client_ui_sidebar --> pkg_client_ui_slots - pkg_client_ui_sidebar --> pkg_invariants - pkg_client_ui_slash --> pkg_client_locale - pkg_client_ui_slash --> pkg_client_runtime - pkg_client_ui_slash --> pkg_client_ui_primitives - pkg_client_ui_slash --> pkg_client_ui_slots - pkg_client_ui_slash --> pkg_invariants - pkg_client_ui_theme --> pkg_client_locale - pkg_client_ui_theme --> pkg_client_runtime - pkg_client_ui_theme --> pkg_client_ui_primitives - pkg_client_ui_theme --> pkg_client_ui_slots - pkg_client_ui_theme --> pkg_invariants - pkg_client_ui_workspace --> pkg_client_locale - pkg_client_ui_workspace --> pkg_client_runtime - pkg_client_ui_workspace --> pkg_client_ui_primitives - pkg_client_ui_workspace --> pkg_client_ui_slots - pkg_client_ui_workspace --> pkg_invariants pkg_credentials_local --> pkg_atomic_write pkg_credentials_local --> pkg_credentials pkg_credentials_local --> pkg_invariants @@ -472,24 +419,17 @@ flowchart TD pkg_app_boot --> pkg_invariants pkg_app_boot --> pkg_paths pkg_app_boot --> pkg_system_prompt +<<<<<<< HEAD pkg_client_ui_layout --> pkg_client_runtime pkg_client_ui_layout --> pkg_client_ui_slots pkg_client_ui_layout --> pkg_client_ui_theme pkg_client_ui_layout --> pkg_invariants +======= +>>>>>>> d6e02ebbcc (feat(goal): add TypeRT gateway example) pkg_code_runtime_worker --> pkg_code_runtime pkg_code_runtime_worker --> pkg_invariants pkg_code_runtime_worker --> pkg_session pkg_code_runtime_worker --> pkg_timeout - pkg_host_directory_picker_browse --> pkg_client_locale - pkg_host_directory_picker_browse --> pkg_client_runtime - pkg_host_directory_picker_browse --> pkg_client_ui_primitives - pkg_host_directory_picker_browse --> pkg_client_ui_slots - pkg_host_directory_picker_browse --> pkg_client_ui_workspace - pkg_host_directory_picker_browse --> pkg_invariants - pkg_host_directory_picker_native --> pkg_client_runtime - pkg_host_directory_picker_native --> pkg_client_ui_slots - pkg_host_directory_picker_native --> pkg_client_ui_workspace - pkg_host_directory_picker_native --> pkg_invariants pkg_lsp_local --> pkg_brand pkg_lsp_local --> pkg_invariants pkg_lsp_local --> pkg_llm @@ -518,6 +458,7 @@ flowchart TD pkg_goal --> pkg_scope pkg_goal --> pkg_session pkg_goal --> pkg_session_projection + pkg_goal --> pkg_type_meta pkg_bash_local --> pkg_bash pkg_bash_local --> pkg_invariants pkg_bash_local --> pkg_subprocess @@ -582,10 +523,6 @@ flowchart TD pkg_tmux_context --> pkg_bash pkg_tmux_context --> pkg_invariants pkg_tmux_context --> pkg_session - pkg_host_directory_picker_auto --> pkg_host_directory_picker_browse - pkg_host_directory_picker_auto --> pkg_host_directory_picker_native - pkg_host_directory_picker_auto --> pkg_host_webserver - pkg_host_directory_picker_auto --> pkg_invariants pkg_pty --> pkg_agent pkg_pty --> pkg_brand pkg_pty --> pkg_invariants @@ -677,6 +614,7 @@ flowchart TD pkg_permission --> pkg_session_projection pkg_permission --> pkg_settings pkg_permission --> pkg_user_approval +<<<<<<< HEAD pkg_client_ui_conversation --> pkg_client_locale pkg_client_ui_conversation --> pkg_client_runtime pkg_client_ui_conversation --> pkg_client_ui_primitives @@ -687,6 +625,11 @@ flowchart TD pkg_command_feedback --> pkg_commands pkg_command_feedback --> pkg_invariants pkg_command_feedback --> pkg_session +======= + pkg_client_remotes --> pkg_goal + pkg_client_remotes --> pkg_host_api_gateway + pkg_client_remotes --> pkg_invariants +>>>>>>> d6e02ebbcc (feat(goal): add TypeRT gateway example) pkg_pty_local --> pkg_agent pkg_pty_local --> pkg_invariants pkg_pty_local --> pkg_pty @@ -840,6 +783,7 @@ flowchart TD pkg_tool_ask_user --> pkg_invariants pkg_tool_ask_user --> pkg_tools pkg_tool_ask_user --> pkg_user_interaction +<<<<<<< HEAD pkg_client_ui_command --> pkg_client_connection pkg_client_ui_command --> pkg_client_locale pkg_client_ui_command --> pkg_client_runtime @@ -869,6 +813,12 @@ flowchart TD pkg_client_ui_skill --> pkg_client_ui_slash pkg_client_ui_skill --> pkg_client_ui_slots pkg_client_ui_skill --> pkg_invariants +======= + pkg_client_runtime --> pkg_client_remotes + pkg_client_runtime --> pkg_invariants + pkg_client_runtime --> pkg_type_meta + pkg_client_runtime --> pkg_typert_registry +>>>>>>> d6e02ebbcc (feat(goal): add TypeRT gateway example) pkg_session_reference --> pkg_agent pkg_session_reference --> pkg_compact pkg_session_reference --> pkg_invariants @@ -1000,42 +950,29 @@ flowchart TD pkg_web_app --> pkg_bash_env pkg_web_app --> pkg_invariants pkg_web_app --> pkg_system_prompt - pkg_client_ui_model --> pkg_client_connection - pkg_client_ui_model --> pkg_client_locale - pkg_client_ui_model --> pkg_client_runtime - pkg_client_ui_model --> pkg_client_ui_command - pkg_client_ui_model --> pkg_client_ui_conversation - pkg_client_ui_model --> pkg_client_ui_primitives - pkg_client_ui_model --> pkg_client_ui_slash - pkg_client_ui_model --> pkg_client_ui_slots - pkg_client_ui_model --> pkg_invariants - pkg_client_ui_permission --> pkg_client_connection - pkg_client_ui_permission --> pkg_client_locale - pkg_client_ui_permission --> pkg_client_runtime - pkg_client_ui_permission --> pkg_client_schema_form - pkg_client_ui_permission --> pkg_client_ui_command - pkg_client_ui_permission --> pkg_client_ui_primitives - pkg_client_ui_permission --> pkg_client_ui_slash - pkg_client_ui_permission --> pkg_client_ui_slots - pkg_client_ui_permission --> pkg_invariants - pkg_client_ui_permission --> pkg_permission - pkg_client_ui_plan --> pkg_client_connection - pkg_client_ui_plan --> pkg_client_locale - pkg_client_ui_plan --> pkg_client_runtime - pkg_client_ui_plan --> pkg_client_ui_conversation - pkg_client_ui_plan --> pkg_client_ui_primitives - pkg_client_ui_plan --> pkg_client_ui_slots - pkg_client_ui_plan --> pkg_invariants - pkg_client_ui_plan --> pkg_plan_mode - pkg_client_ui_subagent --> pkg_client_locale - pkg_client_ui_subagent --> pkg_client_runtime - pkg_client_ui_subagent --> pkg_client_ui_conversation - pkg_client_ui_subagent --> pkg_client_ui_primitives - pkg_client_ui_subagent --> pkg_client_ui_slash - pkg_client_ui_subagent --> pkg_client_ui_slots - pkg_client_ui_subagent --> pkg_invariants - pkg_client_ui_subagent --> pkg_subagent - pkg_client_ui_subagent --> pkg_token_meter + pkg_client_locale --> pkg_client_runtime + pkg_client_locale --> pkg_client_ui_primitives + pkg_client_locale --> pkg_client_ui_slots + pkg_client_locale --> pkg_invariants + pkg_client_test_runtime --> pkg_client_runtime + pkg_client_test_runtime --> pkg_client_ui_slots + pkg_client_test_runtime --> pkg_client_web_react + pkg_client_test_runtime --> pkg_host_apiproxy + pkg_client_test_runtime --> pkg_invariants + pkg_client_ui_models --> pkg_client_connection + pkg_client_ui_models --> pkg_client_runtime + pkg_client_ui_models --> pkg_client_schema_form + pkg_client_ui_models --> pkg_client_ui_primitives + pkg_client_ui_models --> pkg_client_ui_slots + pkg_client_ui_models --> pkg_client_web_react + pkg_client_ui_models --> pkg_invariants + pkg_client_ui_settings --> pkg_client_runtime + pkg_client_ui_settings --> pkg_client_ui_primitives + pkg_client_ui_settings --> pkg_client_ui_slots + pkg_client_ui_settings --> pkg_invariants + pkg_client_ui_trajectory --> pkg_client_runtime + pkg_client_ui_trajectory --> pkg_client_ui_primitives + pkg_client_ui_trajectory --> pkg_invariants pkg_sdk_protocol --> pkg_invariants pkg_sdk_protocol --> pkg_llm pkg_sdk_protocol --> pkg_session @@ -1078,6 +1015,36 @@ flowchart TD pkg_jsonrpc --> pkg_sdk_protocol pkg_jsonrpc --> pkg_session pkg_jsonrpc --> pkg_subagent + pkg_client_ui_question --> pkg_client_locale + pkg_client_ui_question --> pkg_invariants + pkg_client_ui_settings_general --> pkg_client_connection + pkg_client_ui_settings_general --> pkg_client_locale + pkg_client_ui_settings_general --> pkg_client_runtime + pkg_client_ui_settings_general --> pkg_client_ui_primitives + pkg_client_ui_settings_general --> pkg_client_ui_settings + pkg_client_ui_settings_general --> pkg_client_ui_slots + pkg_client_ui_settings_general --> pkg_client_web_react + pkg_client_ui_settings_general --> pkg_invariants + pkg_client_ui_sidebar --> pkg_client_locale + pkg_client_ui_sidebar --> pkg_client_runtime + pkg_client_ui_sidebar --> pkg_client_ui_primitives + pkg_client_ui_sidebar --> pkg_client_ui_slots + pkg_client_ui_sidebar --> pkg_invariants + pkg_client_ui_slash --> pkg_client_locale + pkg_client_ui_slash --> pkg_client_runtime + pkg_client_ui_slash --> pkg_client_ui_primitives + pkg_client_ui_slash --> pkg_client_ui_slots + pkg_client_ui_slash --> pkg_invariants + pkg_client_ui_theme --> pkg_client_locale + pkg_client_ui_theme --> pkg_client_runtime + pkg_client_ui_theme --> pkg_client_ui_primitives + pkg_client_ui_theme --> pkg_client_ui_slots + pkg_client_ui_theme --> pkg_invariants + pkg_client_ui_workspace --> pkg_client_locale + pkg_client_ui_workspace --> pkg_client_runtime + pkg_client_ui_workspace --> pkg_client_ui_primitives + pkg_client_ui_workspace --> pkg_client_ui_slots + pkg_client_ui_workspace --> pkg_invariants pkg_agent_spine_demo --> pkg_agent pkg_agent_spine_demo --> pkg_agent_loop pkg_agent_spine_demo --> pkg_bash_env @@ -1111,6 +1078,22 @@ flowchart TD pkg_subagent_dsh_sdk --> pkg_session pkg_subagent_dsh_sdk --> pkg_subagent pkg_subagent_dsh_sdk --> pkg_subprocess + pkg_client_ui_conversation --> pkg_client_locale + pkg_client_ui_conversation --> pkg_client_runtime + pkg_client_ui_conversation --> pkg_client_ui_primitives + pkg_client_ui_conversation --> pkg_client_ui_slash + pkg_client_ui_conversation --> pkg_client_ui_slots + pkg_client_ui_conversation --> pkg_invariants + pkg_client_ui_conversation --> pkg_token_meter + pkg_client_ui_layout --> pkg_client_runtime + pkg_client_ui_layout --> pkg_client_ui_slots + pkg_client_ui_layout --> pkg_client_ui_theme + pkg_client_ui_layout --> pkg_invariants + pkg_client_ui_skill --> pkg_client_connection + pkg_client_ui_skill --> pkg_client_runtime + pkg_client_ui_skill --> pkg_client_ui_slash + pkg_client_ui_skill --> pkg_client_ui_slots + pkg_client_ui_skill --> pkg_invariants pkg_acp_demo --> pkg_acp pkg_acp_demo --> pkg_agent_spine_demo pkg_acp_demo --> pkg_app_boot @@ -1131,6 +1114,72 @@ flowchart TD pkg_cli_demo --> pkg_session_persistence_jsonl pkg_cli_demo --> pkg_tools pkg_cli_demo --> pkg_workspace_context + pkg_host_directory_picker_browse --> pkg_client_locale + pkg_host_directory_picker_browse --> pkg_client_runtime + pkg_host_directory_picker_browse --> pkg_client_ui_primitives + pkg_host_directory_picker_browse --> pkg_client_ui_slots + pkg_host_directory_picker_browse --> pkg_client_ui_workspace + pkg_host_directory_picker_browse --> pkg_invariants + pkg_host_directory_picker_native --> pkg_client_runtime + pkg_host_directory_picker_native --> pkg_client_ui_slots + pkg_host_directory_picker_native --> pkg_client_ui_workspace + pkg_host_directory_picker_native --> pkg_invariants + pkg_client_ui_command --> pkg_client_connection + pkg_client_ui_command --> pkg_client_locale + pkg_client_ui_command --> pkg_client_runtime + pkg_client_ui_command --> pkg_client_ui_conversation + pkg_client_ui_command --> pkg_client_ui_primitives + pkg_client_ui_command --> pkg_client_ui_slash + pkg_client_ui_command --> pkg_client_ui_slots + pkg_client_ui_command --> pkg_invariants + pkg_client_ui_goal --> pkg_client_connection + pkg_client_ui_goal --> pkg_client_locale + pkg_client_ui_goal --> pkg_client_runtime + pkg_client_ui_goal --> pkg_client_ui_conversation + pkg_client_ui_goal --> pkg_client_ui_primitives + pkg_client_ui_goal --> pkg_client_ui_slots + pkg_client_ui_goal --> pkg_goal + pkg_client_ui_goal --> pkg_invariants + pkg_client_ui_plan --> pkg_client_connection + pkg_client_ui_plan --> pkg_client_locale + pkg_client_ui_plan --> pkg_client_runtime + pkg_client_ui_plan --> pkg_client_ui_conversation + pkg_client_ui_plan --> pkg_client_ui_primitives + pkg_client_ui_plan --> pkg_client_ui_slots + pkg_client_ui_plan --> pkg_invariants + pkg_client_ui_plan --> pkg_plan_mode + pkg_client_ui_subagent --> pkg_client_locale + pkg_client_ui_subagent --> pkg_client_runtime + pkg_client_ui_subagent --> pkg_client_ui_conversation + pkg_client_ui_subagent --> pkg_client_ui_primitives + pkg_client_ui_subagent --> pkg_client_ui_slash + pkg_client_ui_subagent --> pkg_client_ui_slots + pkg_client_ui_subagent --> pkg_invariants + pkg_client_ui_subagent --> pkg_subagent + pkg_client_ui_subagent --> pkg_token_meter + pkg_host_directory_picker_auto --> pkg_host_directory_picker_browse + pkg_host_directory_picker_auto --> pkg_host_directory_picker_native + pkg_host_directory_picker_auto --> pkg_host_webserver + pkg_host_directory_picker_auto --> pkg_invariants + pkg_client_ui_model --> pkg_client_connection + pkg_client_ui_model --> pkg_client_locale + pkg_client_ui_model --> pkg_client_runtime + pkg_client_ui_model --> pkg_client_ui_command + pkg_client_ui_model --> pkg_client_ui_conversation + pkg_client_ui_model --> pkg_client_ui_primitives + pkg_client_ui_model --> pkg_client_ui_slash + pkg_client_ui_model --> pkg_client_ui_slots + pkg_client_ui_model --> pkg_invariants + pkg_client_ui_permission --> pkg_client_connection + pkg_client_ui_permission --> pkg_client_locale + pkg_client_ui_permission --> pkg_client_runtime + pkg_client_ui_permission --> pkg_client_schema_form + pkg_client_ui_permission --> pkg_client_ui_command + pkg_client_ui_permission --> pkg_client_ui_primitives + pkg_client_ui_permission --> pkg_client_ui_slash + pkg_client_ui_permission --> pkg_client_ui_slots + pkg_client_ui_permission --> pkg_invariants + pkg_client_ui_permission --> pkg_permission ``` | Package | Group | Depends on | @@ -1149,7 +1198,6 @@ flowchart TD | [`loader-smoke`](../packages/support/loader-smoke) | `support` | [`invariants`](../packages/support/invariants) | | [`base`](../packages/bundle/base) | `bundle` | [`invariants`](../packages/support/invariants) | | [`client-modules`](../packages/client/modules) | `client` | [`invariants`](../packages/support/invariants) | -| [`client-runtime`](../packages/client/runtime) | `client` | [`invariants`](../packages/support/invariants) | | [`client-schema-form`](../packages/client/schema-form) | `client` | [`invariants`](../packages/support/invariants) | | [`client-ui-primitives`](../packages/client/ui-primitives) | `client` | [`invariants`](../packages/support/invariants) | | [`client-ui-slots`](../packages/client/ui-slots) | `client` | [`invariants`](../packages/support/invariants) | @@ -1168,10 +1216,6 @@ flowchart TD | [`llm`](../packages/llm/llm) | `llm` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`timeout`](../packages/util/timeout) | | [`client-connection`](../packages/client/connection) | `client` | [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`client-hmr`](../packages/client/hmr) | `client` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | -| [`client-locale`](../packages/client/locale) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-test-runtime`](../packages/client/test-runtime) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`host-apiproxy`](../packages/host/apiproxy), [`invariants`](../packages/support/invariants) | -| [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`invariants`](../packages/support/invariants) | | [`credentials`](../packages/credentials/credentials) | `credentials` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`frontend-static`](../packages/host/frontend-static) | `host` | [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess) | @@ -1187,13 +1231,6 @@ flowchart TD | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`type-meta`](../packages/typert/type-meta) | | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`web`](../packages/web/web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | -| [`client-ui-models`](../packages/client/ui-models) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-schema-form`](../packages/client/schema-form), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) | -| [`client-ui-question`](../packages/client/ui-question) | `client` | [`client-locale`](../packages/client/locale), [`invariants`](../packages/support/invariants) | -| [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) | -| [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-slash`](../packages/client/ui-slash) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | | [`host-api-gateway`](../packages/host/api-gateway) | `host` | [`client-connection`](../packages/client/connection), [`invariants`](../packages/support/invariants), [`typert-registry`](../packages/typert/registry) | | [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | @@ -1210,16 +1247,17 @@ flowchart TD | [`session-persistence`](../packages/session-persistence/session-persistence) | `session-persistence` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`llm-replay`](../packages/support/llm-replay) | `support` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`app-boot`](../packages/ui/app-boot) | `ui` | [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`system-prompt`](../packages/core/system-prompt) | +<<<<<<< HEAD | [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/support/invariants) | +======= +>>>>>>> d6e02ebbcc (feat(goal): add TypeRT gateway example) | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | -| [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | -| [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | | [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`session-projection`](../packages/session-projection/session-projection) | `session-projection` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`token-meter`](../packages/llm/token-meter) | `llm` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection) | -| [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection) | +| [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection), [`type-meta`](../packages/typert/type-meta) | | [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`pwsh-local`](../packages/bash/pwsh-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | @@ -1237,7 +1275,6 @@ flowchart TD | [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | -| [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`pty`](../packages/pty/pty) | `pty` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`scripts`](../packages/sdk/scripts) | `sdk` | [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants) | @@ -1257,8 +1294,12 @@ flowchart TD | [`session-title-llm`](../packages/session-title/session-title-llm) | `session-title` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`timeout`](../packages/util/timeout) | | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) | | [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection), [`settings`](../packages/settings/settings), [`user-approval`](../packages/ui/user-approval) | +<<<<<<< HEAD | [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`token-meter`](../packages/llm/token-meter) | | [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | +======= +| [`client-remotes`](../packages/client/remotes) | `client` | [`goal`](../packages/goal/goal), [`host-api-gateway`](../packages/host/api-gateway), [`invariants`](../packages/support/invariants) | +>>>>>>> d6e02ebbcc (feat(goal): add TypeRT gateway example) | [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess) | | [`tasks-local`](../packages/tasks/tasks-local) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tasks`](../packages/tasks/tasks), [`timeout`](../packages/util/timeout) | | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | @@ -1284,10 +1325,14 @@ flowchart TD | [`session-title-first-message-llm`](../packages/session-title/session-title-first-message-llm) | `session-title` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`session-title-llm`](../packages/session-title/session-title-llm) | | [`agent-loop-testkit`](../packages/support/agent-loop-testkit) | `support` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-ask-user`](../packages/ui/tool-ask-user) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | +<<<<<<< HEAD | [`client-ui-command`](../packages/client/ui-command) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-deliverables`](../packages/client/ui-deliverables) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | | [`client-ui-skill`](../packages/client/ui-skill) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +======= +| [`client-runtime`](../packages/client/runtime) | `client` | [`client-remotes`](../packages/client/remotes), [`invariants`](../packages/support/invariants), [`type-meta`](../packages/typert/type-meta), [`typert-registry`](../packages/typert/registry) | +>>>>>>> d6e02ebbcc (feat(goal): add TypeRT gateway example) | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) | | [`workspace-context`](../packages/context/workspace-context) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | `guard` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) | @@ -1309,10 +1354,11 @@ flowchart TD | [`repository-plugin`](../packages/cordis/repository-plugin) | `cordis` | [`invariants`](../packages/support/invariants), [`mcp-client`](../packages/mcp/mcp-client), [`paths`](../packages/util/paths), [`skill-local`](../packages/skill/skill-local) | | [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`web-app`](../packages/bundle/web-app) | `bundle` | [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`system-prompt`](../packages/core/system-prompt) | -| [`client-ui-model`](../packages/client/ui-model) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-command`](../packages/client/ui-command), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-permission`](../packages/client/ui-permission) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-schema-form`](../packages/client/schema-form), [`client-ui-command`](../packages/client/ui-command), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`permission`](../packages/ui/permission) | -| [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`plan-mode`](../packages/plan/plan-mode) | -| [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) | +| [`client-locale`](../packages/client/locale) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-test-runtime`](../packages/client/test-runtime) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`host-apiproxy`](../packages/host/apiproxy), [`invariants`](../packages/support/invariants) | +| [`client-ui-models`](../packages/client/ui-models) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-schema-form`](../packages/client/schema-form), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) | +| [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`invariants`](../packages/support/invariants) | | [`sdk-protocol`](../packages/sdk/sdk-protocol) | `sdk` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | @@ -1320,8 +1366,26 @@ flowchart TD | [`subagent-fork`](../packages/subagent/subagent-fork) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | [`subagent-spawn`](../packages/subagent/subagent-spawn) | `subagent` | [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | [`jsonrpc`](../packages/ui/jsonrpc) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/sdk-protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | +| [`client-ui-question`](../packages/client/ui-question) | `client` | [`client-locale`](../packages/client/locale), [`invariants`](../packages/support/invariants) | +| [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) | +| [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-slash`](../packages/client/ui-slash) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`bash-env`](../packages/bash/bash-env), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks-local`](../packages/tasks/tasks-local), [`tool-bash`](../packages/bash/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | | [`sdk-client`](../packages/sdk/sdk-client) | `sdk` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/sdk-protocol), [`session`](../packages/core/session) | | [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/sdk-client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | +| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`token-meter`](../packages/llm/token-meter) | +| [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/support/invariants) | +| [`client-ui-skill`](../packages/client/ui-skill) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`acp-demo`](../packages/examples/acp-demo) | `examples` | [`acp`](../packages/acp/acp), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | | [`cli-demo`](../packages/examples/cli-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | +| [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | +| [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | +| [`client-ui-command`](../packages/client/ui-command) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | +| [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`plan-mode`](../packages/plan/plan-mode) | +| [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) | +| [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | +| [`client-ui-model`](../packages/client/ui-model) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-command`](../packages/client/ui-command), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-permission`](../packages/client/ui-permission) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-schema-form`](../packages/client/schema-form), [`client-ui-command`](../packages/client/ui-command), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`permission`](../packages/ui/permission) | diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 3c037198da..48732dbbb0 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -340,7 +340,7 @@ Source: [`packages/feedback/command-feedback/src/index.ts:24`](../packages/feedb 'goal/change': GoalChangeMeta ``` -Source: [`packages/goal/goal/src/domain.ts:81`](../packages/goal/goal/src/domain.ts) +Source: [`packages/goal/goal/src/domain.ts:66`](../packages/goal/goal/src/domain.ts) ### `hook/*` diff --git a/knip.json b/knip.json index 32c9e20dbf..3ce9a32d99 100644 --- a/knip.json +++ b/knip.json @@ -115,6 +115,15 @@ "tests/**/*.ts" ] }, + "packages/client/remotes": { + "entry": [ + "tests/**/*.e2e.ts" + ], + "project": [ + "src/**/*.ts", + "tests/**/*.ts" + ] + }, "packages/client/ui-primitives": { "entry": [ "tests/**/*.spec.tsx" diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 681f0d5121..001c43948d 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -124,6 +124,9 @@ - id: connection name: '@deepseek-ai/dsh-client-connection' + - id: client-remotes + name: '@deepseek-ai/dsh-client-remotes' + - id: client-runtime name: '@deepseek-ai/dsh-client-runtime' diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index 29eeb24009..89b5e8e2a7 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -36,6 +36,7 @@ "@deepseek-ai/dsh-client-hmr": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-modules": "workspace:^", + "@deepseek-ai/dsh-client-remotes": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-command": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", diff --git a/packages/client/remotes/README.i18n.yaml b/packages/client/remotes/README.i18n.yaml new file mode 100644 index 0000000000..86f2aded18 --- /dev/null +++ b/packages/client/remotes/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/client/remotes/README.md +README.md: e29188b8e3ae5ecefe194f1355558e9bdeaae7dd +README.zh.md: e6425ab190a28e0a38c3713c4e21645789a8f00c diff --git a/packages/client/remotes/README.md b/packages/client/remotes/README.md new file mode 100644 index 0000000000..e29188b8e3 --- /dev/null +++ b/packages/client/remotes/README.md @@ -0,0 +1,22 @@ +# @deepseek-ai/dsh-client-remotes + +English | [中文](README.zh.md) + +Platform-neutral Client facade for Host Remote capabilities selected by this application. Its Client entry imports generated `/remote` artifacts as runtime values, mounts each contribution through `ctx.api`, and re-exports their declaration merges. Client business packages depend on this facade rather than the Host API Gateway or individual Remote runtime entries. + +The current assembly mounts only the Goal Remote contribution. Cordis effect ownership withdraws every contribution when this assembly unloads, while the Client face of `@deepseek-ai/dsh-host-api-gateway` owns descriptor validation, concrete root and scoped methods, invocation, and cancellation. + +This package contains no transport or Host discovery logic. It can be reused by Web or a future TUI Client that provides the same React-free `ctx.api` contract. + +## Model Experience + +None, as this Client assembly selects Remote application methods and registers no model surface. + +#### KV Cache effect + +No direct effect; mounted Host capabilities own any model-visible behavior they trigger. + +## Known Limitations and Deferred Work + +- The capability set is fixed by explicit build-time value imports; the Client does not discover the Host's active Services or Remote definitions at runtime. +- Additional capabilities require an explicit `/remote` value import and mount in this assembly. diff --git a/packages/client/remotes/README.zh.md b/packages/client/remotes/README.zh.md new file mode 100644 index 0000000000..e6425ab190 --- /dev/null +++ b/packages/client/remotes/README.zh.md @@ -0,0 +1,22 @@ +# @deepseek-ai/dsh-client-remotes + +[English](README.md) | 中文 + +为本应用选定的 Host Remote 能力提供平台无关的 Client 外观。其 Client 入口以运行时值形式导入生成的 `/remote` 产物,通过 `ctx.api` 挂载每项贡献,并重新导出对应的声明合并。Client 业务包依赖此外观,而不依赖 Host API Gateway 或单独的 Remote 运行时入口。 + +当前组合仅挂载 Goal Remote 贡献。该组合卸载时,Cordis effect 的所有权机制会撤回所有贡献;`@deepseek-ai/dsh-host-api-gateway` 的 Client 侧负责描述符校验、具体的根级方法和作用域方法、调用与取消。 + +本包不包含传输逻辑或 Host 发现逻辑。Web 和未来的 TUI Client 只要提供同一份不依赖 React 的 `ctx.api` 契约,均可复用本包。 + +## 模型体验 + +无,因为此 Client 组合只选择应用的 Remote 方法,不注册任何模型接口。 + +#### KV Cache 影响 + +无直接影响;其触发的任何模型可见行为均由已挂载的 Host 能力负责。 + +## 已知限制与暂缓事项 + +- 能力集合由构建时显式导入的值固定确定;Client 不会在运行时发现 Host 中已启用的服务或 Remote 定义。 +- 若要增加能力,必须显式导入相应的 `/remote` 值并在此组合中挂载。 diff --git a/packages/client/remotes/package.json b/packages/client/remotes/package.json new file mode 100644 index 0000000000..ba4e7b6a01 --- /dev/null +++ b/packages/client/remotes/package.json @@ -0,0 +1,55 @@ +{ + "name": "@deepseek-ai/dsh-client-remotes", + "description": "Platform-neutral assembly of explicitly selected Host Remote contributions", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dshClient": { + "inject": [ + "@deepseek-ai/dsh-host-api-gateway" + ], + "platform": "web", + "immediately": true + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "license": "BSD-3-Clause", + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/client.js", + "lib/types/**/*.d.ts" + ], + "peerDependencies": { + "@deepseek-ai/dsh-host-api-gateway": "^0.0.1", + "@deepseek-ai/dsh-goal": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@deepseek-ai/dsh-host-api-gateway": "workspace:^", + "@deepseek-ai/dsh-goal": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/client/remotes/src/client/index.ts b/packages/client/remotes/src/client/index.ts new file mode 100644 index 0000000000..09757b5e9e --- /dev/null +++ b/packages/client/remotes/src/client/index.ts @@ -0,0 +1,19 @@ +/** Platform-neutral assembly of generated Host Remote contributions. */ + +import type { Context } from 'cordis' +import type {} from '@deepseek-ai/dsh-host-api-gateway/client' +import goalsRemote from '@deepseek-ai/dsh-goal/remote' + +export type { ClientApi } from '@deepseek-ai/dsh-host-api-gateway/client' +export type {} from '@deepseek-ai/dsh-goal/remote' + +/** Required service: the typed Client API contribution mount. */ +export const inject = ['api'] + +/** + * Mount the Host capabilities explicitly selected for this Client assembly. + * @param ctx - Client Cordis root carrying the typed API service. + */ +export function apply(ctx: Context): void { + ctx.api.mount(goalsRemote) +} diff --git a/packages/client/remotes/src/index.ts b/packages/client/remotes/src/index.ts new file mode 100644 index 0000000000..c8c4ff20be --- /dev/null +++ b/packages/client/remotes/src/index.ts @@ -0,0 +1,4 @@ +/** Host Loader entry for the Client Remote contribution assembly. */ + +/** Host plugin body; the selected contributions mount only in Client environments. */ +export function apply(): void {} diff --git a/packages/client/remotes/src/invariant.ts b/packages/client/remotes/src/invariant.ts new file mode 100644 index 0000000000..1a6b0ba237 --- /dev/null +++ b/packages/client/remotes/src/invariant.ts @@ -0,0 +1,24 @@ +/** Package-owned invariant companion for `@deepseek-ai/dsh-client-remotes`. */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-client-remotes' + +/** Cordis companion plugin name. */ +export const name = 'client-remotes-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** No runtime invariant: the API service owns contribution and method lifecycle atomically. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/client/remotes/tests/built-lib.e2e.ts b/packages/client/remotes/tests/built-lib.e2e.ts new file mode 100644 index 0000000000..bbba218844 --- /dev/null +++ b/packages/client/remotes/tests/built-lib.e2e.ts @@ -0,0 +1,214 @@ +import { execFile } from 'node:child_process' +import { existsSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { describe, expect, it } from 'vitest' + +/** + * Built-artifact smoke for the first generated Remote: plain Node boots the + * Host and Browser bundle handoffs, then crosses the real `/api2` HTTP route. + */ + +const packageDir = fileURLToPath(new URL('..', import.meta.url)) +const root = resolve(packageDir, '../../..') +const artifact = (path: string): string => join(root, path) +const artifactUrl = (path: string): string => pathToFileURL(artifact(path)).href + +const requiredArtifacts = [ + 'packages/client/connection/lib/client.js', + 'packages/client/connection/lib/index.js', + 'packages/client/remotes/lib/client.js', + 'packages/core/agent/lib/index.js', + 'packages/core/session/lib/index.js', + 'packages/goal/goal/lib/index.js', + 'packages/goal/goal/lib/typert.host.js', + 'packages/host/api-gateway/lib/client.js', + 'packages/host/api-gateway/lib/index.js', + 'packages/typert/registry/lib/client.js', + 'packages/typert/registry/lib/index.js', +].every(path => existsSync(artifact(path))) + +describe.skipIf(!requiredArtifacts)('Goal Remote built LIB chain', () => { + it('runs root and Agent-scoped calls through generated bundles and real HTTP', async () => { + const urls = Object.fromEntries(Object.entries({ + agent: 'packages/core/agent/lib/index.js', + apiGatewayClient: 'packages/host/api-gateway/lib/client.js', + apiGatewayHost: 'packages/host/api-gateway/lib/index.js', + connectionClient: 'packages/client/connection/lib/client.js', + connectionHost: 'packages/client/connection/lib/index.js', + goal: 'packages/goal/goal/lib/index.js', + goalTypert: 'packages/goal/goal/lib/typert.host.js', + registryClient: 'packages/typert/registry/lib/client.js', + registryHost: 'packages/typert/registry/lib/index.js', + remotesClient: 'packages/client/remotes/lib/client.js', + session: 'packages/core/session/lib/index.js', + }).map(([key, path]) => [key, artifactUrl(path)])) + const script = ` + import { createServer } from 'node:http' + import * as cordis from 'cordis' + + const urls = ${JSON.stringify(urls)} + const { Context } = cordis + const { default: AgentRegistry } = await import(urls.agent) + const connectionHost = await import(urls.connectionHost) + const { default: TypertGatewayService } = await import(urls.apiGatewayHost) + const { default: GoalService } = await import(urls.goal) + const { TYPERT } = await import(urls.goalTypert) + const { default: TypertRegistry } = await import(urls.registryHost) + const { Session, SessionId } = await import(urls.session) + + const routes = [] + const host = new Context() + host.provide('httpServer', { + register(route) { + routes.push(route) + return () => { routes.splice(routes.indexOf(route), 1) } + }, + tapIndex() { return () => {} }, + port: 0, + }) + await host.plugin({ inject: connectionHost.inject, apply: connectionHost.apply }) + await host.plugin(TypertRegistry) + await host.plugin(AgentRegistry) + await host.plugin(TypertGatewayService) + await host.plugin(GoalService) + host.typert.register(TYPERT) + + const makeAgent = rawId => { + const session = new Session(SessionId(rawId)) + return { + id: session.id, + options: {}, + session, + ctx: host.extend(), + status: 'idle', + acceptsNextStep: false, + send() {}, + updateInbox() { return 'not-found' }, + followup() {}, + steer() { return { outcome: Promise.resolve({ status: 'rejected' }) } }, + inject(input) { session.append('user/message', input, { surfaceOp: 'append' }) }, + reserveTurnAdmission() {}, + cancel() {}, + whenIdle() { return Promise.resolve() }, + } + } + const rootAgent = makeAgent('built-root-agent') + const scopedAgent = makeAgent('built-scoped-agent') + host.agents.register(rootAgent) + host.agents.register(scopedAgent) + + if (routes.length !== 1) throw new Error('Gateway did not register exactly one /api2 route') + const server = createServer((request, response) => { void routes[0].handler(request, response) }) + await new Promise(resolveListen => server.listen(0, '127.0.0.1', resolveListen)) + const address = server.address() + if (address === null || typeof address === 'string') throw new Error('HTTP server has no TCP address') + const origin = 'http://127.0.0.1:' + String(address.port) + + const handoffs = new Map() + globalThis.window = { + __ModuleLoader__: { + load(handoff) { handoffs.set(handoff.id, handoff) }, + }, + } + globalThis.location = { hostname: '127.0.0.1', origin, search: '' } + await import(urls.registryClient) + await import(urls.connectionClient) + await import(urls.apiGatewayClient) + await import(urls.remotesClient) + + const instantiate = id => { + const handoff = handoffs.get(id) + if (handoff === undefined) throw new Error('missing Client bundle handoff ' + id) + return handoff.factory(specifier => { + if (specifier === 'cordis') return cordis + throw new Error('unexpected Client external ' + specifier) + }) + } + const client = new Context() + for (const id of [ + '@deepseek-ai/dsh-typert-registry', + '@deepseek-ai/dsh-client-connection', + '@deepseek-ai/dsh-host-api-gateway', + '@deepseek-ai/dsh-client-remotes', + ]) { + const plugin = instantiate(id) + await client.plugin({ inject: plugin.inject, apply: plugin.apply }) + } + client.typert.contexts.registerClient('agent', { + identity: candidate => candidate.builtAgentId, + }) + + let invalidRejected = false + try { + await client.api.goals.create(rootAgent.id, { objective: 1 }) + } catch { + invalidRejected = true + } + const rootResult = await client.api.goals.create(rootAgent.id, { objective: 'root goal' }) + const agentContext = client.extend({ builtAgentId: scopedAgent.id }) + const scopedResult = await agentContext.goals.create({ objective: 'scoped goal', maxGoalRounds: 3 }) + const result = { + invalidRejected, + rootResult, + scopedResult, + rootGoal: host.goals.get(rootAgent)?.objective, + scopedGoal: host.goals.get(scopedAgent)?.objective, + rootEvents: rootAgent.session.events.length, + scopedEvents: scopedAgent.session.events.length, + } + + await client.fiber.dispose() + await new Promise((resolveClose, rejectClose) => server.close(error => { + if (error === undefined) resolveClose() + else rejectClose(error) + })) + await host.fiber.dispose() + console.log(JSON.stringify(result)) + ` + + const result = await runPlainNode(script) + expect(result.exitCode, `stderr:\n${result.stderr}`).toBe(0) + const output = JSON.parse(result.stdout.trim().split('\n').at(-1) ?? '{}') as { + invalidRejected: boolean + rootResult: { ref: { id: string; revision: number } } + scopedResult: { ref: { id: string; revision: number } } + rootGoal: string + scopedGoal: string + rootEvents: number + scopedEvents: number + } + expect(output).toMatchObject({ + invalidRejected: true, + rootResult: { ref: { revision: 1 } }, + scopedResult: { ref: { revision: 1 } }, + rootGoal: 'root goal', + scopedGoal: 'scoped goal', + rootEvents: 1, + scopedEvents: 1, + }) + expect(output.rootResult.ref.id).toMatch(/^goal-/) + expect(output.scopedResult.ref.id).toMatch(/^goal-/) + }, 60_000) +}) + +/** Execute one ESM script without tsx or a TypeScript loader. */ +function runPlainNode(script: string): Promise<{ + readonly exitCode: number | null + readonly stdout: string + readonly stderr: string +}> { + return new Promise((resolveRun) => { + execFile(process.execPath, ['--input-type=module', '-e', script], { + cwd: packageDir, + encoding: 'utf8', + timeout: 55_000, + }, (error, stdout, stderr) => { + resolveRun({ + exitCode: error === null ? 0 : typeof error.code === 'number' ? error.code : null, + stdout, + stderr, + }) + }) + }) +} diff --git a/packages/client/remotes/tsconfig.json b/packages/client/remotes/tsconfig.json new file mode 100644 index 0000000000..c99a5fce19 --- /dev/null +++ b/packages/client/remotes/tsconfig.json @@ -0,0 +1,30 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../host/api-gateway" + }, + { + "path": "../../ui/commands" + }, + { + "path": "../../goal/goal" + }, + { + "path": "../../session-title/session-title" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/client/remotes/tsdown.config.ts b/packages/client/remotes/tsdown.config.ts new file mode 100644 index 0000000000..20fa098462 --- /dev/null +++ b/packages/client/remotes/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-client-remotes', ['lib/types/index.js', 'lib/types/invariant.js']) diff --git a/packages/client/runtime/package.json b/packages/client/runtime/package.json index b636316b68..cc51aa772d 100644 --- a/packages/client/runtime/package.json +++ b/packages/client/runtime/package.json @@ -24,7 +24,9 @@ }, "dshClient": { "inject": [ - "@deepseek-ai/dsh-client-connection" + "@deepseek-ai/dsh-client-connection", + "@deepseek-ai/dsh-client-remotes", + "@deepseek-ai/dsh-typert-registry" ], "platform": "web", "immediately": true @@ -47,11 +49,17 @@ }, "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-client-remotes": "^0.0.1", + "@deepseek-ai/dsh-type-meta": "^0.0.1", + "@deepseek-ai/dsh-typert-registry": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-client-remotes": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", + "@deepseek-ai/dsh-type-meta": "workspace:^", + "@deepseek-ai/dsh-typert-registry": "workspace:^", "@types/react": "~18.3.1", "cordis": "^4.0.0-rc.7" }, diff --git a/packages/client/runtime/src/client/agents/scope.ts b/packages/client/runtime/src/client/agents/scope.ts index af6fa3afcd..ba4fd8ede7 100644 --- a/packages/client/runtime/src/client/agents/scope.ts +++ b/packages/client/runtime/src/client/agents/scope.ts @@ -18,6 +18,7 @@ import { Context as CordisContext } from 'cordis' import type { Context, Fiber } from 'cordis' import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' +import type { TypeRTRemoteContextApi } from '@deepseek-ai/dsh-type-meta' /** Context tag written by {@link createScope}. */ const kScope = Symbol('dsh.client.scope') @@ -29,7 +30,7 @@ export interface AgentScopeHandle { * through it (passing it as the dispatch subject routes to this agent's * tagged listeners plus every untagged one). */ - ctx: Context + ctx: Context & TypeRTRemoteContextApi<'agent'> /** Backing fiber (dispose tears down every scope-owned registration). */ fiber: Fiber } @@ -48,15 +49,16 @@ function agentScope(): void {} */ export function createScope(ctx: Context, key: SessionId): AgentScopeHandle { const fiber = ctx.plugin(agentScope) + const scoped = fiber.ctx.extend({ + [kScope]: key, + [CordisContext.filter](listenerCtx: Context): boolean { + const tag = scopeOf(listenerCtx) + return tag === undefined || tag === key + }, + }) as Context & TypeRTRemoteContextApi<'agent'> return { fiber, - ctx: fiber.ctx.extend({ - [kScope]: key, - [CordisContext.filter](listenerCtx: Context): boolean { - const tag = scopeOf(listenerCtx) - return tag === undefined || tag === key - }, - }), + ctx: scoped, } } diff --git a/packages/client/runtime/src/client/contract/sessions.ts b/packages/client/runtime/src/client/contract/sessions.ts index fbb0bb1a4e..8e9c530720 100644 --- a/packages/client/runtime/src/client/contract/sessions.ts +++ b/packages/client/runtime/src/client/contract/sessions.ts @@ -11,6 +11,7 @@ import type { Context } from 'cordis' import type { RpcResult, SessionId, SubagentAddress, } from '@deepseek-ai/dsh-client-connection/client' +import type { TypeRTRemoteContextApi } from '@deepseek-ai/dsh-type-meta' import type { HostObservable, SessionMaybeProvideInfo } from '@deepseek-ai/dsh-client-ui-slots' import type { SessionSearchResultItem } from '../sessions/manager.ts' import type { @@ -19,6 +20,9 @@ import type { import type { SessionFace } from './session.ts' import type { ObservableSnapshot } from './store.ts' +/** Client Cordis Context carrying one Agent identity and its generated Remote namespaces. */ +export type AgentContext = Context & TypeRTRemoteContextApi<'agent'> + /** The sessions-service face injected as `ctx.sessions`. */ export interface ISessions { /** The useSessions standard feed (list rows + current selection; read face — writes stay inside the domain). */ @@ -95,7 +99,7 @@ export interface ISessions { * @param id - session id. * @returns scoped ctx, or undefined for a session neither listed nor already scoped. */ - scope(id: SessionId): Context | undefined + scope(id: SessionId): AgentContext | undefined /** * Read the Agent scope tag off a context (service-method seam: fetch * bundles must reach scope resolution through ctx.sessions). diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index 06f88a9131..f1efd6a65d 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -1,6 +1,8 @@ /** Browser runtime services for slots, sessions, workspaces, and connection-stream delivery. */ import type { Context } from 'cordis' import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-client-connection/client' +import type {} from '@deepseek-ai/dsh-client-remotes/client' +import type { TypeRTContext } from '@deepseek-ai/dsh-type-meta' import type { MaybeSnapshotSelectorHook, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' import { SlotsService } from './slots.ts' import { SessionsService } from './sessions/service.ts' @@ -26,7 +28,7 @@ export type { ISession, ProjectionsFace, SessionFace } from './contract/session. export type { ISessionHistory, SessionHistoryFace, SessionHistorySnapshot, } from './contract/session-history.ts' -export type { ISessions } from './contract/sessions.ts' +export type { AgentContext, ISessions } from './contract/sessions.ts' export type { IWorkspaces } from './contract/workspaces.ts' export type { SessionBinding, SessionListState, SessionProvideContribution, SessionProvideDescriptor, SessionSummary, @@ -75,6 +77,13 @@ export type { SessionId } from '@deepseek-ai/dsh-client-connection/client' /** Client-side Cordis context after declaration merging. */ export type ClientContext = Context +declare module '@deepseek-ai/dsh-type-meta' { + interface TypeRTContextMap { + /** Client Agent scope identity; the agent and session share one wire id. */ + agent: TypeRTContext + } +} + /** The conversation-snapshot selector hook (ConvViewProps/ToolRowProps take this). */ export type UseConversationSession = SnapshotSelectorHook @@ -170,8 +179,8 @@ declare module 'cordis' { } } -/** Required services: the wire handle mounted by the connection plugin. */ -export const inject = ['connection'] +/** Required services: the typed Remote API, wire handle, and Client TypeRT registry. */ +export const inject = ['api', 'connection', 'typert'] /** Mounts the browser runtime services and connection stream. * @param ctx - Client Cordis context. @@ -180,6 +189,9 @@ export function apply(ctx: Context): void { ctx.plugin(SlotsService) const connection = ctx.get('connection') as ConnectionHandle const sessions = new SessionsService(ctx, connection.api) + ctx.typert.contexts.registerClient('agent', { + identity: candidate => sessions.scopeOf(candidate), + }) const sessionHistory = new SessionHistoryService(ctx, connection.api) const workspaces = new WorkspacesService(ctx, connection.api, sessions) ctx.effect( diff --git a/packages/client/runtime/src/client/sessions/service.ts b/packages/client/runtime/src/client/sessions/service.ts index b1b271e702..621760df02 100644 --- a/packages/client/runtime/src/client/sessions/service.ts +++ b/packages/client/runtime/src/client/sessions/service.ts @@ -29,7 +29,7 @@ import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/t import type { SnapshotStore } from '../contract/store.ts' import { createSnapshotStore } from '../contract/store.ts' import type { SessionFace } from '../contract/session.ts' -import type { ISessions } from '../contract/sessions.ts' +import type { AgentContext, ISessions } from '../contract/sessions.ts' import { createScope, scopeOf as scopeTagOf } from '../agents/scope.ts' import { SessionManager } from './manager.ts' import type { SessionListPhase, SessionSearchResultItem, SubagentCatalogSnapshot } from './manager.ts' @@ -127,7 +127,7 @@ export interface SessionBinding { readonly sessionId: SessionId /** The outward session face only — feature code never sees the concrete class. */ readonly session: SessionFace - readonly ctx: Context + readonly ctx: AgentContext } // Scope primitives live in ../agents/scope.ts (the client mirror of host @@ -182,7 +182,7 @@ function increasedForkTitle(title: string): string { interface ScopeRecord { fiber: Fiber - ctx: Context + ctx: AgentContext binding: SessionBinding /** The concrete Session for runtime-internal entry points (staging open()); the binding carries only the outward face. */ session: Session @@ -483,7 +483,7 @@ export class SessionsService implements ISessions { * @param id - session id (the agent identity — 1:1 same axis). * @returns scoped ctx, or undefined for a session neither listed nor already scoped. */ - scope(id: SessionId): Context | undefined { + scope(id: SessionId): AgentContext | undefined { return this.resolve(id)?.ctx } diff --git a/packages/client/runtime/tests/client-apply.spec.ts b/packages/client/runtime/tests/client-apply.spec.ts index 14e51fae8e..5635793122 100644 --- a/packages/client/runtime/tests/client-apply.spec.ts +++ b/packages/client/runtime/tests/client-apply.spec.ts @@ -8,6 +8,7 @@ import { describe, expect, it } from 'vitest' import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' import type { ConnectionSinks } from '@deepseek-ai/dsh-client-connection/client' import { SESSION_SEARCH_RESULT_LIMIT } from '@deepseek-ai/dsh-host-apiproxy/api' +import TypertRegistry from '@deepseek-ai/dsh-typert-registry' import * as RuntimeClient from '../src/client/index.ts' import type { SessionsService } from '../src/client/sessions/service.ts' import type { WorkspacesService } from '../src/client/workspaces/service.ts' @@ -22,6 +23,7 @@ interface Bench { async function mount(): Promise { const ctx = new Context() + await ctx.plugin(TypertRegistry) const api = new FakeApiClient() const bench: Bench = { ctx, api, sinks: undefined, stopped: 0 } const handle: ConnectionHandle = { @@ -36,6 +38,7 @@ async function mount(): Promise { }, } ctx.reflect.provide('connection', handle) + ctx.reflect.provide('api', {}) await ctx.plugin(RuntimeClient).await() return bench } diff --git a/packages/client/runtime/tsconfig.json b/packages/client/runtime/tsconfig.json index f1512c7059..85ba61d41a 100644 --- a/packages/client/runtime/tsconfig.json +++ b/packages/client/runtime/tsconfig.json @@ -20,6 +20,9 @@ { "path": "../connection" }, + { + "path": "../remotes" + }, { "path": "../../host/apiproxy" }, @@ -43,6 +46,12 @@ }, { "path": "../../support/invariants" + }, + { + "path": "../../typert/type-meta" + }, + { + "path": "../../typert/registry" } ], "exclude": [ diff --git a/packages/client/test-runtime/src/sessions.ts b/packages/client/test-runtime/src/sessions.ts index e313b63fd2..4747929572 100644 --- a/packages/client/test-runtime/src/sessions.ts +++ b/packages/client/test-runtime/src/sessions.ts @@ -3,7 +3,7 @@ import type { Context } from 'cordis' import { createScope, scopeOf, SessionProvideChannel } from '@deepseek-ai/dsh-client-runtime/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import type { - ConversationSnapshot, ISessions, ObservableSnapshot, ProjectionsFace, SessionFace, SessionId, + AgentContext, ConversationSnapshot, ISessions, ObservableSnapshot, ProjectionsFace, SessionFace, SessionId, SessionListState, SessionProvideDescriptor, SessionSearchResultItem, SessionSummary, SnapshotStore, SubagentAddress, } from '@deepseek-ai/dsh-client-runtime/client' @@ -134,7 +134,7 @@ interface SessionRecord { summary: SessionSummary snapshot: SnapshotStore session: FixtureSession - scope: Context | undefined + scope: AgentContext | undefined scopeFiber: { dispose(): Promise } | undefined /** Materialized standard-props bundle (identity-stable per session; invalidated on roster change). */ provideInfo: SessionProvideInfo | undefined @@ -144,7 +144,7 @@ interface SessionRecord { export interface TestSessionBinding { readonly sessionId: SessionId readonly session: FixtureSession - readonly ctx: Context + readonly ctx: AgentContext } /** @@ -345,7 +345,7 @@ export class TestSessions implements ISessions { * @param id - session id. * @returns the scoped context, or undefined for unknown sessions. */ - scope(id: string): Context | undefined { + scope(id: string): AgentContext | undefined { const record = this.records.get(id as SessionId) if (record === undefined) return undefined if (record.scope === undefined) { diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index b8da6049e8..48f4aadeed 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -382,6 +382,30 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'clear(agent: Agent, ref: GoalRef): GoalRef', jsDoc: '/**\n * Clear the current goal while retaining a durable tombstone and history.\n * @param agent - owning live agent.\n * @param ref - expected current revision.\n * @returns the tombstone ref whose revision is one past the cleared snapshot.\n */', }, + { + signature: '@Remote(\'create\') remoteExportCreate(agent: Agent, request: CreateGoalRequest): CreateGoalResult', + jsDoc: '/**\n * Create one Goal through the remote boundary.\n * @param agent - exact live Agent resolved from the wire identity.\n * @param request - objective and optional round cap.\n * @returns the created Goal identity.\n */', + }, + { + signature: '@Remote(\'edit\') remoteExportEdit(agent: Agent, ref: GoalRef, request: EditGoalRequest): GoalView', + jsDoc: '/**\n * Edit one Goal through the remote boundary.\n * @param agent - exact live Agent resolved from the wire identity.\n * @param ref - expected current revision.\n * @param request - replacement fields.\n * @returns the edited Goal view.\n */', + }, + { + signature: '@Remote(\'pause\') remoteExportPause(agent: Agent, ref: GoalRef): GoalView', + jsDoc: '/**\n * Pause one Goal through the remote boundary.\n * @param agent - exact live Agent resolved from the wire identity.\n * @param ref - expected current revision.\n * @returns the paused Goal view.\n */', + }, + { + signature: '@Remote(\'resume\') remoteExportResume(agent: Agent, ref: GoalRef): GoalView', + jsDoc: '/**\n * Resume one Goal through the remote boundary.\n * @param agent - exact live Agent resolved from the wire identity.\n * @param ref - expected current revision.\n * @returns the resumed Goal view.\n */', + }, + { + signature: '@Remote(\'complete\') remoteExportComplete(agent: Agent, ref: GoalRef): GoalView', + jsDoc: '/**\n * Complete one Goal through the remote boundary.\n * @param agent - exact live Agent resolved from the wire identity.\n * @param ref - expected current revision.\n * @returns the completed Goal view.\n */', + }, + { + signature: '@Remote(\'clear\') remoteExportClear(agent: Agent, ref: GoalRef): GoalRef', + jsDoc: '/**\n * Clear one terminal Goal through the remote boundary.\n * @param agent - exact live Agent resolved from the wire identity.\n * @param ref - expected current revision.\n * @returns the committed clear revision.\n */', + }, ], }, { @@ -1859,6 +1883,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'CreateGoalRequest', declaration: 'export interface CreateGoalRequest {\n readonly objective: string;\n readonly maxGoalRounds?: number;\n}', }, + { + name: 'CreateGoalResult', + declaration: 'export interface CreateGoalResult {\n readonly ref: GoalRef;\n}', + }, { name: 'CreateSessionOptions', declaration: 'export interface CreateSessionOptions {\n readonly seed?: readonly SessionEvent[];\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly createdAt?: number;\n readonly seedLength?: number;\n readonly origin?: \'subagent\';\n readonly delegationDepth?: number;\n };\n}', diff --git a/packages/goal/goal/package.json b/packages/goal/goal/package.json index 397e5717ba..fccf7de3be 100644 --- a/packages/goal/goal/package.json +++ b/packages/goal/goal/package.json @@ -23,6 +23,14 @@ "types": "./lib/types/client.d.ts", "default": "./lib/types/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" }, @@ -30,7 +38,13 @@ "lib/index.js", "lib/invariant.js", "lib/types/**/*.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/typert.host.js", + "lib/typert.host.d.ts", + "lib/typert.remote-client.js", + "lib/typert.remote-client.d.ts", + "lib/typert.remote-client.d.ts.map", + "src" ], "license": "BSD-3-Clause", "peerDependencies": { @@ -41,6 +55,7 @@ "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-scope": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-type-meta": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "dependencies": { @@ -56,6 +71,7 @@ "@deepseek-ai/dsh-loader-smoke": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-type-meta": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/goal/goal/src/index.ts b/packages/goal/goal/src/index.ts index 87ea52018e..0997aad0dc 100644 --- a/packages/goal/goal/src/index.ts +++ b/packages/goal/goal/src/index.ts @@ -12,6 +12,7 @@ import type { ZodType } from 'zod' import { agentEvents } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import { Remote, bindTypeRTGateway } from '@deepseek-ai/dsh-type-meta' // Type-only: resolves ctx.sessionProjections for the optional unit child. import type {} from '@deepseek-ai/dsh-session-projection' import { @@ -189,6 +190,9 @@ export class GoalService extends Service { private readonly resolved: ResolvedConfig private readonly caches = new WeakMap() + /** Explicit participation in the TypeRT Gateway under the Cordis service key. */ + readonly typertGateway = bindTypeRTGateway(this, 'goals') + constructor(ctx: Context, config: Config = {}) { super(ctx, 'goals') this.resolved = { @@ -576,6 +580,7 @@ export class GoalService extends Service { * @param request - objective and optional round cap. * @returns the created Goal identity. */ + @Remote('create') remoteExportCreate(agent: Agent, request: CreateGoalRequest): CreateGoalResult { const view = this.create(agent, request) return { ref: { id: view.id, revision: view.revision } } @@ -588,6 +593,7 @@ export class GoalService extends Service { * @param request - replacement fields. * @returns the edited Goal view. */ + @Remote('edit') remoteExportEdit(agent: Agent, ref: GoalRef, request: EditGoalRequest): GoalView { return this.edit(agent, ref, request) } @@ -598,6 +604,7 @@ export class GoalService extends Service { * @param ref - expected current revision. * @returns the paused Goal view. */ + @Remote('pause') remoteExportPause(agent: Agent, ref: GoalRef): GoalView { return this.pause(agent, ref) } @@ -608,6 +615,7 @@ export class GoalService extends Service { * @param ref - expected current revision. * @returns the resumed Goal view. */ + @Remote('resume') remoteExportResume(agent: Agent, ref: GoalRef): GoalView { return this.resume(agent, ref) } @@ -618,6 +626,7 @@ export class GoalService extends Service { * @param ref - expected current revision. * @returns the completed Goal view. */ + @Remote('complete') remoteExportComplete(agent: Agent, ref: GoalRef): GoalView { return this.complete(agent, ref) } @@ -628,6 +637,7 @@ export class GoalService extends Service { * @param ref - expected current revision. * @returns the committed clear revision. */ + @Remote('clear') remoteExportClear(agent: Agent, ref: GoalRef): GoalRef { return this.clear(agent, ref) } diff --git a/packages/goal/goal/tsconfig.json b/packages/goal/goal/tsconfig.json index 9663f894fe..f106707bd3 100644 --- a/packages/goal/goal/tsconfig.json +++ b/packages/goal/goal/tsconfig.json @@ -35,6 +35,9 @@ { "path": "../../session-projection/session-projection" }, + { + "path": "../../typert/type-meta" + }, { "path": "../../support/invariants" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index caf1f8a5ba..d9a4453a61 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1131,6 +1131,9 @@ importers: '@deepseek-ai/dsh-client-modules': specifier: workspace:^ version: link:../../client/modules + '@deepseek-ai/dsh-client-remotes': + specifier: workspace:^ + version: link:../../client/remotes '@deepseek-ai/dsh-client-runtime': specifier: workspace:^ version: link:../../client/runtime @@ -1345,6 +1348,21 @@ importers: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis + packages/client/remotes: + devDependencies: + '@deepseek-ai/dsh-goal': + specifier: workspace:^ + version: link:../../goal/goal + '@deepseek-ai/dsh-host-api-gateway': + specifier: workspace:^ + version: link:../../host/api-gateway + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + cordis: + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + packages/client/runtime: dependencies: '@deepseek-ai/dsh-client-connection': @@ -1387,12 +1405,21 @@ importers: specifier: ~4.4.7 version: 4.4.7(@types/react@18.3.31)(immer@10.2.0)(react@18.3.1) devDependencies: + '@deepseek-ai/dsh-client-remotes': + specifier: workspace:^ + version: link:../remotes '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants '@deepseek-ai/dsh-timeout': specifier: workspace:^ version: link:../../util/timeout + '@deepseek-ai/dsh-type-meta': + specifier: workspace:^ + version: link:../../typert/type-meta + '@deepseek-ai/dsh-typert-registry': + specifier: workspace:^ + version: link:../../typert/registry '@types/react': specifier: ~18.3.1 version: 18.3.31 @@ -3509,6 +3536,9 @@ importers: '@deepseek-ai/dsh-session-projection': specifier: workspace:^ version: link:../../session-projection/session-projection + '@deepseek-ai/dsh-type-meta': + specifier: workspace:^ + version: link:../../typert/type-meta cordis: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 84013225f7..088329e83d 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -90,6 +90,7 @@ export const LINK_MAP: Readonly> = { FsWriteIntent: 'filesystem.md', FsWriteOutcome: 'filesystem.md', CreateGoalRequest: 'goal.md', + CreateGoalResult: 'goal.md', EditGoalRequest: 'goal.md', GoalBlockReason: 'goal.md', GoalChanged: 'goal.md', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 603ad20d8e..84b957e633 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -199,7 +199,7 @@ { "doc": "docs/core-data-structures/goal.md", "symbol": "GoalView", - "source": "packages/goal/goal/src/domain.ts" + "source": "packages/goal/goal/src/types.ts" }, { "doc": "docs/core-data-structures/goal.md", @@ -219,12 +219,12 @@ { "doc": "docs/core-data-structures/goal.md", "symbol": "CreateGoalRequest", - "source": "packages/goal/goal/src/domain.ts" + "source": "packages/goal/goal/src/types.ts" }, { "doc": "docs/core-data-structures/goal.md", "symbol": "EditGoalRequest", - "source": "packages/goal/goal/src/domain.ts" + "source": "packages/goal/goal/src/types.ts" }, { "doc": "docs/core-data-structures/goal.md", diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 7e81b30e07..78745dbed1 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -57,6 +57,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/client/web-react': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, 'packages/client/schema-form': { kind: 'none', reason: 'Browser-side form-rendering library; registers no model surface.' }, 'packages/client/connection': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, + 'packages/client/remotes': { kind: 'none', reason: 'Client-side Remote assembly; selected business methods own any model-visible effect.' }, 'packages/client/runtime': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, 'packages/client/ui-layout': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, 'packages/client/ui-sidebar': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, diff --git a/tsconfig.client.json b/tsconfig.client.json index b0567f762e..327b337963 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -53,6 +53,7 @@ { "path": "./packages/client/connection" }, { "path": "./packages/typert/registry" }, { "path": "./packages/host/api-gateway" }, + { "path": "./packages/client/remotes" }, { "path": "./packages/client/runtime" }, { "path": "./packages/client/test-runtime" }, { "path": "./packages/client/ui-layout" }, From 4eff7510589a1661e114a7cef28b8db1733abe6b Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 6 Aug 2026 16:13:45 +0800 Subject: [PATCH 20/63] test(client): mount TypeRT remote assembly in fixtures --- apps/web/tests/assembled-boot.ts | 31 ++++++++++--------- apps/web/tests/search-card.snapshot.ts | 4 +-- .../client/runtime/tests/wire-events.spec.ts | 3 ++ 3 files changed, 22 insertions(+), 16 deletions(-) diff --git a/apps/web/tests/assembled-boot.ts b/apps/web/tests/assembled-boot.ts index 0e168ba9fe..ebb2aa513a 100644 --- a/apps/web/tests/assembled-boot.ts +++ b/apps/web/tests/assembled-boot.ts @@ -1,5 +1,5 @@ // Shared scaffolding for the assembled-jsdom snapshots: the real built -// `packages/client/*/lib/client.js` artifacts booted through AppWebEntry's +// workspace `lib/client.js` artifacts booted through AppWebEntry's // ModuleLoader path (loadBundle) against the keyless FixtureApiClient // transport. Every file that mounts this graph needs the same boot entry list, // the same bundle map, the same jsdom globals, and the same mount call, and @@ -14,18 +14,21 @@ import { afterEach, beforeEach, vi } from 'vitest' import type { WebBootEntry } from '@deepseek-ai/dsh-client-modules/client' import { AppWebEntry } from '@deepseek-ai/dsh-client-web' -/** Boot entries for the minimal assembled graph, each carrying the workspace directory its bundle is read from. */ -const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [ - { id: '@deepseek-ai/dsh-client-connection', dir: 'connection', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true }, - { id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true }, - { id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true }, - { id: '@deepseek-ai/dsh-client-locale', dir: 'locale', url: '/plugins/locale.js', rev: 'fx', inject: [], immediately: true }, - { id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] }, - { id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, - { id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, +/** Boot entries for the minimal assembled graph, each carrying the workspace bundle it loads. */ +const PLUGINS: readonly (WebBootEntry & { bundlePath: string })[] = [ + { id: '@deepseek-ai/dsh-typert-registry', bundlePath: 'packages/typert/registry/lib/client.js', url: '/plugins/typert-registry.js', rev: 'fx', inject: [], immediately: true }, + { id: '@deepseek-ai/dsh-client-connection', bundlePath: 'packages/client/connection/lib/client.js', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true }, + { id: '@deepseek-ai/dsh-host-api-gateway', bundlePath: 'packages/host/api-gateway/lib/client.js', url: '/plugins/api-gateway.js', rev: 'fx', inject: ['@deepseek-ai/dsh-typert-registry', '@deepseek-ai/dsh-client-connection'], immediately: true }, + { id: '@deepseek-ai/dsh-client-remotes', bundlePath: 'packages/client/remotes/lib/client.js', url: '/plugins/client-remotes.js', rev: 'fx', inject: ['@deepseek-ai/dsh-host-api-gateway'], immediately: true }, + { id: '@deepseek-ai/dsh-client-runtime', bundlePath: 'packages/client/runtime/lib/client.js', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection', '@deepseek-ai/dsh-client-remotes', '@deepseek-ai/dsh-typert-registry'], immediately: true }, + { id: '@deepseek-ai/dsh-client-ui-theme', bundlePath: 'packages/client/ui-theme/lib/client.js', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true }, + { id: '@deepseek-ai/dsh-client-locale', bundlePath: 'packages/client/locale/lib/client.js', url: '/plugins/locale.js', rev: 'fx', inject: [], immediately: true }, + { id: '@deepseek-ai/dsh-client-ui-layout', bundlePath: 'packages/client/ui-layout/lib/client.js', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] }, + { id: '@deepseek-ai/dsh-client-ui-sidebar', bundlePath: 'packages/client/ui-sidebar/lib/client.js', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, + { id: '@deepseek-ai/dsh-client-ui-conversation', bundlePath: 'packages/client/ui-conversation/lib/client.js', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, { id: '@deepseek-ai/dsh-client-ui-workspace', - dir: 'ui-workspace', + bundlePath: 'packages/client/ui-workspace/lib/client.js', url: '/plugins/ui-workspace.js', rev: 'fx', inject: [ @@ -34,12 +37,12 @@ const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [ '@deepseek-ai/dsh-client-ui-sidebar', ], }, - { id: '@deepseek-ai/dsh-client-ui-trajectory', dir: 'ui-trajectory', url: '/plugins/ui-trajectory.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-conversation'] }, + { id: '@deepseek-ai/dsh-client-ui-trajectory', bundlePath: 'packages/client/ui-trajectory/lib/client.js', url: '/plugins/ui-trajectory.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-conversation'] }, ] const bundles = new Map(PLUGINS.map(plugin => [ plugin.url, - readFileSync(join(process.cwd(), 'packages/client', plugin.dir, 'lib/client.js'), 'utf8'), + readFileSync(join(process.cwd(), plugin.bundlePath), 'utf8'), ])) interface FixtureWindow extends Window { @@ -97,7 +100,7 @@ export function mountAssembledApp(): void { const root = document.createElement('div') root.id = 'root' document.body.appendChild(root) - win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ dir: _dir, ...plugin }) => plugin) } + win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ bundlePath: _bundlePath, ...plugin }) => plugin) } act(() => { const entry = new AppWebEntry(root, { loadBundle: async (url) => { diff --git a/apps/web/tests/search-card.snapshot.ts b/apps/web/tests/search-card.snapshot.ts index 626be993a6..8e6322c4af 100644 --- a/apps/web/tests/search-card.snapshot.ts +++ b/apps/web/tests/search-card.snapshot.ts @@ -1,6 +1,6 @@ // @vitest-environment jsdom -// Assembled search-card snapshot: boots the real built `packages/client/*/lib/ -// client.js` bundles through AppWebEntry's ModuleLoader path against the keyless +// Assembled search-card snapshot: boots the real built workspace client bundles +// through AppWebEntry's ModuleLoader path against the keyless // FixtureApiClient transport (no API key, no model round), opens the fixture // session, and pins the search card the `grep` turn (fixture turn 66) renders in // the assembled application. The built-boot smoke proves the graph boots but diff --git a/packages/client/runtime/tests/wire-events.spec.ts b/packages/client/runtime/tests/wire-events.spec.ts index f081eb54c1..5ab644682a 100644 --- a/packages/client/runtime/tests/wire-events.spec.ts +++ b/packages/client/runtime/tests/wire-events.spec.ts @@ -6,6 +6,7 @@ import { Context } from 'cordis' import { describe, expect, it } from 'vitest' import type { ConnectionHandle, ConnectionSinks } from '@deepseek-ai/dsh-client-connection/client' +import TypertRegistry from '@deepseek-ai/dsh-typert-registry' import * as RuntimeClient from '../src/client/index.ts' import { FakeApiClient } from './fake-api.ts' @@ -16,6 +17,7 @@ interface Bench { async function mount(): Promise { const ctx = new Context() + await ctx.plugin(TypertRegistry) const api = new FakeApiClient() const bench: Bench = { ctx, sinks: undefined } const handle: ConnectionHandle = { @@ -30,6 +32,7 @@ async function mount(): Promise { }, } ctx.reflect.provide('connection', handle) + ctx.reflect.provide('api', {}) await ctx.plugin(RuntimeClient).await() return bench } From 36516e97b970c47b15504f6825d891b1f21bf864 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 6 Aug 2026 16:41:24 +0800 Subject: [PATCH 21/63] feat(connection): dispatch TypeRT remotes through shared API --- ...08-02-typert-remote-method-calls.i18n.yaml | 4 +- .../2026-08-02-typert-remote-method-calls.md | 73 ++++++----- ...026-08-02-typert-remote-method-calls.zh.md | 73 ++++++----- packages/client/connection/README.i18n.yaml | 4 +- packages/client/connection/README.md | 2 +- packages/client/connection/README.zh.md | 2 +- .../connection/src/api-request-trust.ts | 9 +- packages/client/connection/src/http-bridge.ts | 8 +- packages/client/connection/src/index.ts | 66 +++++----- packages/client/connection/src/rpc-host.ts | 74 ++++++++++- packages/client/connection/src/rpc.ts | 22 +++- .../connection/tests/client-apply.spec.ts | 28 ++--- .../client/connection/tests/node-half.spec.ts | 118 +++++++++++++++--- packages/host/api-gateway/README.i18n.yaml | 4 +- packages/host/api-gateway/README.md | 4 +- packages/host/api-gateway/README.zh.md | 4 +- packages/host/api-gateway/src/client/index.ts | 2 +- packages/host/api-gateway/src/index.ts | 45 ++++--- .../host/api-gateway/tests/client.spec.ts | 6 +- .../host/api-gateway/tests/gateway.spec.ts | 73 +++++++++-- 20 files changed, 439 insertions(+), 182 deletions(-) diff --git a/.agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml b/.agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml index cc2f0736d4..6e7a1a3a13 100644 --- a/.agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml +++ b/.agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.md -2026-08-02-typert-remote-method-calls.md: c3a7a77c583720c3f967de185a089d374f017d81 -2026-08-02-typert-remote-method-calls.zh.md: 9b2fbbd69f1c054cbf6c86f177b743c583be3e8a +2026-08-02-typert-remote-method-calls.md: 61c8f61468621846fa8e8ff78d52313ae805aa17 +2026-08-02-typert-remote-method-calls.zh.md: 1e09965d2baba2db35301288f338cef15d947f36 diff --git a/.agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.md b/.agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.md index c3a7a77c58..61c8f61468 100644 --- a/.agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.md +++ b/.agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.md @@ -20,7 +20,7 @@ A business Service declares callable methods with `@Remote` or `@RemoteContext() The Remote consumer projection contains `.d.ts`, `.d.ts.map`, and `.js` files. The `.d.ts` exposes only methods marked with a Remote decorator and refers to the business package's single public type symbols. The `.d.ts.map` navigates consumer API methods back to their Host business method implementations. The `.js` carries endpoint, parameter, Context, and Zod information for the same contract. At the assembly layer, the Browser Client mounts the required Remote JS contributions onto the Client API Service. The projection and API abstraction remain platform-independent so that a future TUI can reuse them. -`@deepseek-ai/dsh-host-api-gateway`, located at `packages/host/api-gateway`, provides two symmetric faces: its default entry provides Host `ctx.typertGateway`, while its `/client` entry provides consumer-side `ctx.api`. Each side consumes a locally generated `InvocationDescriptor` from the same model; descriptors are not sent over the wire. The Remote data protocol runs over the single Connection/RPC mechanism through an isolated `/api2` channel. The business calling interface does not change when Connection migrates from HTTP to WebSocket. +`@deepseek-ai/dsh-host-api-gateway`, located at `packages/host/api-gateway`, provides two symmetric faces: its default entry provides Host `ctx.typertGateway`, while its `/client` entry provides consumer-side `ctx.api`. Each side consumes a locally generated `InvocationDescriptor` from the same model; descriptors are not sent over the wire. The Remote data protocol runs over Connection's shared `/api` RPC channel. The business calling interface does not change when Connection migrates from HTTP to WebSocket. ## Components and Cordis services @@ -30,7 +30,7 @@ The Remote consumer projection contains `.d.ts`, `.d.ts.map`, and `.js` files. T | TypeRT registry | `ctx.typert` | Separately stores reflection for the current environment, imported Remote contributions, lookup providers, and Context providers | | TypeRT generator/loader | No new business service | Generates three kinds of `lib` artifacts from the Host/Client Programs and registers the current environment's artifacts with `ctx.typert` | | Host API Gateway's Host face | `ctx.typertGateway` | Associates Host definitions with live Services, decodes parameters, resolves receivers, invokes methods, and encodes results | -| Connection | `ctx.connection` | Exclusively owns the HTTP Server/future WebSocket, RPC envelope, rpcId, serialization, trust, and error transport, while carrying the isolated `/api` and `/api2` channels | +| Connection | `ctx.connection` | Exclusively owns the HTTP Server/future WebSocket, the shared `/api` route, RPC envelope, rpcId, serialization, trust, error transport, TypeRT interception, and legacy API Proxy fallback | | Host API Gateway's Client face | `ctx.api` | Mounts Remote contributions, materializes root and scoped APIs, and delegates canonical calls to `ctx.connection.rpc` | | Client Remotes | No new service | Serves as the only Remote facade for Client business code, selecting and mounting `/remote` contributions while exposing the Gateway Client face and the selected API declarations | | Agent/Session owning packages | Existing domain services | Provide both static interface merges and runtime lookup/Context providers | @@ -139,7 +139,7 @@ Parameter order comes from the method signature. HTTP fields come from parameter A LIB codec contains a Zod schema and a canonical `typeSymbol` consisting of "package + public subpath + export name." An SRC codec is marked only as `src-json`. When the Host and consumer run in different JavaScript realms, each holds its own Zod instances, but both sets are generated from the same TypeRT model and symbol keys. -Descriptors exist only in the local registry on each side. The wire carries only the `/api2` channel, endpoint, and `{ args }` payload. The Host uses its descriptor to decode and invoke the method, while the Client uses its corresponding descriptor to encode arguments and validate the result. +Descriptors exist only in the local registry on each side. The wire carries only the `/api` channel, endpoint, and `{ args }` payload. The Host uses its descriptor to decode and invoke the method, while the Client uses its corresponding descriptor to encode arguments and validate the result. ## TypeRT runtime registry @@ -294,20 +294,20 @@ Client business packages depend only on `@deepseek-ai/dsh-client-remotes/client` `ctx.api.mount()` registers a contribution with `TypeRT.remotes`, and its disposer is owned by the Cordis fiber that called the method. Duplicate endpoints, conflicting invocation modes for the same namespace and method, or conflicts between a descriptor and an existing type identity fail immediately. -The API Service materializes each `@Remote` descriptor as a real function on the root `api`. The function constructs named `args` in descriptor parameter order, applies the Client's strict codec, and then calls `ctx.connection.rpc.call('/api2', endpoint, { args })`. +The API Service materializes each `@Remote` descriptor as a real function on the root `api`. The function constructs named `args` in descriptor parameter order, applies the Client's strict codec, and then calls `ctx.connection.rpc.call('/api', endpoint, { args })`. -Neither a direct descriptor with `scope` nor a `@RemoteContext` descriptor copies functions into every Agent Scope. The API Service creates one root singleton Cordis Service for each scoped namespace and materializes methods on that Service. When `agent.goals.create()` is called, the Cordis tracker rebinds the Service's `this.ctx` to the current Agent Context. The method then asks the corresponding Context binder for identity from `this.ctx`. A direct scoped projection substitutes this identity at the lookup position named by `scope.wire`; a Context descriptor writes the identity into the receiver's separate wire field. Both issue the same kind of `/api2` call. +Neither a direct descriptor with `scope` nor a `@RemoteContext` descriptor copies functions into every Agent Scope. The API Service creates one root singleton Cordis Service for each scoped namespace and materializes methods on that Service. When `agent.goals.create()` is called, the Cordis tracker rebinds the Service's `this.ctx` to the current Agent Context. The method then asks the corresponding Context binder for identity from `this.ctx`. A direct scoped projection substitutes this identity at the lookup position named by `scope.wire`; a Context descriptor writes the identity into the receiver's separate wire field. Both issue the same kind of `/api` call. ```text root ctx.api.goals.create(agentId, request) → direct descriptor - → ctx.connection.rpc.call('/api2', 'goals/create', { args }) + → ctx.connection.rpc.call('/api', 'goals/create', { args }) agent.goals.create(request) → tracker 将 namespace Service rebind 到 agent Context → agent binder 从 caller Context 取得 agentId → 用 agentId 补入同一 direct descriptor 的 lookup 参数 - → ctx.connection.rpc.call('/api2', 'goals/create', { args }) + → ctx.connection.rpc.call('/api', 'goals/create', { args }) ``` The Root `Context` does not merge the scoped `goals` type; only `AgentContext` gains that property through `RemoteContextApi<'agent'>`. If a caller bypasses the type system and dynamically calls a scoped method from Root, the binder reports an explicit error. If the Client already has a Cordis service with the same name, or two contributions claim the same namespace and method incompatibly, mounting fails instead of overwriting the existing service. @@ -318,7 +318,7 @@ Generated Remote JS contains only descriptors, symbol keys, and codecs; it does Remote API is a consumer capability, not a synonym for Browser API. This phase implements only Browser Client contribution mounting, Connection RPC calls, and Agent Scope association. -Remote DTS, Remote JS, `RemoteApi`, `InvocationDescriptor`, the Remote RPC data protocol, and Context binders must not depend on the DOM, Browser module loaders, or HTTP. Through Connection, the Browser Client encodes descriptor-materialized methods as `/api2` RPC calls. +Remote DTS, Remote JS, `RemoteApi`, `InvocationDescriptor`, the Remote RPC data protocol, and Context binders must not depend on the DOM, Browser module loaders, or HTTP. Through Connection, the Browser Client encodes descriptor-materialized methods as `/api` RPC calls. A future TUI can join the same call abstraction without changing business decorators, Remote maps, or the shape of API calls. The TUI-visible API must still be generated exclusively from `@Remote` and `@RemoteContext`; sharing a process with the Host must not allow it to bypass Remote restrictions and expose Service methods directly. @@ -363,22 +363,28 @@ ctx.typertGateway.invoke({ namespace, method, args }) `ctx.typertGateway.invoke()` is the carrier-independent Host entry point. It neither creates an rpcId, RPC envelope, nor HTTP response. It returns only the encoded result or raises a Gateway error that the Connection RPC adapter maps for transport. -## The `/api2` call chain +## The shared `/api` call chain -`/api2` is an isolated protocol channel on the single Connection/RPC mechanism, not a transport created by the Gateway. The Gateway registers one local handler with Connection. This phase adds the following general channel capability to the existing HTTP Connection: +Connection owns one `/api` route on the HTTP Server. The Gateway mounts a synchronous endpoint ownership test and the Remote RPC handler into Connection: ```text -ctx.connection.rpc.handle('/api2', (endpoint, payload) => { - const { namespace, method } = parseEndpoint(endpoint) - const { args } = parsePayload(payload) - return ctx.typertGateway.invoke({ namespace, method, args }) -}) +ctx.connection.rpc.intercept( + '/api', + endpoint => ownsRemoteEndpoint(endpoint), + (endpoint, payload) => { + const { namespace, method } = parseEndpoint(endpoint) + const { args } = parsePayload(payload) + return ctx.typertGateway.invoke({ namespace, method, args }) + }, +) ``` -The Connection Host half obtains a handle from the single HTTP Server and reuses the same RPC bridge, request/response envelope, rpcId, serialization, trust, transport errors, and `RpcError`. Its current physical mapping is: +The Gateway claims an endpoint when the Host registry contains its strict descriptor, remembers a withdrawn strict descriptor, or finds a matching `@Remote` marker on an active SRC Service binding. A claimed endpoint stays in the Gateway after payload decoding, descriptor resolution, or invocation fails; only an endpoint that is not Remote-owned reaches the legacy API Proxy fallback. + +The Connection Host half passes one composite FetchHandler to the HTTP bridge. After the bridge creates a standard `Request`, that handler selects either the Gateway RPC FetchHandler or the API Proxy FetchHandler. Both paths reuse the same request/response envelope, rpcId, serialization, trust, transport errors, and `RpcError`. The current physical mapping is: ```text -POST /api2// +POST /api// ``` The Remote payload is a named JSON object, not a positional array, and does not carry an `InvocationDescriptor`. A normal Goal call has this payload slot: @@ -399,11 +405,12 @@ The complete path is: ```text ctx.api.goals.create(sessionId, request) → Client InvocationDescriptor 编码 { args: { agentId, request } } -→ ctx.connection.rpc.call('/api2', 'goals/create', { args }) +→ ctx.connection.rpc.call('/api', 'goals/create', { args }) → Connection 创建 rpcId 和既有 client-request envelope -→ 当前 carrier 发送 POST /api2/goals/create -→ Connection Host half 执行 trust、反序列化和 RPC 分发 -→ /api2 handler 调用 ctx.typertGateway.invoke(...) +→ 当前 carrier 发送 POST /api/goals/create +→ Connection Host half 执行共享 trust,再由 bridge 创建标准 Request +→ 复合 FetchHandler 判断 endpoint ownership 并选择目标 FetchHandler +→ TypeRT interceptor 调用 ctx.typertGateway.invoke(...) → Host InvocationDescriptor 解码、lookup、receiver 解析和 Reflect.apply → result codec 编码 → Connection 写入既有 RPC result 并回送相同 rpcId @@ -412,30 +419,30 @@ ctx.api.goals.create(sessionId, request) Remote does not define a second-layer `{ ok, value/error }` response. Successful values and Gateway errors use the existing RPC response's `result` directly. The Gateway adapter maps endpoint, schema, lookup, Context, Service, and business-invocation failures to `RpcError`; Connection transports that error. -The Gateway does not handle per-method permissions, caller identity, cancellation, idempotency, or long-lived connection state. This work only extends Connection with general channel registration and invocation capabilities. It does not change existing `/api`, trusted connection, trusted-host, or privileged-method semantics. Connection's WebSocket migration remains separate follow-up work. +The Gateway does not handle per-method permissions, caller identity, cancellation, idempotency, or long-lived connection state. TypeRT endpoints use Connection's trusted-host policy; unclaimed endpoints retain the legacy API Proxy's trust and privileged-method policies. Connection's WebSocket migration remains separate follow-up work. ## Connection and protocol boundaries -The API Service owns Remote contributions, method materialization, Scope binding, and the correspondence between positional parameters and descriptors. The Gateway owns Host descriptors, lookup, Context, and business invocation. Connection only sends `/api2`, the endpoint, and `{ args }` as one RPC call to the target and returns the existing RPC result; it does not understand Goal, Agent, lookup, descriptors, or Client API types. +The API Service owns Remote contributions, method materialization, Scope binding, and the correspondence between positional parameters and descriptors. The Gateway owns Host descriptors, endpoint ownership, lookup, Context, and business invocation. Connection sends `/api`, the endpoint, and `{ args }` as one RPC call to the target and returns the existing RPC result; it does not understand Goal, Agent, lookup, descriptors, or Client API types. -`/api` and `/api2` share one Connection, Server, RPC envelope, and connection lifecycle while remaining separate protocols. When Connection migrates from HTTP to WebSocket, `/api2` naturally changes from a physical path to a logical channel. The Remote payload, business decorators, generated DTS, Remote API types, and Agent Scope programming interface remain unchanged. +The Gateway registers only its ownership matcher and RPC handler with Connection; it does not register an HTTP route. Connection mounts the shared `/api` route into the HTTP Server and gives the bridge one composite FetchHandler; that handler dispatches claimed endpoints to Gateway and unclaimed endpoints to API Proxy. A future Connection transport can preserve this order without changing the Remote payload, business decorators, generated DTS, Remote API types, or Agent Scope programming interface. ## Package boundaries - `@deepseek-ai/dsh-type-meta`: lightweight protocols for decorators, bindings, lookup, Remote Context, and descriptors. - TypeRT generator: analyzes Host/Client Programs, generates local faces and Remote consumer projections, and emits canonical symbol/Zod information. - TypeRT runtime: separately stores the current environment's local reflection and imported Remote contributions. -- `@deepseek-ai/dsh-host-api-gateway`: its default entry associates Host definitions with Services, performs lookup, resolves Context receivers, invokes methods, encodes results, and registers an `/api2` handler with Connection; its `/client` entry mounts Remote contributions, creates strict API methods, and delegates calls to `ctx.connection.rpc`. The entries share the Remote protocol but do not import each other's Cordis interface merges. +- `@deepseek-ai/dsh-host-api-gateway`: its default entry associates Host definitions with Services, claims Remote endpoints, performs lookup, resolves Context receivers, invokes methods, encodes results, and registers an `/api` interceptor with Connection; its `/client` entry mounts Remote contributions, creates strict API methods, and delegates calls to `ctx.connection.rpc`. The entries share the Remote protocol but do not import each other's Cordis interface merges. - `@deepseek-ai/dsh-client-remotes`: the only Remote facade depended on by Client business code; directly depends on the Gateway Client face, selects `/remote` contributions, and exposes the merged API types to business packages. -- Connection: owns the single HTTP Server/future WebSocket carrier, RPC envelope, rpcId, serialization, trust, and error transport while carrying the isolated `/api` and `/api2` channels. +- Connection: owns the single HTTP Server/future WebSocket carrier, shared `/api` route and composite FetchHandler, API Proxy fallback, RPC envelope, rpcId, serialization, trust, and error transport. - Business-object packages such as Agent/Session: own lookup, Context providers, canonical ID types, and public type-only entries. - Business Service packages: declare bindings, Remote methods, and their request/result types, and export the generated `/remote` subpath. ## Initial implementation scope -The first vertical path implements `@deepseek-ai/dsh-goal/remote → Browser Client API → Connection RPC /api2 → Host Gateway → GoalService.remoteExportCreate()` and proves that the same direct descriptor with an Agent lookup supports both `ctx.api.goals.create(agentId, request)` and `agentCtx.goals.create(request)`. The scoped-receiver semantics of `@RemoteContext('agent')` remain a separate mode. +The first vertical path implements `@deepseek-ai/dsh-goal/remote → Browser Client API → Connection RPC /api → Host Gateway → GoalService.remoteExportCreate()` and proves that the same direct descriptor with an Agent lookup supports both `ctx.api.goals.create(agentId, request)` and `agentCtx.goals.create(request)`. The scoped-receiver semantics of `@RemoteContext('agent')` remain a separate mode. -This phase implements Connection's general second-channel API and its current HTTP carrier mapping, but not WebSocket migration, the TUI runtime, a TUI carrier, or TUI Agent Scope wiring. This RFC also does not design Permission/Approval state machines, Session event streams, call authorization, cancellation, retries, idempotency, or cross-version protocol compatibility. +This phase implements Connection's shared-channel interceptor and current HTTP carrier mapping, but not WebSocket migration, the TUI runtime, a TUI carrier, or TUI Agent Scope wiring. This RFC also does not design Permission/Approval state machines, Session event streams, call authorization, cancellation, retries, idempotency, or cross-version protocol compatibility. ## Alternatives considered @@ -455,7 +462,7 @@ This phase implements Connection's general second-channel API and its current HT **Let a top-level `/remote` import register global state implicitly.** The target Cordis Context may not exist when ESM evaluation occurs, and ownership becomes ambiguous across multiple Contexts, HMR, and disposal. A normal value import therefore returns only a contribution, which the environment assembly explicitly mounts through the API Service. -**Create a separate transport, HTTP route, and response envelope for Remote.** This would duplicate the existing Connection's Server ownership, rpcId, serialization, trust, errors, and future WebSocket lifecycle, requiring two RPC stacks to migrate separately. `/api2` instead reuses the single Connection/RPC mechanism as an isolated protocol channel. +**Create a separate transport, HTTP route, or `/api2` channel for Remote.** This would duplicate or split Connection's Server ownership, rpcId, serialization, trust, errors, and future WebSocket lifecycle. The shared `/api` interceptor instead keeps one physical route and lets Connection preserve API Proxy as the fallback FetchHandler. ## Acceptance criteria @@ -465,10 +472,10 @@ This phase implements Connection's general second-channel API and its current HT - After the Client assembly mounts the JS contribution obtained from the same import, TypeRT can reflect endpoint, parameter, result, lookup, Context, and Zod information, and the API Service creates the calling method without a hand-written stub. - Remote DTS, Remote JS, `RemoteApi`, and the descriptor protocol do not depend on Browser-specific capabilities, and the type model cannot expose unmarked Goal Service methods, preserving the boundary required for future isomorphic TUI integration. - `agent.goals.*` obtains its call Scope through the Cordis tracker and Context binder. The Root Context has no Agent-only type, and functions are not copied into each Scope. -- `/api2/goals/create` resolves `agentId` to the canonical Agent object, invokes the original Goal Service receiver, and returns the result through the existing RPC result/error mechanism. -- `/api2` and `/api` share the single Connection/RPC carrier while remaining protocol-isolated. Remote neither registers an HTTP Server handle directly nor defines a second response envelope. -- Connection provides general channel registration and invocation capabilities and maps `/api2` to the current HTTP carrier. Existing `/api` behavior and trust semantics remain unchanged. -- This implementation does not change existing `/api`, Connection/trusted connection, Permission/Approval, or Session event stream behavior. +- `/api/goals/create` resolves `agentId` to the canonical Agent object, invokes the original Goal Service receiver, and returns the result through the existing RPC result/error mechanism. +- Gateway mounts into Connection, Connection mounts the single `/api` route into HTTP Server, and Remote defines neither an HTTP route nor a second response envelope. +- Connection's composite FetchHandler dispatches a TypeRT-owned endpoint to Gateway and falls back to API Proxy only when Gateway does not claim it. A withdrawn strict endpoint remains claimed and fails as unavailable. +- Existing API Proxy trust, privileged-method, Permission/Approval, and Session event stream behavior remains unchanged for unclaimed endpoints. ## Risks diff --git a/.agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.zh.md b/.agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.zh.md index 9b2fbbd69f..1e09965d2b 100644 --- a/.agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.zh.md +++ b/.agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.zh.md @@ -20,7 +20,7 @@ Host 与 Browser Client 使用独立的 TypeScript Program,因为两边会以 Remote 消费端投影同时包含 `.d.ts`、`.d.ts.map` 和 `.js`。`.d.ts` 只暴露被 Remote decorator 标记的方法,并引用业务包唯一的公共类型符号;`.d.ts.map` 把消费端 API 方法导航回 Host 业务方法实现;`.js` 携带同一契约的 endpoint、参数、Context 和 Zod 信息。Browser Client 在 assembly 层把需要的 Remote JS 贡献集中挂到 Client API Service;该投影和 API 抽象保持平台无关,以便未来 TUI 复用。 -`@deepseek-ai/dsh-host-api-gateway` 在 `packages/host/api-gateway` 内提供对称的两个 face:默认入口提供 Host `ctx.typertGateway`,`/client` 入口提供消费端 `ctx.api`。两边各自在本地消费由同一模型生成的 `InvocationDescriptor`,descriptor 不通过 wire 发送。Remote 数据协议运行在唯一 Connection/RPC 机制之上,使用独立 `/api2` channel;业务调用界面不随 Connection 从 HTTP 迁移到 WebSocket 而改变。 +`@deepseek-ai/dsh-host-api-gateway` 在 `packages/host/api-gateway` 内提供对称的两个 face:默认入口提供 Host `ctx.typertGateway`,`/client` 入口提供消费端 `ctx.api`。两边各自在本地消费由同一模型生成的 `InvocationDescriptor`,descriptor 不通过 wire 发送。Remote 数据协议运行在 Connection 共享的 `/api` RPC channel 上;业务调用界面不随 Connection 从 HTTP 迁移到 WebSocket 而改变。 ## 组件和 Cordis 服务 @@ -30,7 +30,7 @@ Remote 消费端投影同时包含 `.d.ts`、`.d.ts.map` 和 `.js`。`.d.ts` 只 | TypeRT registry | `ctx.typert` | 分开保存当前环境 reflection、导入的 Remote contribution、lookup provider 和 Context provider | | TypeRT generator/loader | 无新增业务服务 | 从 Host/Client Program 生成三类 `lib` 产物,并把当前环境产物注册到 `ctx.typert` | | Host API Gateway 的 Host face | `ctx.typertGateway` | 关联 Host definition 与活 Service,解码参数、解析 receiver、调用方法和编码结果 | -| Connection | `ctx.connection` | 独占 HTTP Server/未来 WebSocket、RPC envelope、rpcId、序列化、trust 和错误传输,并承载 `/api` 与 `/api2` 两个隔离 channel | +| Connection | `ctx.connection` | 独占 HTTP Server/未来 WebSocket、共享 `/api` route、RPC envelope、rpcId、序列化、trust、错误传输、TypeRT 拦截和旧 API Proxy 回退 | | Host API Gateway 的 Client face | `ctx.api` | mount Remote contribution,实体化根 API 和 scoped API,把规范调用交给 `ctx.connection.rpc` | | Client Remotes | 无新增服务 | 作为 Client 业务的唯一 Remote facade,选择并挂载 `/remote` contribution,同时传递 Gateway Client face 和所选 API 的类型声明 | | Agent/Session owning 包 | 既有领域服务 | 同时提供静态 interface merge 与运行时 lookup/Context provider | @@ -139,7 +139,7 @@ InvocationDescriptor { LIB codec 带有 Zod schema 和“package + 公共 subpath + export name”的规范 `typeSymbol`;SRC codec 只标记 `src-json`。Host 和消费端运行在不同 JavaScript realm 时会各自持有 Zod 实例,但这些实例由同一 TypeRT 模型和 symbol key 生成。 -descriptor 只存在于两端本地 registry。wire 上只有 `/api2` channel、endpoint 和 `{ args }` payload;Host 用自己的 descriptor 解码和调用,Client 用自己的对应 descriptor 编码参数和验证结果。 +descriptor 只存在于两端本地 registry。wire 上只有 `/api` channel、endpoint 和 `{ args }` payload;Host 用自己的 descriptor 解码和调用,Client 用自己的对应 descriptor 编码参数和验证结果。 ## TypeRT 运行时 registry @@ -294,20 +294,20 @@ Client 业务包只引用 `@deepseek-ai/dsh-client-remotes/client`,不直接 `ctx.api.mount()` 把 contribution 注册到 `TypeRT.remotes`,并由调用该方法的 Cordis fiber 持有 disposer。endpoint 重复、同一 namespace/method 模式冲突或 descriptor 与现有类型身份冲突时直接失败。 -API Service 把 `@Remote` descriptor 实体化为根 `api` 上的真实函数。函数按 descriptor 的位置参数顺序构造具名 `args`,执行 Client strict codec,然后调用 `ctx.connection.rpc.call('/api2', endpoint, { args })`。 +API Service 把 `@Remote` descriptor 实体化为根 `api` 上的真实函数。函数按 descriptor 的位置参数顺序构造具名 `args`,执行 Client strict codec,然后调用 `ctx.connection.rpc.call('/api', endpoint, { args })`。 -带 `scope` 的 direct descriptor 和 `@RemoteContext` descriptor 都不为每个 Agent Scope 复制函数。API Service 为每个 scoped namespace 建立一个 root singleton Cordis Service,并在该 Service 上实体化方法;Cordis tracker 在 `agent.goals.create()` 调用时把 Service 的 `this.ctx` rebind 到当前 Agent Context。方法再通过对应 Context binder 从 `this.ctx` 取得 identity。direct scoped 投影用 identity 替代 `scope.wire` 指定的 lookup 位置,Context descriptor 则把 identity 写入 receiver 的独立 wire 字段;两者都发起同一种 `/api2` 调用。 +带 `scope` 的 direct descriptor 和 `@RemoteContext` descriptor 都不为每个 Agent Scope 复制函数。API Service 为每个 scoped namespace 建立一个 root singleton Cordis Service,并在该 Service 上实体化方法;Cordis tracker 在 `agent.goals.create()` 调用时把 Service 的 `this.ctx` rebind 到当前 Agent Context。方法再通过对应 Context binder 从 `this.ctx` 取得 identity。direct scoped 投影用 identity 替代 `scope.wire` 指定的 lookup 位置,Context descriptor 则把 identity 写入 receiver 的独立 wire 字段;两者都发起同一种 `/api` 调用。 ```text root ctx.api.goals.create(agentId, request) → direct descriptor - → ctx.connection.rpc.call('/api2', 'goals/create', { args }) + → ctx.connection.rpc.call('/api', 'goals/create', { args }) agent.goals.create(request) → tracker 将 namespace Service rebind 到 agent Context → agent binder 从 caller Context 取得 agentId → 用 agentId 补入同一 direct descriptor 的 lookup 参数 - → ctx.connection.rpc.call('/api2', 'goals/create', { args }) + → ctx.connection.rpc.call('/api', 'goals/create', { args }) ``` Root `Context` 不 merge scoped `goals` 类型;只有 `AgentContext` 通过 `RemoteContextApi<'agent'>` 获得该属性。若调用方绕过类型从 Root 动态调用 scoped 方法,binder 明确报错。若 Client 已有同名 Cordis service,或两个 contribution 冲突占用同一 namespace/method,mount 直接失败,不覆盖现有服务。 @@ -318,7 +318,7 @@ Root `Context` 不 merge scoped `goals` 类型;只有 `AgentContext` 通过 `R Remote API 是消费端能力,不等同于 Browser API。本期只实现 Browser Client 的 contribution 挂载、Connection RPC 调用和 Agent Scope 关联。 -Remote DTS、Remote JS、`RemoteApi`、`InvocationDescriptor`、Remote RPC 数据协议和 Context binder 不得依赖 DOM、Browser module loader 或 HTTP。Browser Client 通过 Connection 把 descriptor 实体化的方法编码为 `/api2` RPC 调用。 +Remote DTS、Remote JS、`RemoteApi`、`InvocationDescriptor`、Remote RPC 数据协议和 Context binder 不得依赖 DOM、Browser module loader 或 HTTP。Browser Client 通过 Connection 把 descriptor 实体化的方法编码为 `/api` RPC 调用。 未来 TUI 可以在不改变业务 decorator、Remote maps 和 API 调用形状的前提下接入同一调用抽象。届时 TUI 可见的 API 仍只能由 `@Remote` 和 `@RemoteContext` 生成,不能因为它与 Host 同进程就绕过 Remote 限制直接暴露 Service 方法。 @@ -363,22 +363,28 @@ ctx.typertGateway.invoke({ namespace, method, args }) `ctx.typertGateway.invoke()` 是 carrier-independent 的 Host 入口。它不创建 rpcId、RPC envelope 或 HTTP response;它只返回编码结果,或产生由 Connection RPC adapter 映射的 Gateway 错误。 -## `/api2` 调用链 +## 共享 `/api` 调用链 -`/api2` 是唯一 Connection/RPC 机制上的独立协议 channel,不是 Gateway 自建的 transport。Gateway 只向 Connection 注册一个本地 handler;本期在现有 HTTP Connection 中增加这项通用 channel 能力: +Connection 在 HTTP Server 上持有唯一 `/api` route。Gateway 把同步 endpoint ownership 判断和 Remote RPC handler 挂到 Connection: ```text -ctx.connection.rpc.handle('/api2', (endpoint, payload) => { - const { namespace, method } = parseEndpoint(endpoint) - const { args } = parsePayload(payload) - return ctx.typertGateway.invoke({ namespace, method, args }) -}) +ctx.connection.rpc.intercept( + '/api', + endpoint => ownsRemoteEndpoint(endpoint), + (endpoint, payload) => { + const { namespace, method } = parseEndpoint(endpoint) + const { args } = parsePayload(payload) + return ctx.typertGateway.invoke({ namespace, method, args }) + }, +) ``` -Connection Host half 从唯一 HTTP Server 取得 handle,复用同一 RPC bridge、request/response envelope、rpcId、序列化、trust、transport error 和 `RpcError`。当前物理映射是: +Host registry 中存在 strict descriptor、记录过已撤回的 strict descriptor,或 active SRC Service binding 上存在匹配的 `@Remote` 标记时,Gateway 认领该 endpoint。endpoint 一旦被认领,即使 payload 解码、descriptor 解析或调用失败也继续由 Gateway 返回错误;只有不属于 Remote 的 endpoint 才进入旧 API Proxy 回退。 + +Connection Host half 把一个复合 FetchHandler 交给 HTTP bridge。bridge 创建标准 `Request` 后,该 handler 再选择 Gateway RPC FetchHandler 或 API Proxy FetchHandler;两条路径复用同一 request/response envelope、rpcId、序列化、trust、transport error 和 `RpcError`。当前物理映射是: ```text -POST /api2// +POST /api// ``` Remote payload 使用具名 JSON 对象,不使用位置数组,也不发送 `InvocationDescriptor`。普通 Goal 调用的 payload slot 是: @@ -399,11 +405,12 @@ Remote payload 使用具名 JSON 对象,不使用位置数组,也不发送 ` ```text ctx.api.goals.create(sessionId, request) → Client InvocationDescriptor 编码 { args: { agentId, request } } -→ ctx.connection.rpc.call('/api2', 'goals/create', { args }) +→ ctx.connection.rpc.call('/api', 'goals/create', { args }) → Connection 创建 rpcId 和既有 client-request envelope -→ 当前 carrier 发送 POST /api2/goals/create -→ Connection Host half 执行 trust、反序列化和 RPC 分发 -→ /api2 handler 调用 ctx.typertGateway.invoke(...) +→ 当前 carrier 发送 POST /api/goals/create +→ Connection Host half 执行共享 trust,再由 bridge 创建标准 Request +→ 复合 FetchHandler 判断 endpoint ownership 并选择目标 FetchHandler +→ TypeRT interceptor 调用 ctx.typertGateway.invoke(...) → Host InvocationDescriptor 解码、lookup、receiver 解析和 Reflect.apply → result codec 编码 → Connection 写入既有 RPC result 并回送相同 rpcId @@ -412,30 +419,30 @@ ctx.api.goals.create(sessionId, request) Remote 不定义第二层 `{ ok, value/error }` response。成功值和 Gateway 错误直接使用既有 RPC response 的 `result`;Gateway adapter 负责把 endpoint、schema、lookup、Context、Service 和业务调用失败映射为 `RpcError`,Connection 负责传输该错误。 -Gateway 不处理逐方法权限、调用者身份、取消、幂等或长连接状态。本工作只扩展 Connection 的通用 channel 注册和调用能力,不改变现有 `/api`、trusted connection、trusted-host 或 privileged method 语义;Connection/WebSocket 迁移后续独立完成。 +Gateway 不处理逐方法权限、调用者身份、取消、幂等或长连接状态。TypeRT endpoint 使用 Connection 的 trusted-host 策略;未认领 endpoint 保留旧 API Proxy 的 trust 和 privileged-method 策略。Connection/WebSocket 迁移后续独立完成。 ## Connection 与协议边界 -API Service 负责 Remote contribution、方法实体化、Scope 绑定以及位置参数与 descriptor 的对应。Gateway 负责 Host descriptor、lookup、Context 和业务调用。Connection 只负责把 `/api2`、endpoint 和 `{ args }` 作为一个 RPC 调用发送到目标并返回既有 RPC result;它不理解 Goal、Agent、lookup、descriptor 或 Client API 类型。 +API Service 负责 Remote contribution、方法实体化、Scope 绑定以及位置参数与 descriptor 的对应。Gateway 负责 Host descriptor、endpoint ownership、lookup、Context 和业务调用。Connection 把 `/api`、endpoint 和 `{ args }` 作为一个 RPC 调用发送到目标并返回既有 RPC result;它不理解 Goal、Agent、lookup、descriptor 或 Client API 类型。 -`/api` 与 `/api2` 共享唯一 Connection、Server、RPC envelope 和连接生命周期,但保持协议隔离。Connection 从 HTTP 迁移到 WebSocket 时,`/api2` 从物理路径自然变成逻辑 channel;Remote payload、业务 decorator、生成的 DTS、Remote API 类型和 Agent Scope 编程界面都不变化。 +Gateway 只向 Connection 注册 ownership matcher 和 RPC handler,不注册 HTTP route。Connection 把共享 `/api` route 挂到 HTTP Server,并把一个复合 FetchHandler 交给 bridge;该 handler 将已认领 endpoint 分发给 Gateway,未认领 endpoint 则交给 API Proxy。未来 Connection transport 可以保留相同顺序,而不改变 Remote payload、业务 decorator、生成的 DTS、Remote API 类型或 Agent Scope 编程界面。 ## 包边界 - `@deepseek-ai/dsh-type-meta`:轻量 decorator、binding、lookup、Remote Context 和 descriptor 协议。 - TypeRT generator:分析 Host/Client Program,生成本地 face 和 Remote 消费端投影,并生成规范 symbol/Zod 信息。 - TypeRT runtime:分别保存当前环境的 local reflection 与导入的 Remote contribution。 -- `@deepseek-ai/dsh-host-api-gateway`:默认入口关联 Host definition 与 Service,执行 lookup、Context receiver 解析、调用和结果编码,并向 Connection 注册 `/api2` handler;`/client` 入口挂载 Remote contribution,创建严格 API 方法,并把调用交给 `ctx.connection.rpc`。两个入口共享 Remote 协议,但不互相导入各自的 Cordis interface merge。 +- `@deepseek-ai/dsh-host-api-gateway`:默认入口关联 Host definition 与 Service,认领 Remote endpoint,执行 lookup、Context receiver 解析、调用和结果编码,并向 Connection 注册 `/api` interceptor;`/client` 入口挂载 Remote contribution,创建严格 API 方法,并把调用交给 `ctx.connection.rpc`。两个入口共享 Remote 协议,但不互相导入各自的 Cordis interface merge。 - `@deepseek-ai/dsh-client-remotes`:Client 业务唯一依赖的 Remote facade;直接依赖 Gateway Client face,选择 `/remote` contributions,并向业务包传递合并后的 API 类型。 -- Connection:拥有唯一 HTTP Server/未来 WebSocket carrier、RPC envelope、rpcId、序列化、trust 和错误传输,同时承载隔离的 `/api` 与 `/api2` channel。 +- Connection:拥有唯一 HTTP Server/未来 WebSocket carrier、共享 `/api` route 与复合 FetchHandler、API Proxy 回退、RPC envelope、rpcId、序列化、trust 和错误传输。 - Agent/Session 等业务对象包:拥有 lookup、Context provider、唯一 ID 类型和纯类型公共出口。 - 业务 Service 包:声明 binding、Remote 方法及其 request/result 类型,并导出生成的 `/remote` 子路径。 ## 首期实现范围 -第一条纵向链路实现 `@deepseek-ai/dsh-goal/remote → Browser Client API → Connection RPC /api2 → Host Gateway → GoalService.remoteExportCreate()`,并证明同一个带 Agent lookup 的 direct descriptor 同时支持 `ctx.api.goals.create(agentId, request)` 与 `agentCtx.goals.create(request)`。`@RemoteContext('agent')` 的 scoped receiver 语义继续保留为独立模式。 +第一条纵向链路实现 `@deepseek-ai/dsh-goal/remote → Browser Client API → Connection RPC /api → Host Gateway → GoalService.remoteExportCreate()`,并证明同一个带 Agent lookup 的 direct descriptor 同时支持 `ctx.api.goals.create(agentId, request)` 与 `agentCtx.goals.create(request)`。`@RemoteContext('agent')` 的 scoped receiver 语义继续保留为独立模式。 -本期实现 Connection 的通用第二 channel API 及当前 HTTP carrier 映射,但不实现 WebSocket 迁移、TUI runtime、TUI carrier 或 TUI Agent Scope 接线。本 RFC 也不设计 Permission/Approval 状态机、Session 事件流、调用授权、取消、重试、幂等和跨版本协议兼容。 +本期实现 Connection 的共享 channel interceptor 及当前 HTTP carrier 映射,但不实现 WebSocket 迁移、TUI runtime、TUI carrier 或 TUI Agent Scope 接线。本 RFC 也不设计 Permission/Approval 状态机、Session 事件流、调用授权、取消、重试、幂等和跨版本协议兼容。 ## Alternatives considered @@ -455,7 +462,7 @@ API Service 负责 Remote contribution、方法实体化、Scope 绑定以及位 **让 `/remote` 的顶层 import 偷偷注册全局状态。** ESM 求值时未必已有目标 Cordis Context,多个 Context、HMR 和 dispose 也无法明确归属,因此普通 value import 只返回 contribution,由环境 assembly 的 API Service 显式挂载。 -**为 Remote 新建独立 transport、HTTP route 和响应信封。** 这会复制现有 Connection 的 Server ownership、rpcId、序列化、trust、错误和未来 WebSocket 生命周期,并让两个 RPC 栈分别迁移,因此 `/api2` 作为独立协议 channel 复用唯一 Connection/RPC 机制。 +**为 Remote 新建独立 transport、HTTP route 或 `/api2` channel。** 这会复制或拆分 Connection 的 Server ownership、rpcId、序列化、trust、错误和未来 WebSocket 生命周期。共享 `/api` interceptor 保留唯一物理 route,并让 Connection 继续以 API Proxy 作为回退 FetchHandler。 ## Acceptance criteria @@ -465,10 +472,10 @@ API Service 负责 Remote contribution、方法实体化、Scope 绑定以及位 - Client assembly 挂载同一个 import 得到的 JS contribution 后,TypeRT 能反射 endpoint、参数、结果、lookup、Context 和 Zod 信息,API Service 无需手写 stub 即可创建调用方法。 - Remote DTS、Remote JS、`RemoteApi` 和 descriptor 协议不依赖 Browser 专属能力,且类型模型无法暴露未标记的 Goal Service 方法,为未来 TUI 同构接入保留边界。 - `agent.goals.*` 通过 Cordis tracker 和 Context binder 取得调用 Scope,Root Context 不获得 Agent-only 类型,且不为每个 Scope 复制函数。 -- `/api2/goals/create` 能把 `agentId` 解析为唯一 Agent 对象,调用原始 Goal Service receiver,并通过既有 RPC result/error 返回结果。 -- `/api2` 与 `/api` 共享唯一 Connection/RPC carrier,但保持协议隔离;Remote 不直接注册 HTTP Server handle,也不定义第二套 response envelope。 -- Connection 提供通用 channel 注册和调用能力,并把 `/api2` 映射到当前 HTTP carrier;现有 `/api` 行为与 trust 语义保持不变。 -- 现有 `/api`、Connection/trusted connection、Permission/Approval 和 Session 事件流行为不因本实现改变。 +- `/api/goals/create` 能把 `agentId` 解析为唯一 Agent 对象,调用原始 Goal Service receiver,并通过既有 RPC result/error 返回结果。 +- Gateway 挂到 Connection,Connection 把唯一 `/api` route 挂到 HTTP Server;Remote 不定义 HTTP route 或第二套 response envelope。 +- Connection 的复合 FetchHandler 将 TypeRT 认领的 endpoint 分发给 Gateway,仅在 Gateway 不认领时回退 API Proxy;已撤回的 strict endpoint 继续被认领并返回 unavailable。 +- 未认领 endpoint 保留既有 API Proxy trust、privileged-method、Permission/Approval 和 Session 事件流行为。 ## Risks diff --git a/packages/client/connection/README.i18n.yaml b/packages/client/connection/README.i18n.yaml index 05b9bb4141..ddfda12f4e 100644 --- a/packages/client/connection/README.i18n.yaml +++ b/packages/client/connection/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/connection/README.md -README.md: 1393e79aacecbbf7b186f19e4c42269595854b0e -README.zh.md: 70380ceba1b16b2970e947fb6cd9b2af9085ae51 +README.md: 161e34c4b6018625fb690e178eb9a9f8ac0ef21b +README.zh.md: d17012cc89c02a1b11f16d126b7c0cafe67fb2a0 diff --git a/packages/client/connection/README.md b/packages/client/connection/README.md index 1393e79aac..161e34c4b6 100644 --- a/packages/client/connection/README.md +++ b/packages/client/connection/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + current-page loopback state + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. The browser carrier uses HTTP POST for unary and respond operations and opens one downlink-only WebSocket each for `events.mux` and `events.host`; the in-process carrier satisfies the same two-stream abstraction. Loopback hostname classification stays package-internal: the `/api` Host fence and WebSocket upgrades use it directly, while other client plugins consume the derived `ctx.connection.isLoopback` state. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`openDocument`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`; reads and native actions included, since describing returns the exposed configuration, opening acts on the Host desktop, and probing an arbitrary reference reports where a credential comes from) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform carriers and ConnectionController loop are package-internal; apply selects and drives them. The downlink boundary is documented in the [WebSocket downlink carrier Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md); the protocol contract is api-contracts v3 §3. +Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + current-page loopback state + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. The browser carrier uses HTTP POST for unary and respond operations and opens one downlink-only WebSocket each for `events.mux` and `events.host`; the in-process carrier satisfies the same two-stream abstraction. The Host half owns the single `/api` route and its Fetch bridge; a registered TypeRT interceptor claims its Remote endpoints before the API Proxy fallback. Loopback hostname classification stays package-internal: the `/api` Host fence and WebSocket upgrades use it directly, while other client plugins consume the derived `ctx.connection.isLoopback` state. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`openDocument`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`; reads and native actions included, since describing returns the exposed configuration, opening acts on the Host desktop, and probing an arbitrary reference reports where a credential comes from) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform carriers and ConnectionController loop are package-internal; apply selects and drives them. The downlink boundary is documented in the [WebSocket downlink carrier Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md); the protocol contract is api-contracts v3 §3. ## /api browser-trust fence diff --git a/packages/client/connection/README.zh.md b/packages/client/connection/README.zh.md index 70380ceba1..d17012cc89 100644 --- a/packages/client/connection/README.zh.md +++ b/packages/client/connection/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 当前页面的 loopback 状态 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。浏览器载体以 HTTP POST 发送 unary/respond,并为 `events.mux` 与 `events.host` 各开一条只下行的 WebSocket;进程内载体满足同一双流抽象。Loopback hostname 判定逻辑留在包内部:`/api` Host fence 与 WebSocket upgrade 会直接使用它,其他客户端插件则消费派生的 `ctx.connection.isLoopback` 状态。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory`、`host.openPath`,以及整个配置面——`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`;读取与原生操作也在内,因为 describe 会返回已暴露的配置、打开操作会作用于 Host 桌面,而探测任意引用会报出某条凭据来自何处)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台载体与 ConnectionController 循环属于包内部;apply 负责选择并驱动它们。下行边界见 [WebSocket 下行载体 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md);协议契约见 api-contracts v3 §3。 +协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 当前页面的 loopback 状态 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。浏览器载体以 HTTP POST 发送 unary/respond,并为 `events.mux` 与 `events.host` 各开一条只下行的 WebSocket;进程内载体满足同一双流抽象。Host half 持有唯一 `/api` route 及其 Fetch bridge;已注册的 TypeRT interceptor 会先认领自己的 Remote endpoint,未认领请求再回退 API Proxy。Loopback hostname 判定逻辑留在包内部:`/api` Host fence 与 WebSocket upgrade 会直接使用它,其他客户端插件则消费派生的 `ctx.connection.isLoopback` 状态。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory`、`host.openPath`,以及整个配置面——`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`;读取与原生操作也在内,因为 describe 会返回已暴露的配置、打开操作会作用于 Host 桌面,而探测任意引用会报出某条凭据来自何处)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台载体与 ConnectionController 循环属于包内部;apply 负责选择并驱动它们。下行边界见 [WebSocket 下行载体 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md);协议契约见 api-contracts v3 §3。 ## /api 浏览器信任栅栏 diff --git a/packages/client/connection/src/api-request-trust.ts b/packages/client/connection/src/api-request-trust.ts index 4e897ccf87..141092c63b 100644 --- a/packages/client/connection/src/api-request-trust.ts +++ b/packages/client/connection/src/api-request-trust.ts @@ -16,12 +16,13 @@ import type { IncomingHttpHeaders } from 'node:http' import { isLoopbackHostname } from './loopback-hostname.ts' -/** The request facts the fence reads (structural subset of IncomingMessage). */ +/** The request facts the fence reads from either HTTP representation. */ interface ApiTrustRequest { - headers: IncomingHttpHeaders + headers: IncomingHttpHeaders | Headers } -function header(headers: IncomingHttpHeaders, name: string): string | undefined { +function header(headers: IncomingHttpHeaders | Headers, name: string): string | undefined { + if (headers instanceof Headers) return headers.get(name) ?? undefined const value = headers[name] return typeof value === 'string' ? value : undefined } @@ -88,7 +89,7 @@ function isTrustedAuthority(hostUrl: URL, trustedHosts: readonly string[]): bool /** * Decide whether one /api request may reach the RPC bridge. - * @param request - node HTTP request facts (headers). + * @param request - Node HTTP or Fetch request facts (headers). * @param trustedHosts - non-loopback authorities this deployment serves: exact `host:port`, or port-less `host` matching any port. * @returns true when the Host is ours (loopback or trusted) and any attached browser markers are same-origin. */ diff --git a/packages/client/connection/src/http-bridge.ts b/packages/client/connection/src/http-bridge.ts index 88d577bef8..cdf8d12bfe 100644 --- a/packages/client/connection/src/http-bridge.ts +++ b/packages/client/connection/src/http-bridge.ts @@ -5,7 +5,13 @@ import type { IncomingMessage, ServerResponse } from 'node:http' -interface FetchHandler { +/** Transport-independent request handler consumed by the Host HTTP bridge. */ +export interface FetchHandler { + /** + * Handle one standard Fetch request. + * @param request - request produced by the active transport bridge. + * @returns complete or streaming Fetch response. + */ fetch(request: Request): Promise } diff --git a/packages/client/connection/src/index.ts b/packages/client/connection/src/index.ts index d8b6ef8846..aefdcdadf4 100644 --- a/packages/client/connection/src/index.ts +++ b/packages/client/connection/src/index.ts @@ -12,6 +12,7 @@ import { rejectWebSocketUpgrade, WebSocketDownlinks } from './websocket-downlink export type { ConnectionRpcAuthority, + ConnectionRpcEndpointMatcher, ConnectionRpcHandler, ConnectionRpcHandlerOptions, HostConnectionHandle, @@ -24,7 +25,7 @@ export { API_PATH, HOST_EVENTS_PATH, MUX_EVENTS_PATH } from './api-path.ts' /** Stable Cordis plugin name. */ export const name = 'client-connection' -/** Services required before providing Connection; legacy `/api` attaches when apiProxy is present. */ +/** Services required before providing Connection; API Proxy is an optional `/api` fallback. */ export const inject = ['httpServer'] /** Plugin config: the deployment's non-loopback serving authorities. */ @@ -93,35 +94,44 @@ export function apply(ctx: Context, config?: ConnectionConfig): void { // Config boundary: a malformed entry fails the load loudly here rather than // silently authorizing its hostname prefix at request time. for (const entry of trustedHosts) assertTrustedAuthority(entry) - new HostConnectionService(ctx, trustedHosts) + const connection = new HostConnectionService(ctx, trustedHosts) + const fetchHandler = connection.createSharedFetchHandler(API_PATH, { + async fetch(request) { + const pathname = new URL(request.url).pathname + const method = pathname.startsWith(`${API_PATH}/`) + ? pathname.slice(API_PATH.length + 1) + : undefined + if (method !== undefined + && PRIVILEGED_METHODS.has(method) + && !isTrustedApiRequest(request, [])) { + return new Response('forbidden', { status: 403 }) + } + if (request.method === 'GET' && (pathname === MUX_EVENTS_PATH || pathname === HOST_EVENTS_PATH)) { + return new Response('upgrade required', { + status: 426, + headers: { connection: 'Upgrade', upgrade: 'websocket' }, + }) + } + const apiProxy = ctx.get('apiProxy') + if (apiProxy === undefined) return new Response('not found', { status: 404 }) + return toFetchHandler(apiProxy).fetch(request) + }, + }) + const route: WebRoute = { + kind: 'prefix', + path: API_PATH, + handler: async (req, res) => { + if (!isTrustedApiRequest(req, trustedHosts)) { + res.writeHead(403) + res.end('forbidden') + return + } + await bridge(req, res, fetchHandler) + }, + } + ctx.effect(() => ctx.httpServer.register(route), 'client-connection: /api route') ctx.inject(['apiProxy'], (apiCtx) => { - const apiHandler = toFetchHandler(apiCtx.apiProxy) const downlinks = new WebSocketDownlinks(apiCtx.apiProxy) - const route: WebRoute = { - kind: 'prefix', - path: API_PATH, - handler: async (req, res) => { - const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname - const method = pathname.startsWith(`${API_PATH}/`) - ? pathname.slice(API_PATH.length + 1) - : undefined - const allowed = method !== undefined && PRIVILEGED_METHODS.has(method) - ? isTrustedApiRequest(req, []) - : isTrustedApiRequest(req, trustedHosts) - if (!allowed) { - res.writeHead(403) - res.end('forbidden') - return - } - if (req.method === 'GET' && (pathname === MUX_EVENTS_PATH || pathname === HOST_EVENTS_PATH)) { - res.writeHead(426, { connection: 'Upgrade', upgrade: 'websocket' }) - res.end('upgrade required') - return - } - await bridge(req, res, apiHandler) - }, - } - apiCtx.effect(() => apiCtx.httpServer.register(route), 'client-connection: /api route') const registerDownlink = ( path: string, handle: WebUpgradeRoute['handler'], diff --git a/packages/client/connection/src/rpc-host.ts b/packages/client/connection/src/rpc-host.ts index a6fbdb0264..7d3e5ff6f5 100644 --- a/packages/client/connection/src/rpc-host.ts +++ b/packages/client/connection/src/rpc-host.ts @@ -11,9 +11,11 @@ import { type RpcId as RpcIdType, type ServerResponse as RpcServerResponse, } from '@deepseek-ai/dsh-host-apiproxy/api' -import { bridge } from './http-bridge.ts' +import { bridge, type FetchHandler } from './http-bridge.ts' import { isTrustedApiRequest } from './api-request-trust.ts' +import { API_PATH } from './api-path.ts' import type { + ConnectionRpcEndpointMatcher, ConnectionRpcHandler, ConnectionRpcHandlerOptions, HostConnectionHandle, @@ -24,8 +26,23 @@ const INVALID_REQUEST_RPC_ID = RpcId('invalid-request') const CHANNEL_PATTERN = /^\/[A-Za-z0-9._~-]+$/ const ENDPOINT_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/ +interface ConnectionRpcInterceptor { + readonly matches: ConnectionRpcEndpointMatcher + readonly fetchHandler: FetchHandler + readonly options: ConnectionRpcHandlerOptions +} + +declare module 'cordis' { + interface Context { + /** Host Connection transport and RPC registrations. */ + connection: HostConnectionHandle + } +} + /** Host Connection service whose channel registrations belong to the caller fiber. */ export class HostConnectionService extends Service implements HostConnectionHandle { + private readonly interceptors = new Map() + /** * Provide the Host half over the active HTTP server. * @param ctx - owning Connection plugin context. @@ -40,6 +57,33 @@ export class HostConnectionService extends Service implements HostConnectionHand const owner = this.ctx return { handle: (channel, handler, options) => this.register(owner, channel, handler, options), + intercept: (channel, matches, handler, options) => + this.registerInterceptor(owner, channel, matches, handler, options), + } + } + + /** + * Compose one shared-channel Fetch handler from its interceptor and fallback. + * @param channel - shared channel mounted by Connection. + * @param fallback - handler for endpoints not claimed by the interceptor. + * @returns Fetch handler that selects exactly one target for each request. + */ + createSharedFetchHandler( + channel: '/api', + fallback: FetchHandler, + ): FetchHandler { + return { + fetch: (request) => { + const endpoint = endpointFromPath(channel, new URL(request.url).pathname) + const interceptor = this.interceptors.get(channel) + if (endpoint === undefined || interceptor === undefined || !interceptor.matches(endpoint)) { + return fallback.fetch(request) + } + if (interceptor.options.authority === 'loopback' && !isTrustedApiRequest(request, [])) { + return Promise.resolve(new Response('forbidden', { status: 403 })) + } + return interceptor.fetchHandler.fetch(request) + }, } } @@ -69,12 +113,38 @@ export class HostConnectionService extends Service implements HostConnectionHand `client-connection: ${channel} rpc channel`, ) } + + private registerInterceptor( + owner: Context, + channel: string, + matches: ConnectionRpcEndpointMatcher, + handler: ConnectionRpcHandler, + options: ConnectionRpcHandlerOptions, + ): () => Promise { + if (channel !== API_PATH) { + throw new Error(`connection: invalid shared RPC channel ${JSON.stringify(channel)}`) + } + const interceptor: ConnectionRpcInterceptor = { + matches, + fetchHandler: rpcFetchHandler(channel, handler), + options, + } + return owner.effect(() => { + if (this.interceptors.has(channel)) { + throw new Error(`connection: shared RPC channel ${JSON.stringify(channel)} already has an interceptor`) + } + this.interceptors.set(channel, interceptor) + return () => { + this.interceptors.delete(channel) + } + }, `client-connection: ${channel} rpc interceptor`) + } } function rpcFetchHandler( channel: string, handler: ConnectionRpcHandler, -): { fetch(request: Request): Promise } { +): FetchHandler { return { async fetch(request: Request): Promise { const endpoint = endpointFromPath(channel, new URL(request.url).pathname) diff --git a/packages/client/connection/src/rpc.ts b/packages/client/connection/src/rpc.ts index ab68783724..e1260f00e8 100644 --- a/packages/client/connection/src/rpc.ts +++ b/packages/client/connection/src/rpc.ts @@ -18,11 +18,14 @@ export type ConnectionRpcHandler = ( signal: AbortSignal, ) => Promise> +/** Synchronous ownership test for one endpoint on a shared RPC channel. */ +export type ConnectionRpcEndpointMatcher = (endpoint: string) => boolean + /** Host registry for logical RPC channels carried by the current transport. */ export interface HostConnectionRpc { /** * Register one absolute channel prefix and its trust policy. - * @param channel - absolute logical channel such as `/api2`. + * @param channel - absolute logical channel such as `/rpc`. * @param handler - decoded endpoint handler returning the existing RPC result shape. * @param options - channel trust policy. * @returns asynchronous disposer removing the channel and its physical route. @@ -32,6 +35,21 @@ export interface HostConnectionRpc { handler: ConnectionRpcHandler, options: ConnectionRpcHandlerOptions, ): () => Promise + + /** + * Intercept owned endpoints on the shared `/api` channel before its fallback. + * @param channel - reserved shared channel; currently `/api`. + * @param matches - synchronous endpoint ownership test. + * @param handler - decoded endpoint handler returning the existing RPC result shape. + * @param options - trust policy for every endpoint claimed by this interceptor. + * @returns asynchronous disposer removing the interceptor. + */ + intercept( + channel: '/api', + matches: ConnectionRpcEndpointMatcher, + handler: ConnectionRpcHandler, + options: ConnectionRpcHandlerOptions, + ): () => Promise } /** Host `ctx.connection` shape consumed by transport-independent adapters. */ @@ -44,7 +62,7 @@ export interface HostConnectionHandle { export interface ClientConnectionRpc { /** * Call one endpoint through an already registered logical channel. - * @param channel - absolute logical channel such as `/api2`. + * @param channel - absolute logical channel such as `/api`. * @param endpoint - channel-relative endpoint such as `goals/create`. * @param payload - channel-owned request payload. * @param signal - optional caller cancellation. diff --git a/packages/client/connection/tests/client-apply.spec.ts b/packages/client/connection/tests/client-apply.spec.ts index 3ce8b89ecb..6bf9c26b46 100644 --- a/packages/client/connection/tests/client-apply.spec.ts +++ b/packages/client/connection/tests/client-apply.spec.ts @@ -204,7 +204,7 @@ describe('connection client apply', () => { expect(sockets[0]?.readyState).toBe(FakeWebSocket.CLOSED) }) - it('carries generic RPC calls over the isolated channel with rpcId echo validation', async () => { + it('carries RPC calls over the shared API channel with rpcId echo validation', async () => { ;(globalThis as Win).location = { hostname: 'localhost', search: '' } const handle = await mount() const original = globalThis.fetch @@ -221,13 +221,13 @@ describe('connection client apply', () => { }) } try { - await expect(handle.rpc.call('/api2', 'goals/create', { args: { agentId: 'agent-1' } })) + await expect(handle.rpc.call('/api', 'goals/create', { args: { agentId: 'agent-1' } })) .resolves.toEqual({ ok: true, value: { ref: 'goal-1' } }) } finally { globalThis.fetch = original } expect(seen).toHaveLength(1) - expect(seen[0]?.url).toBe('http://dsh.internal/api2/goals/create') + expect(seen[0]?.url).toBe('http://dsh.internal/api/goals/create') expect(seen[0]?.body).toMatchObject({ type: 'client-request', method: 'goals/create', @@ -244,10 +244,10 @@ describe('connection client apply', () => { const abort = new AbortController() globalThis.fetch = vi.fn().mockResolvedValue(new Response('unavailable', { status: 503 })) try { - await expect(handle.rpc.call('/api2', 'goals/create', {}, abort.signal)) + await expect(handle.rpc.call('/api', 'goals/create', {}, abort.signal)) .rejects.toThrow('HTTP 503') expect(globalThis.fetch).toHaveBeenCalledWith( - new URL('https://harness.example/api2/goals/create'), + new URL('https://harness.example/api/goals/create'), expect.objectContaining({ signal: abort.signal }), ) @@ -257,9 +257,9 @@ describe('connection client apply', () => { rpcId: 'different-rpc', result: { ok: true, value: null }, })) - await expect(handle.rpc.call('/api2', 'goals/create', {})).rejects.toThrow('rpcId mismatch') + await expect(handle.rpc.call('/api', 'goals/create', {})).rejects.toThrow('rpcId mismatch') const fetch = vi.mocked(globalThis.fetch) - expect(fetch.mock.calls[0]?.[0]).toEqual(new URL('http://dsh.internal/api2/goals/create')) + expect(fetch.mock.calls[0]?.[0]).toEqual(new URL('http://dsh.internal/api/goals/create')) expect(fetch.mock.calls[0]?.[1]).not.toHaveProperty('signal') } finally { globalThis.fetch = original @@ -267,12 +267,12 @@ describe('connection client apply', () => { for (const [channel, endpoint] of [ ['api2', 'goals/create'], - ['/api2/path', 'goals/create'], - ['/api2', ''], - ['/api2', '.'], - ['/api2', '..'], - ['/api2', 'goals//create'], - ['/api2', 'goals/create?unsafe'], + ['/api/path', 'goals/create'], + ['/api', ''], + ['/api', '.'], + ['/api', '..'], + ['/api', 'goals//create'], + ['/api', 'goals/create?unsafe'], ] as const) { await expect(handle.rpc.call(channel, endpoint, {})).rejects.toThrow('invalid RPC target') } @@ -281,6 +281,6 @@ describe('connection client apply', () => { it('keeps generic Remote calls unavailable in the client-only fixture', async () => { ;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' } const handle = await mount() - await expect(handle.rpc.call('/api2', 'goals/create', {})).rejects.toThrow(/unavailable in fixture mode/) + await expect(handle.rpc.call('/api', 'goals/create', {})).rejects.toThrow(/unavailable in fixture mode/) }) }) diff --git a/packages/client/connection/tests/node-half.spec.ts b/packages/client/connection/tests/node-half.spec.ts index 1c42a9dc88..59ab8e6102 100644 --- a/packages/client/connection/tests/node-half.spec.ts +++ b/packages/client/connection/tests/node-half.spec.ts @@ -195,35 +195,36 @@ describe('connection node half', () => { await dispose() }) - it('provides a disposable generic RPC channel without requiring apiProxy', async () => { + it('provides a disposable dedicated RPC channel without requiring apiProxy', async () => { const ctx = new Context() const routes: WebRoute[] = [] ctx.provide('httpServer', fakeHttpServer(routes, []) as HttpServerService) const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() - expect(routes).toHaveLength(0) + expect(routes).toHaveLength(1) + expect(routes[0]).toMatchObject({ kind: 'prefix', path: API_PATH }) const connection = ctx.get('connection') as HostConnectionHandle const calls: unknown[] = [] - const remove = connection.rpc.handle('/api2', async (endpoint, payload) => { + const remove = connection.rpc.handle('/rpc', async (endpoint, payload) => { calls.push({ endpoint, payload }) return { ok: true, value: { accepted: true } } }, { authority: 'trusted-host' }) - const route = routes.find(candidate => candidate.path === '/api2') + const route = routes.find(candidate => candidate.path === '/rpc') expect(route).toBeDefined() const request: ClientRequest = { type: 'client-request', - rpcId: RpcId('rpc-api2'), + rpcId: RpcId('rpc-dedicated'), method: 'goals/create', payload: { args: { agentId: 'agent-1' } }, } const result = fakeResponse() - await route!.handler(fakePost({ host: '127.0.0.1:3080' }, '/api2/goals/create', request), result.response) + await route!.handler(fakePost({ host: '127.0.0.1:3080' }, '/rpc/goals/create', request), result.response) expect(result.state.status).toBe(200) expect(JSON.parse(String(result.state.body))).toEqual({ type: 'server-response', - rpcId: 'rpc-api2', + rpcId: 'rpc-dedicated', result: { ok: true, value: { accepted: true } }, }) expect(calls).toEqual([{ @@ -231,11 +232,90 @@ describe('connection node half', () => { payload: { args: { agentId: 'agent-1' } }, }]) - expect(() => connection.rpc.handle('/api2', async () => ({ ok: true, value: null }), { + expect(() => connection.rpc.handle('/rpc', async () => ({ ok: true, value: null }), { authority: 'trusted-host', })).toThrow(/duplicate route/) await remove() + expect(routes.map(candidate => candidate.path)).toEqual([API_PATH]) + await fiber.dispose() expect(routes).toHaveLength(0) + }) + + it('dispatches claimed /api endpoints before the API Proxy fallback and withdraws the claim', async () => { + const ctx = new Context() + const routes: WebRoute[] = [] + ctx.provide('httpServer', fakeHttpServer(routes, []) as HttpServerService) + ctx.provide('apiProxy', {} as unknown as ApiProxy) + const fiber = ctx.plugin({ inject: [...inject], apply }, { trustedHosts: ['harness.example'] }) + await fiber.await() + const connection = ctx.get('connection') as HostConnectionHandle + const calls: unknown[] = [] + const remove = connection.rpc.intercept( + '/api', + endpoint => endpoint === 'goals/create', + async (endpoint, payload) => { + calls.push({ endpoint, payload }) + return { ok: true, value: { accepted: true } } + }, + { authority: 'trusted-host' }, + ) + expect(() => connection.rpc.intercept( + '/api', + () => true, + async () => ({ ok: true, value: null }), + { authority: 'trusted-host' }, + )).toThrow('already has an interceptor') + expect(() => connection.rpc.intercept( + '/rpc' as '/api', + () => true, + async () => ({ ok: true, value: null }), + { authority: 'trusted-host' }, + )).toThrow('invalid shared RPC channel') + const route = routes.find(candidate => candidate.path === API_PATH)! + const request: ClientRequest = { + type: 'client-request', + rpcId: RpcId('rpc-shared'), + method: 'goals/create', + payload: { args: { agentId: 'agent-1' } }, + } + + const claimed = fakeResponse() + await route.handler(fakePost({ host: '127.0.0.1:3080' }, '/api/goals/create', request), claimed.response) + expect(JSON.parse(String(claimed.state.body))).toEqual({ + type: 'server-response', + rpcId: 'rpc-shared', + result: { ok: true, value: { accepted: true } }, + }) + expect(calls).toEqual([{ + endpoint: 'goals/create', + payload: { args: { agentId: 'agent-1' } }, + }]) + + const denied = fakeResponse() + await route.handler(fakePost({ host: 'other.example' }, '/api/goals/create', request), denied.response) + expect(denied.state).toMatchObject({ status: 403, body: 'forbidden' }) + expect(calls).toHaveLength(1) + + const unclaimed = fakeResponse() + await route.handler(fakeRequest({ host: '127.0.0.1:3080' }, '/api/session.list'), unclaimed.response) + expect(unclaimed.state.status).toBe(404) + + await remove() + const withdrawn = fakeResponse() + await route.handler(fakePost({ host: '127.0.0.1:3080' }, '/api/goals/create', request), withdrawn.response) + expect(withdrawn.state.status).toBe(404) + expect(calls).toHaveLength(1) + + const removeLoopback = connection.rpc.intercept( + '/api', + endpoint => endpoint === 'goals/create', + async () => ({ ok: true, value: null }), + { authority: 'loopback' }, + ) + const loopbackOnly = fakeResponse() + await route.handler(fakePost({ host: 'harness.example' }, '/api/goals/create', request), loopbackOnly.response) + expect(loopbackOnly.state.status).toBe(403) + await removeLoopback() await fiber.dispose() }) @@ -246,20 +326,20 @@ describe('connection node half', () => { const fiber = ctx.plugin({ inject: [...inject], apply }, { trustedHosts: ['harness.example'] }) await fiber.await() const connection = ctx.get('connection') as HostConnectionHandle - const remove = connection.rpc.handle('/api2', async (endpoint) => { + const remove = connection.rpc.handle('/rpc', async (endpoint) => { if (endpoint === 'fail') throw new Error('handler broke') return { ok: true, value: null } }, { authority: 'trusted-host', }) - const route = routes[0]! + const route = routes.find(candidate => candidate.path === '/rpc')! const denied = fakeResponse() - await route.handler(fakePost({ host: 'other.example' }, '/api2/goals/create', {}), denied.response) + await route.handler(fakePost({ host: 'other.example' }, '/rpc/goals/create', {}), denied.response) expect(denied.state).toMatchObject({ status: 403, body: 'forbidden' }) const methodMismatch = fakeResponse() - await route.handler(fakePost({ host: 'harness.example' }, '/api2/goals/create', { + await route.handler(fakePost({ host: 'harness.example' }, '/rpc/goals/create', { type: 'client-request', rpcId: 'rpc-bad', method: 'other', payload: {}, }), methodMismatch.response) expect(JSON.parse(String(methodMismatch.state.body))).toMatchObject({ @@ -268,12 +348,12 @@ describe('connection node half', () => { }) for (const [request, status] of [ - [fakeRequest({ host: 'harness.example' }, '/api2/goals/create'), 404], + [fakeRequest({ host: 'harness.example' }, '/rpc/goals/create'), 404], [fakePost({ host: 'harness.example' }, '/outside/goals/create', {}), 404], - [fakePost({ host: 'harness.example' }, '/api2/goals//create', {}), 404], - [fakeRawPost({ host: 'harness.example' }, '/api2/goals/create', '{}'), 415], - [fakeRawPost({ host: 'harness.example', 'content-type': 'text/plain' }, '/api2/goals/create', '{}'), 415], - [fakeRawPost({ host: 'harness.example', 'content-type': 'application/json; charset=utf-8' }, '/api2/goals/create', '{'), 400], + [fakePost({ host: 'harness.example' }, '/rpc/goals//create', {}), 404], + [fakeRawPost({ host: 'harness.example' }, '/rpc/goals/create', '{}'), 415], + [fakeRawPost({ host: 'harness.example', 'content-type': 'text/plain' }, '/rpc/goals/create', '{}'), 415], + [fakeRawPost({ host: 'harness.example', 'content-type': 'application/json; charset=utf-8' }, '/rpc/goals/create', '{'), 400], ] as const) { const response = fakeResponse() await route.handler(request, response.response) @@ -286,7 +366,7 @@ describe('connection node half', () => { [null, 'invalid-request'], ] as const) { const response = fakeResponse() - await route.handler(fakePost({ host: 'harness.example' }, '/api2/goals/create', body), response.response) + await route.handler(fakePost({ host: 'harness.example' }, '/rpc/goals/create', body), response.response) expect(JSON.parse(String(response.state.body))).toMatchObject({ rpcId, result: { ok: false, error: { code: 'bad-request' } }, @@ -294,7 +374,7 @@ describe('connection node half', () => { } const failed = fakeResponse() - await route.handler(fakePost({ host: 'harness.example' }, '/api2/fail', { + await route.handler(fakePost({ host: 'harness.example' }, '/rpc/fail', { type: 'client-request', rpcId: 'rpc-fail', method: 'fail', payload: {}, }), failed.response) expect(failed.state).toMatchObject({ status: 500, body: 'handler failure: Error: handler broke' }) diff --git a/packages/host/api-gateway/README.i18n.yaml b/packages/host/api-gateway/README.i18n.yaml index 2abe47e0d3..747aa65665 100644 --- a/packages/host/api-gateway/README.i18n.yaml +++ b/packages/host/api-gateway/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/api-gateway/README.md -README.md: 3ef926ace2ee4d6008b1d6c18b1e070fa39bc176 -README.zh.md: 77b8b8a87d5f511000aac5cf9f75ebca5fcdfbca +README.md: cc80bb19fec15414aa0857154a8a36fb4f642672 +README.zh.md: 6febb1cfe4fc7fa4c5a17e1e4f6a21e2ee03e295 diff --git a/packages/host/api-gateway/README.md b/packages/host/api-gateway/README.md index 3ef926ace2..cc80bb19fe 100644 --- a/packages/host/api-gateway/README.md +++ b/packages/host/api-gateway/README.md @@ -10,13 +10,13 @@ Two-sided Remote control for Host and Client Cordis environments. The Host entry Strict mode reads generated invocation descriptors from `ctx.typert.local`. Lookup parameters use registered `ctx.typert.lookups` providers, while `@RemoteContext` resolves its receiver through a registered Host Context provider. SRC mode is a development fallback for endpoints that have never had a strict definition; it parses simple parameter names and accepts only JSON-safe values for non-lookup parameters. Withdrawing an observed strict definition fails instead of weakening validation. -The Host entry registers the trusted-host `/api2` unary RPC channel when Connection is available. Direct `invoke()` calls preserve business errors; `TypertGatewayError` distinguishes failures owned by dispatch, binding, providers, lookup, Context, arguments, and codecs. +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 unclaimed endpoints to API Proxy. Direct `invoke()` calls preserve business errors; `TypertGatewayError` distinguishes failures owned by dispatch, binding, providers, lookup, Context, arguments, and codecs. ## Client service: `ClientApi` (ctx key: `api`) `ctx.api.mount()` validates and registers a generated Host-for-Client contribution, then installs concrete direct and scoped methods for the calling Cordis fiber. Duplicate endpoints, namespace collisions, and descriptors without strict generated codecs fail before methods become callable. -Each call validates positional inputs, constructs the descriptor's exact named `args`, and sends it through `ctx.connection.rpc.call('/api2', endpoint, ...)`. The returned value is validated before reaching application code. Withdrawing a contribution removes its descriptors and methods together, aborts in-flight calls, and makes retained method handles reject. +Each call validates positional inputs, constructs the descriptor's exact named `args`, and sends it through `ctx.connection.rpc.call('/api', endpoint, ...)`. The returned value is validated before reaching application code. Withdrawing a contribution removes its descriptors and methods together, aborts in-flight calls, and makes retained method handles reject. Generated declaration merges provide the TypeScript API. The Client entry contains no Host Service or Host Cordis interface merge, and method lookup and invocation use ordinary objects and functions rather than a JavaScript Proxy. diff --git a/packages/host/api-gateway/README.zh.md b/packages/host/api-gateway/README.zh.md index 77b8b8a87d..6febb1cfe4 100644 --- a/packages/host/api-gateway/README.zh.md +++ b/packages/host/api-gateway/README.zh.md @@ -10,13 +10,13 @@ 严格模式从 `ctx.typert.local` 读取生成的调用描述符。查找参数使用已向 `ctx.typert.lookups` 注册的提供方,`@RemoteContext` 则通过已注册的 Host Context 提供方解析其接收者。SRC 模式是开发阶段的回退路径,适用于从未具备严格定义的端点;它解析简单参数名,并且只允许非查找参数使用可安全表示为 JSON 的值。已观测到的严格定义一旦撤回,系统会直接报错,而不会降低校验强度。 -Connection 可用时,Host 入口会注册 trusted-host 的 `/api2` 一元 RPC 通道。直接调用 `invoke()` 会保留业务错误;`TypertGatewayError` 可区分分发、绑定、提供方、查找、Context、参数和编解码器各自负责的故障。 +Connection 可用时,Host 入口会在 Connection 共享的 `/api` FetchHandler 上注册 trusted-host interceptor。Connection 把这个复合 handler 交给 HTTP bridge;handler 将已认领 endpoint 分发给 Gateway,未认领 endpoint 则交给 API Proxy。直接调用 `invoke()` 会保留业务错误;`TypertGatewayError` 可区分分发、绑定、提供方、查找、Context、参数和编解码器各自负责的故障。 ## Client 服务:`ClientApi`(ctx key:`api`) `ctx.api.mount()` 会校验并注册生成的 Host-for-Client 贡献项,然后为发起调用的 Cordis fiber 安装具体的直接方法和作用域方法。重复端点、命名空间冲突,以及缺少生成的严格编解码器的描述符,都会在方法可调用前报错。 -每次调用都会校验位置参数,构造与描述符完全匹配的具名 `args`,再通过 `ctx.connection.rpc.call('/api2', endpoint, ...)` 发送。返回值经过校验后才会交给应用代码。撤回贡献项会同时移除其描述符和方法、中止正在进行的调用,并使外部仍持有的方法句柄在调用时返回拒绝。 +每次调用都会校验位置参数,构造与描述符完全匹配的具名 `args`,再通过 `ctx.connection.rpc.call('/api', endpoint, ...)` 发送。返回值经过校验后才会交给应用代码。撤回贡献项会同时移除其描述符和方法、中止正在进行的调用,并使外部仍持有的方法句柄在调用时返回拒绝。 生成的声明合并提供 TypeScript API。Client 入口不包含 Host 服务或 Host Cordis 接口合并;方法查找和调用使用普通对象与函数,而不使用 JavaScript Proxy。 diff --git a/packages/host/api-gateway/src/client/index.ts b/packages/host/api-gateway/src/client/index.ts index fe8fd9f1b3..1f92bc0748 100644 --- a/packages/host/api-gateway/src/client/index.ts +++ b/packages/host/api-gateway/src/client/index.ts @@ -248,7 +248,7 @@ class ClientApiService extends Service implements ClientApi { }) const connection = this.ownerCtx.get('connection') as ConnectionHandle | undefined if (connection === undefined) throw new Error(`client api: ${endpoint} has no active Connection`) - const result = await connection.rpc.call('/api2', endpoint, { args }, token.abort.signal) + const result = await connection.rpc.call('/api', endpoint, { args }, token.abort.signal) if (!mountActive(token)) throw new Error(`client api: Remote method ${endpoint} was withdrawn during invocation`) if (!result.ok) throw remoteFailure(endpoint, result.error) return parse(descriptor.result, result.value, endpoint, 'result') diff --git a/packages/host/api-gateway/src/index.ts b/packages/host/api-gateway/src/index.ts index c83772261a..2adfaa8387 100644 --- a/packages/host/api-gateway/src/index.ts +++ b/packages/host/api-gateway/src/index.ts @@ -5,6 +5,7 @@ */ import { Context, Service, symbols } from 'cordis' +import type { ConnectionRpcHandler } from '@deepseek-ai/dsh-client-connection' import { remoteMethods, type InvocationDescriptor, @@ -35,26 +36,7 @@ interface ResolvedBinding { readonly original: object } -type ConnectionRpcResult = - | { readonly ok: true; readonly value: unknown } - | { - readonly ok: false - readonly error: { - readonly code: 'internal' - readonly message: string - readonly details: Record - } - } - -interface HostConnectionLike { - readonly rpc: { - handle( - channel: string, - handler: (endpoint: string, payload: unknown, signal: AbortSignal) => Promise, - options: { readonly authority: 'trusted-host' | 'loopback' }, - ): () => Promise - } -} +type ConnectionRpcResult = Awaited> /** Dispatch failure produced outside the invoked business method. */ export class TypertGatewayError extends Error { @@ -101,15 +83,32 @@ export class TypertGatewayService extends Service implements TypertGateway { constructor(ctx: Context) { super(ctx, 'typertGateway') ctx.inject(['connection'], (connectionCtx) => { - const connection = connectionCtx.get('connection') as unknown as HostConnectionLike - connection.rpc.handle( - '/api2', + connectionCtx.connection.rpc.intercept( + '/api', + endpoint => this.claimsEndpoint(endpoint), (endpoint, payload, signal) => this.dispatchRpc(endpoint, payload, signal), { authority: 'trusted-host' }, ) }) } + private claimsEndpoint(endpoint: string): boolean { + const segments = endpoint.split('/') + if (segments.length !== 2 || segments[0] === '' || segments[1] === '') return false + const [namespace, method] = segments as [string, string] + if (this.ctx.typert.local.get(endpoint) !== undefined || this.ctx.typert.local.hasSeen(endpoint)) return true + for (const [serviceKey, definition] of Object.entries(this.ctx.reflect.props)) { + if (definition.type !== 'service') continue + const receiver = this.ctx.get(serviceKey) as unknown + if (!isObject(receiver)) continue + const original = originalOf(receiver) + const binding = Reflect.get(original, 'typertGateway') as unknown + if (!isObject(binding) || Reflect.get(binding, 'namespace') !== namespace) continue + if (remoteMethods(original).some(candidate => (candidate.exportName ?? candidate.method) === method)) return true + } + return false + } + /** * Invoke one live Remote method through strict generated reflection or SRC markers. * @param request - decoded endpoint and exact named wire arguments. diff --git a/packages/host/api-gateway/tests/client.spec.ts b/packages/host/api-gateway/tests/client.spec.ts index 8c0753f3f9..ab08ef09bc 100644 --- a/packages/host/api-gateway/tests/client.spec.ts +++ b/packages/host/api-gateway/tests/client.spec.ts @@ -109,7 +109,7 @@ describe('Client TypeRT API', () => { await expect(ctx.api.goals.create('agent-1', { objective: 'ship' })).resolves.toEqual({ ref: 'goal-1' }) expect(call).toHaveBeenCalledWith( - '/api2', + '/api', 'goals/create', { args: { agentId: 'agent-1', request: { objective: 'ship' } } }, expect.any(AbortSignal), @@ -144,7 +144,7 @@ describe('Client TypeRT API', () => { await expect(agentCtx.goals.create({ objective: 'ship scoped' })).resolves.toEqual({ ref: 'goal-2' }) expect(call).toHaveBeenCalledWith( - '/api2', + '/api', 'goals/create', { args: { agentId: 'agent-2', request: { objective: 'ship scoped' } } }, expect.any(AbortSignal), @@ -175,7 +175,7 @@ describe('Client TypeRT API', () => { await expect(agentCtx.goals.rename({ objective: 'land' })).resolves.toEqual({ renamed: true }) expect(call).toHaveBeenCalledWith( - '/api2', + '/api', 'goals/rename', { args: { agentId: 'agent-2', request: { objective: 'land' } } }, expect.any(AbortSignal), diff --git a/packages/host/api-gateway/tests/gateway.spec.ts b/packages/host/api-gateway/tests/gateway.spec.ts index 0b550e126d..d5a3f9a8ee 100644 --- a/packages/host/api-gateway/tests/gateway.spec.ts +++ b/packages/host/api-gateway/tests/gateway.spec.ts @@ -96,6 +96,7 @@ type FakeRpcHandler = (endpoint: string, payload: unknown, signal: AbortSignal) class FakeConnectionService extends Service { channel: string | undefined authority: string | undefined + matches: ((endpoint: string) => boolean) | undefined handler: FakeRpcHandler | undefined constructor(ctx: Context) { @@ -105,14 +106,21 @@ class FakeConnectionService extends Service { get rpc() { const owner = this.ctx return { - handle: (channel: string, handler: FakeRpcHandler, options: { readonly authority: string }) => + intercept: ( + channel: string, + matches: (endpoint: string) => boolean, + handler: FakeRpcHandler, + options: { readonly authority: string }, + ) => owner.effect(() => { this.channel = channel this.authority = options.authority + this.matches = matches this.handler = handler return () => { this.channel = undefined this.authority = undefined + this.matches = undefined this.handler = undefined } }), @@ -820,7 +828,7 @@ describe('TypertGatewayService', () => { }), 'invocation-unavailable') }) - it('mounts /api2 through an optional Connection and returns existing RPC results', async () => { + 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(FakeConnectionService) @@ -828,13 +836,18 @@ describe('TypertGatewayService', () => { await gatewayFiber await ctx.plugin(GoalService) const connection = rawConnection(ctx) - expect(connection).toMatchObject({ channel: '/api2', authority: 'trusted-host' }) + expect(connection).toMatchObject({ channel: '/api', authority: 'trusted-host' }) registerAgentLookup(ctx, { id: 'agent-1' }) registerStrict(ctx, [createDescriptor()]) + expect(connection.matches?.('goals/create')).toBe(true) + expect(connection.matches?.('goals/passthrough')).toBe(true) + expect(connection.matches?.('goals')).toBe(false) + expect(connection.matches?.('goals/missing')).toBe(false) + expect(connection.matches?.('legacy/list')).toBe(false) const signal = new AbortController().signal const handler = connection.handler - if (handler === undefined) throw new Error('fixture Connection did not retain the /api2 handler') + if (handler === undefined) throw new Error('fixture Connection did not retain the /api interceptor') await expect(handler('goals/create', { args: { agentId: 'agent-1', request: { title: 'ship' } }, }, signal)).resolves.toEqual({ @@ -873,7 +886,7 @@ describe('TypertGatewayService', () => { expect(connection.handler).toBeUndefined() }) - it('dispatches a generated invocation through the real /api2 HTTP carrier', async () => { + it('dispatches claimed invocations through /api and leaves unclaimed endpoints to its fallback', async () => { const ctx = new Context().extend({ fixtureScope: 'http-caller' }) const routes: WebRoute[] = [] ctx.provide('httpServer', fakeHttpServer(routes) as HttpServerService) @@ -886,11 +899,12 @@ describe('TypertGatewayService', () => { await goalFiber const removeLookup = registerAgentLookup(ctx, { id: 'agent-1' }) const removeStrict = registerStrict(ctx, [createDescriptor()]) + let strictActive = true expect(routes).toHaveLength(1) const server = await serveRoute(routes[0]!) try { - const response = await fetch(`${server.origin}/api2/goals/create`, { + const response = await fetch(`${server.origin}/api/goals/create`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ @@ -909,9 +923,54 @@ describe('TypertGatewayService', () => { value: { agentId: 'agent-1', title: 'ship', scope: 'http-caller' }, }, }) + + const invalid = await fetch(`${server.origin}/api/goals/create`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + type: 'client-request', + rpcId: 'rpc-invalid', + method: 'goals/create', + payload: { invalid: true }, + }), + }) + expect(invalid.status).toBe(200) + await expect(invalid.json()).resolves.toMatchObject({ + type: 'server-response', + rpcId: 'rpc-invalid', + result: { + ok: false, + error: { code: 'internal', message: expect.stringContaining('plain-object args field') }, + }, + }) + + await removeStrict() + strictActive = false + const withdrawn = await fetch(`${server.origin}/api/goals/create`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + type: 'client-request', + rpcId: 'rpc-withdrawn', + method: 'goals/create', + payload: { args: { agentId: 'agent-1', request: { title: 'ship' } } }, + }), + }) + expect(withdrawn.status).toBe(200) + await expect(withdrawn.json()).resolves.toMatchObject({ + type: 'server-response', + rpcId: 'rpc-withdrawn', + result: { + ok: false, + error: { code: 'internal', message: expect.stringContaining('strict definition was withdrawn') }, + }, + }) + + const unclaimed = await fetch(`${server.origin}/api/legacy/list`, { method: 'POST' }) + expect(unclaimed.status).toBe(404) } finally { await server.close() - await removeStrict() + if (strictActive) await removeStrict() await removeLookup() await goalFiber.dispose() await gatewayFiber.dispose() From cd566f26f56ae8ac4a56c23adec94ee067def193 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 6 Aug 2026 16:46:02 +0800 Subject: [PATCH 22/63] test(client-remotes): cover shared API bundle chain --- packages/client/remotes/tests/built-lib.e2e.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/client/remotes/tests/built-lib.e2e.ts b/packages/client/remotes/tests/built-lib.e2e.ts index bbba218844..bef3f4ad65 100644 --- a/packages/client/remotes/tests/built-lib.e2e.ts +++ b/packages/client/remotes/tests/built-lib.e2e.ts @@ -6,7 +6,7 @@ import { describe, expect, it } from 'vitest' /** * Built-artifact smoke for the first generated Remote: plain Node boots the - * Host and Browser bundle handoffs, then crosses the real `/api2` HTTP route. + * Host and Browser bundle handoffs, then crosses the shared `/api` HTTP route. */ const packageDir = fileURLToPath(new URL('..', import.meta.url)) @@ -98,7 +98,9 @@ describe.skipIf(!requiredArtifacts)('Goal Remote built LIB chain', () => { host.agents.register(rootAgent) host.agents.register(scopedAgent) - if (routes.length !== 1) throw new Error('Gateway did not register exactly one /api2 route') + if (routes.length !== 1 || routes[0].path !== '/api') { + throw new Error('Connection did not register exactly one /api route') + } const server = createServer((request, response) => { void routes[0].handler(request, response) }) await new Promise(resolveListen => server.listen(0, '127.0.0.1', resolveListen)) const address = server.address() From 88385a658e7e1e138e002a9146919abe428633b0 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 6 Aug 2026 17:06:54 +0800 Subject: [PATCH 23/63] docs(cordis): refresh gateway service location --- docs/cordis-catalog/services.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 9f5e66ea36..a0fee79546 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -2650,7 +2650,7 @@ Resolve strict generated definitions or conservative SRC markers against current async invoke(request: InvokeRemoteRequest): Promise ``` -Source: [`packages/host/api-gateway/src/index.ts:94`](../../packages/host/api-gateway/src/index.ts) +Source: [`packages/host/api-gateway/src/index.ts:76`](../../packages/host/api-gateway/src/index.ts) ## `ctx.userInteraction` — `UserInteractionService` From 9b63d72c9482c1dfd39f79ec1fd3b0562b521b93 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 6 Aug 2026 17:49:35 +0800 Subject: [PATCH 24/63] fix(typert): harden remote reflection boundaries --- ...08-02-typert-remote-method-calls.i18n.yaml | 6 + .../2026-08-02-typert-remote-method-calls.md | 66 +++--- ...026-08-02-typert-remote-method-calls.zh.md | 66 +++--- ...08-02-typert-remote-method-calls.i18n.yaml | 6 - docs/config-catalog.md | 2 +- docs/cordis-catalog/services.md | 2 +- docs/core-data-structures/core.i18n.yaml | 4 +- docs/core-data-structures/core.md | 1 + docs/core-data-structures/core.zh.md | 1 + docs/core-data-structures/typert.i18n.yaml | 6 + docs/core-data-structures/typert.md | 196 ++++++++++++++++++ docs/core-data-structures/typert.zh.md | 196 ++++++++++++++++++ package.json | 2 +- .../cordis/tool-cordis/src/api-catalog.ts | 2 +- packages/host/api-gateway/src/client/index.ts | 1 + packages/host/api-gateway/src/index.ts | 12 +- .../host/api-gateway/tests/client.spec.ts | 15 +- .../host/api-gateway/tests/gateway.spec.ts | 29 +++ packages/typert/generator/src/analyzer.ts | 104 +++++++++- .../generator/tests/remote-model.spec.ts | 26 +++ packages/typert/loader/src/index.ts | 6 +- packages/typert/loader/tests/loader.spec.ts | 52 +++-- packages/typert/registry/src/service.ts | 26 ++- packages/typert/registry/src/types.ts | 7 +- packages/typert/registry/tests/typert.spec.ts | 18 ++ packages/typert/type-meta/src/index.ts | 1 + packages/typert/type-meta/src/types.ts | 16 ++ scripts/type-equiv.manifest.json | 60 ++++++ 28 files changed, 813 insertions(+), 116 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml rename .agents/notes/{proposed => implemented}/architecture/2026-08-02-typert-remote-method-calls.md (85%) rename .agents/notes/{proposed => implemented}/architecture/2026-08-02-typert-remote-method-calls.zh.md (85%) delete mode 100644 .agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml create mode 100644 docs/core-data-structures/typert.i18n.yaml create mode 100644 docs/core-data-structures/typert.md create mode 100644 docs/core-data-structures/typert.zh.md diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml new file mode 100644 index 0000000000..752a5d4c8b --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md +2026-08-02-typert-remote-method-calls.md: 91ab8e44ff8aedf666fe3426b85b54491deb340c +2026-08-02-typert-remote-method-calls.zh.md: 73abd53109d871076aa41af39825c80c35ac3f26 diff --git a/.agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md similarity index 85% rename from .agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.md rename to .agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md index 61c8f61468..91ab8e44ff 100644 --- a/.agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md @@ -1,6 +1,6 @@ # Agent Note: TypeRT Gateway Targeted Method Calls -Status: proposed +Status: implemented English | [中文](2026-08-02-typert-remote-method-calls.zh.md) @@ -8,13 +8,13 @@ English | [中文](2026-08-02-typert-remote-method-calls.zh.md) The Host API Proxy handles direct method calls, stateful interactions, and Session event streams. These concerns have different lifecycles, routing semantics, and client programming interfaces. Continuing to export all business operations through one package would couple business Services, transport protocols, state machines, and client types. -This proposal addresses only targeted method calls in which one request produces one result. Stateful interactions such as Permission and Approval, as well as Session event streams, do not use this design and will be designed separately. +This decision covers only targeted method calls in which one request produces one result. Stateful interactions such as Permission and Approval, as well as Session event streams, remain separate designs. -The contract for a direct method call belongs to the business Service that implements it. Business developers should declare only which methods are remotely callable, without also maintaining a central API interface, routing table, parameter conversion table, client stub, and Zod schema. +The contract for a direct method call belongs to the business Service that implements it. Business developers declare only which methods are remotely callable, without also maintaining a central API interface, routing table, parameter conversion table, client stub, and Zod schema. The Host and Browser Client use separate TypeScript Programs because each side augments the Cordis `Context` type differently. A Remote projection must not import the complete Host declarations into a consumer or depend on Browser-specific types. If the TUI later reuses this programming interface, it must likewise see only methods marked Remote. TUI integration is outside the current scope, but the implementation boundary must preserve this isomorphic reuse. -## Proposal +## Decision A business Service declares callable methods with `@Remote` or `@RemoteContext()` and explicitly joins the Gateway through `bindTypeRTGateway()`. TypeRT generates the Host-local reflection artifact and a platform-independent Remote consumer projection from the Host Program. The Client Program continues to generate its own local reflection artifact independently. @@ -24,7 +24,7 @@ The Remote consumer projection contains `.d.ts`, `.d.ts.map`, and `.js` files. T ## Components and Cordis services -| Component | Cordis service | Responsibility in this proposal | +| Component | Cordis service | Responsibility | |---|---|---| | `@deepseek-ai/dsh-type-meta` | Declares only the minimal `ctx.typert` protocol | Decorators, bindings, descriptors, lookup/Context, and the Remote map; no dependency on the compiler, Zod, Connection, or Browser | | TypeRT registry | `ctx.typert` | Separately stores reflection for the current environment, imported Remote contributions, lookup providers, and Context providers | @@ -104,7 +104,7 @@ ctx.typert.lookups.register('agent', { The static declaration tells TypeRT that `Agent` corresponds to `SessionId` on the wire. The runtime provider resolves an `agentId` in a request to the currently live `Agent` object. If either side is missing, the LIB build or the earliest resolvable runtime registration fails immediately. -Lookup objects such as Agent and Session may each occupy only one top-level parameter position. An ordinary JSON request may be passed as another complete parameter, but this proposal does not support `request.agent`, object destructuring, arrays of objects, nested lookups, or searching arbitrary complex structures for IDs. +Lookup objects such as Agent and Session may each occupy only one top-level parameter position. An ordinary JSON request may be passed as another complete parameter, but this design does not support `request.agent`, object destructuring, arrays of objects, nested lookups, or searching arbitrary complex structures for IDs. Remote Context uses a separate merge-extensible map and provider. The Agent package registers an `agent` Context provider that locates the Agent Context from its wire identity and resolves the Service key named by the descriptor from that Context. The Gateway does not know the internal structure of an Agent Context. @@ -150,7 +150,9 @@ ctx.typert.lookups wire ID 到 Host 活对象的 provider ctx.typert.contexts Host Context resolver 与 Client Context binder ``` -Every registration returns a disposer owned by the caller's Cordis fiber. The Gateway and API Service read the current snapshot before subscribing to changes, so business Services, generated contributions, providers, and consumers can load in any order. When any dependency is disposed, its related endpoints or methods become unavailable immediately. +Every registration returns a disposer owned by the caller's Cordis fiber. Client contribution mounting registers the descriptor set and concrete methods as one owned operation. The Host Gateway resolves descriptors, Services, and providers from current state for every claim and invocation instead of retaining endpoint registrations. Removing a strict definition, Service, or provider therefore makes the corresponding call unavailable without leaving a stale live object. + +The lookup registry retains the stable wire declaration after its live resolver unloads. SRC parsing continues to classify the parameter as a lookup, while invocation fails with `lookup-unavailable`; it never reclassifies the incoming ID as an ordinary JSON business object. Re-registering the same key with different parameter, wire, or canonical type symbols fails for the lifetime of that TypeRT Service. The registry's Host root entry has the complete `TypeRTService` interface merge. The registry implementation shared by Host and Client lives in a separate module without environment declarations. The registry's `/client` entry imports only that shared implementation and does not pass through the Host root entry, so it cannot bring Host Cordis declarations into the Client Program. @@ -312,11 +314,11 @@ agent.goals.create(request) The Root `Context` does not merge the scoped `goals` type; only `AgentContext` gains that property through `RemoteContextApi<'agent'>`. If a caller bypasses the type system and dynamically calls a scoped method from Root, the binder reports an explicit error. If the Client already has a Cordis service with the same name, or two contributions claim the same namespace and method incompatibly, mounting fails instead of overwriting the existing service. -Generated Remote JS contains only descriptors, symbol keys, and codecs; it does not bundle Host Service implementations. The API Service can create real functions from that data, so this proposal does not depend on a JavaScript Proxy. A Proxy remains an implementation option but is not a source of types or reflection. +Generated Remote JS contains only descriptors, symbol keys, and codecs; it does not bundle Host Service implementations. The API Service creates real functions from that data, so the runtime does not depend on a JavaScript Proxy. A Proxy remains an implementation option but is not a source of types or reflection. ## Cross-environment isomorphism constraints -Remote API is a consumer capability, not a synonym for Browser API. This phase implements only Browser Client contribution mounting, Connection RPC calls, and Agent Scope association. +Remote API is a consumer capability, not a synonym for Browser API. The shipped runtime implements Browser Client contribution mounting, Connection RPC calls, and Agent Scope association. Remote DTS, Remote JS, `RemoteApi`, `InvocationDescriptor`, the Remote RPC data protocol, and Context binders must not depend on the DOM, Browser module loaders, or HTTP. Through Connection, the Browser Client encodes descriptor-materialized methods as `/api` RPC calls. @@ -324,7 +326,7 @@ A future TUI can join the same call abstraction without changing business decora TUI runtime mounting, carriers, Agent Scope association, and SRC startup wiring are outside this phase. -The Web already depends on build artifacts such as `lib/client.js`, so it requires a complete `build:lib` before startup. After the Host Remote contract changes, developers must rebuild the lib and then start or restart the Web. The first phase does not implement incremental watching of the Remote contract. +The Web already depends on build artifacts such as `lib/client.js`, so it requires a complete `build:lib` before startup. After the Host Remote contract changes, developers rebuild the lib and then start or restart the Web. Incremental watching of the Remote contract is not implemented. ## SRC and LIB operating modes @@ -340,11 +342,11 @@ At runtime, LIB only loads definitions from `lib`; it does not start the TypeScr CI and releases use LIB. Moving all repository coverage to LIB is separate follow-up work and does not block this direct-method-call implementation. -## Host Gateway registration +## Host Gateway resolution -The Host Gateway observes both TypeRT Remote definitions and the Cordis Service lifecycle. When a Service carrying the `typertGateway` facet and a definition with the same service key are both available, the Gateway registers the definition's endpoints. Their arrival order does not matter. +The Host Gateway registers one `/api` interceptor with Connection and does not maintain a second endpoint registry. Its ownership matcher resolves each endpoint from the current TypeRT local registry or scans current Cordis Services for a matching `typertGateway` binding and SRC Remote marker. TypeRT definitions and business Services may therefore arrive in either order. -At startup, the Gateway reads the current snapshots of TypeRT definitions and the Cordis reflection store before subscribing to registry changes and `internal/service`. It reconciles definitions, live Services, and bindings by service key, and unregisters endpoints when a Service is replaced or disposed. If a definition, lookup provider, or Context provider is removed, dependent endpoints immediately become unavailable; the Gateway neither retains invalid objects nor degrades to invoking methods with raw IDs. +Invocation resolves the descriptor, receiver, lookup providers, and Context provider again from current state. A current strict descriptor takes precedence over SRC. After a strict endpoint has appeared, `TypeRTLocalRegistry.hasSeen()` keeps it owned when that descriptor is withdrawn and forbids SRC fallback for the remainder of the registry lifetime; re-registering the strict descriptor restores calls. Removing a Service or provider makes invocation fail explicitly, and the Gateway neither retains invalid objects nor invokes a method with a raw lookup ID. An ordinary `@Remote` call retains the original Service instance as receiver. After lookups succeed, the Gateway calls the member identified by `implementation ?? method` with parameters in descriptor order. @@ -417,7 +419,7 @@ ctx.api.goals.create(sessionId, request) → Client result codec 验证并返回 CreateGoalResult ``` -Remote does not define a second-layer `{ ok, value/error }` response. Successful values and Gateway errors use the existing RPC response's `result` directly. The Gateway adapter maps endpoint, schema, lookup, Context, Service, and business-invocation failures to `RpcError`; Connection transports that error. +Remote does not define a second-layer `{ ok, value/error }` response. Successful values and Gateway errors use the existing RPC response's `result` directly. The current adapter converts every Gateway and business-invocation failure to the existing `RpcError` envelope with `code: 'internal'`; the Gateway's structured error category remains available only in-process, while the message carries the diagnostic across Connection. The Gateway does not handle per-method permissions, caller identity, cancellation, idempotency, or long-lived connection state. TypeRT endpoints use Connection's trusted-host policy; unclaimed endpoints retain the legacy API Proxy's trust and privileged-method policies. Connection's WebSocket migration remains separate follow-up work. @@ -438,11 +440,11 @@ The Gateway registers only its ownership matcher and RPC handler with Connection - Business-object packages such as Agent/Session: own lookup, Context providers, canonical ID types, and public type-only entries. - Business Service packages: declare bindings, Remote methods, and their request/result types, and export the generated `/remote` subpath. -## Initial implementation scope +## Shipped scope and deferred work -The first vertical path implements `@deepseek-ai/dsh-goal/remote → Browser Client API → Connection RPC /api → Host Gateway → GoalService.remoteExportCreate()` and proves that the same direct descriptor with an Agent lookup supports both `ctx.api.goals.create(agentId, request)` and `agentCtx.goals.create(request)`. The scoped-receiver semantics of `@RemoteContext('agent')` remain a separate mode. +The shipped vertical path is `@deepseek-ai/dsh-goal/remote → Browser Client API → Connection RPC /api → Host Gateway → GoalService.remoteExportCreate()`. The same direct descriptor with an Agent lookup supports both `ctx.api.goals.create(agentId, request)` and `agentCtx.goals.create(request)`. `@RemoteContext('agent')` remains the distinct scoped-receiver mode. -This phase implements Connection's shared-channel interceptor and current HTTP carrier mapping, but not WebSocket migration, the TUI runtime, a TUI carrier, or TUI Agent Scope wiring. This RFC also does not design Permission/Approval state machines, Session event streams, call authorization, cancellation, retries, idempotency, or cross-version protocol compatibility. +Connection supplies the shared-channel interceptor and current HTTP carrier mapping. WebSocket migration, the TUI runtime and carrier, TUI Agent Scope wiring, Permission/Approval state machines, Session event streams, call authorization, cancellation, retries, idempotency, and cross-version protocol compatibility remain outside this decision. ## Alternatives considered @@ -464,26 +466,24 @@ This phase implements Connection's shared-channel interceptor and current HTTP c **Create a separate transport, HTTP route, or `/api2` channel for Remote.** This would duplicate or split Connection's Server ownership, rpcId, serialization, trust, errors, and future WebSocket lifecycle. The shared `/api` interceptor instead keeps one physical route and lets Connection preserve API Proxy as the fallback FetchHandler. -## Acceptance criteria +## Verification -- Goal Service retains its existing business method and adds a remote entry point at the end of the class through an explicit `typertGateway` and `@Remote('create') remoteExportCreate(...)`, without maintaining a second route, codec, or Client method list. -- One clean `build:lib` generates the Host Remote contract before compiling Host and Client consumers and produces JS, DTS, and a DTS map under the business package's `lib`, importable through `/remote`. -- After importing `@deepseek-ai/dsh-goal/remote`, a consumer project gets a strict `api.goals.create(...)` type; without the import, that namespace does not enter its types. Go to Definition on `create` follows the declaration map to the Host Service's `remoteExportCreate` implementation. -- After the Client assembly mounts the JS contribution obtained from the same import, TypeRT can reflect endpoint, parameter, result, lookup, Context, and Zod information, and the API Service creates the calling method without a hand-written stub. -- Remote DTS, Remote JS, `RemoteApi`, and the descriptor protocol do not depend on Browser-specific capabilities, and the type model cannot expose unmarked Goal Service methods, preserving the boundary required for future isomorphic TUI integration. -- `agent.goals.*` obtains its call Scope through the Cordis tracker and Context binder. The Root Context has no Agent-only type, and functions are not copied into each Scope. -- `/api/goals/create` resolves `agentId` to the canonical Agent object, invokes the original Goal Service receiver, and returns the result through the existing RPC result/error mechanism. -- Gateway mounts into Connection, Connection mounts the single `/api` route into HTTP Server, and Remote defines neither an HTTP route nor a second response envelope. -- Connection's composite FetchHandler dispatches a TypeRT-owned endpoint to Gateway and falls back to API Proxy only when Gateway does not claim it. A withdrawn strict endpoint remains claimed and fails as unavailable. -- Existing API Proxy trust, privileged-method, Permission/Approval, and Session event stream behavior remains unchanged for unclaimed endpoints. +- Goal Service keeps its existing business method and adds an explicit `typertGateway` plus `@Remote('create') remoteExportCreate(...)`, without a second route, codec, or Client method list. +- A clean `build:lib` emits Host and consumer Remote artifacts before Client compilation, including the business package's JS, DTS, and declaration map under `/remote`. +- Importing `@deepseek-ai/dsh-goal/remote` adds the strict `api.goals.create(...)` type and declaration navigation to `remoteExportCreate`; omitting that import omits the namespace. +- Mounting the same import's JS contribution supplies endpoint, parameter, result, lookup, Context, and Zod reflection and materializes the call without a handwritten stub. +- Root and Agent-scoped calls cross the real shared `/api` carrier, resolve `agentId` to the live Agent, invoke the original Goal receiver, and return through the existing RPC envelope. +- The Remote artifacts and maps contain only marked methods and no Browser dependency, preserving the same consumer boundary for a future TUI. +- Lifecycle tests withdraw and remount descriptors, Services, lookups, Context providers, and Client namespaces; unavailable dependencies fail without stale calls or raw-ID fallback. +- Unclaimed endpoints continue through the existing API Proxy path with its trust, privileged-method, Permission/Approval, and Session event-stream behavior unchanged. -## Risks +## Consequences Remote API types depend on generated `lib` declarations. Build orchestration must finish the Host contract pass before compiling Host and Client consumers; an incorrect order makes a clean build depend on stale artifacts. Source navigation requires a Remote package to publish both its declaration map and the `src` file referenced by the map. If package `files` omits either side, types still compile but consumer navigation stops at the generated DTS. The workspace manifest check must therefore treat both as one publication contract. -The permissive SRC descriptor does not validate the internal structure of ordinary JSON. After a Host Remote signature changes, the Web and strict type consumers must rebuild the lib; the first phase has no incremental contract watcher. +The permissive SRC descriptor does not validate the internal structure of ordinary JSON. After a Host Remote signature changes, the Web and strict type consumers must rebuild the lib because no incremental contract watcher exists. Canonical public types require business DTOs to have type-only entries, which may expose packages whose Host types and implementation entries are currently mixed. The build rejects those boundaries instead of copying types to conceal them. @@ -494,3 +494,9 @@ Browser and Host each hold their own Zod instances and cannot compare object ide A consumer may import a Remote contract that is not currently mounted on the Host. The types mean "this protocol capability was selected by the consumer," not that a corresponding Service currently exists in the target process; an unavailable endpoint must fail explicitly at runtime. Connection's general channel API must suit both the current HTTP carrier and a future WebSocket carrier. If the API exposes `fetch`, an HTTP request, or a route handle to the Gateway/API Service, WebSocket migration will pierce the Remote layer again. Those physical objects must therefore remain internal to Connection. + +Remote endpoints use Connection's `trusted-host` authority. Loopback is accepted by default and LAN callers require an explicit trusted-host configuration, but this layer adds no per-method caller authorization; every trusted host can invoke a mounted Remote endpoint. + +`hasSeen()` favors strict-definition safety over SRC availability. While a strict descriptor is withdrawn, such as during HMR, the Gateway continues to claim the endpoint and reports it unavailable instead of falling back to a weak SRC descriptor. Re-registration restores it; only a TypeRT registry restart forgets the historical strict definition. + +Connection supplies an `AbortSignal`, but Remote business signatures have no cancellation parameter. A client disconnect therefore does not cancel business work; cancellation remains deferred rather than being implied by the transport handler shape. diff --git a/.agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.zh.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md similarity index 85% rename from .agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.zh.md rename to .agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md index 1e09965d2b..73abd53109 100644 --- a/.agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md @@ -1,6 +1,6 @@ # Agent Note: TypeRT Gateway 定向方法调用 -Status: proposed +Status: implemented [English](2026-08-02-typert-remote-method-calls.md) | 中文 @@ -8,13 +8,13 @@ Status: proposed Host API Proxy 同时承担直接方法调用、带状态交互和 Session 事件流。三者的生命周期、路由语义和客户端编程界面不同,继续共用一个业务导出包会让业务 Service、传输协议、状态机和客户端类型彼此耦合。 -本方案只解决一次请求对应一次结果的定向方法调用。Permission、Approval 等带状态交互以及 Session 事件流不使用本方案,后续分别设计。 +本决策只涵盖一次请求对应一次结果的定向方法调用。Permission、Approval 等带状态交互以及 Session 事件流仍采用独立设计。 -直接方法调用的契约属于实现该行为的业务 Service。业务开发者应只声明哪些方法可以远程调用,而不应再同步维护中央 API 接口、路由表、参数转换表、客户端 stub 和 Zod schema。 +直接方法调用的契约属于实现该行为的业务 Service。业务开发者只需声明哪些方法可以远程调用,无需再同步维护中央 API 接口、路由表、参数转换表、客户端 stub 和 Zod schema。 Host 与 Browser Client 使用独立的 TypeScript Program,因为两边会以不同类型合并同名 Cordis `Context`。Remote 投影不能把完整 Host 声明导入消费端,也不能依赖 Browser 专属类型;未来 TUI 若复用这套编程界面,也只能看到 Remote 标记的方法。本期不实现 TUI 接入,但实现边界不得阻断这种同构复用。 -## Proposal +## 决策 业务 Service 通过 `@Remote` 或 `@RemoteContext()` 声明可调用方法,并通过 `bindTypeRTGateway()` 显式加入 Gateway。TypeRT 从 Host Program 生成 Host 本地反射产物和平台无关的 Remote 消费端投影;Client Program 继续独立生成自己的本地反射产物。 @@ -24,7 +24,7 @@ Remote 消费端投影同时包含 `.d.ts`、`.d.ts.map` 和 `.js`。`.d.ts` 只 ## 组件和 Cordis 服务 -| 组件 | Cordis 服务 | 本方案中的职责 | +| 组件 | Cordis 服务 | 职责 | |---|---|---| | `@deepseek-ai/dsh-type-meta` | 只声明 `ctx.typert` 的最小协议 | decorator、binding、descriptor、lookup/Context 和 Remote map;不依赖 compiler、Zod、Connection 或 Browser | | TypeRT registry | `ctx.typert` | 分开保存当前环境 reflection、导入的 Remote contribution、lookup provider 和 Context provider | @@ -104,7 +104,7 @@ ctx.typert.lookups.register('agent', { 静态声明让 TypeRT 知道 `Agent` 在 wire 上对应 `SessionId`;运行时 provider 负责把请求中的 `agentId` 解析为当前活的 `Agent` 对象。缺少任一侧时,LIB 构建或最早可解析的运行时注册直接失败。 -Agent、Session 等 lookup 对象只能各自占据一个顶层参数位置。普通 JSON request 可以作为另一个完整参数传入,但本方案不支持 `request.agent`、对象解构、对象数组、嵌套 lookup 或从任意复杂结构中搜索 ID。 +Agent、Session 等 lookup 对象只能各自占据一个顶层参数位置。普通 JSON request 可以作为另一个完整参数传入,但本设计不支持 `request.agent`、对象解构、对象数组、嵌套 lookup 或从任意复杂结构中搜索 ID。 Remote Context 使用独立的 merge-extensible map 和 provider。Agent 包注册 `agent` Context provider,负责用 wire identity 找到 Agent Context,并从该 Context 解析 descriptor 指定的 service key;Gateway 不知道 Agent Context 的内部结构。 @@ -150,7 +150,9 @@ ctx.typert.lookups wire ID 到 Host 活对象的 provider ctx.typert.contexts Host Context resolver 与 Client Context binder ``` -每次注册都返回由调用方 Cordis fiber 持有的 disposer。Gateway 和 API Service 先读取当前快照再订阅变化,因此业务 Service、generated contribution、provider 和消费者可以按任意顺序加载;任一依赖 dispose 后,相关 endpoint 或方法立即失效。 +每次注册都返回由调用方 Cordis fiber 持有的 disposer。挂载 Client contribution 时,descriptor 集与具体方法会作为一项有明确所有者的操作统一注册。Host Gateway 每次认领和调用时都从当前状态解析 descriptor、Service 与提供方,不保留 endpoint 注册。因此移除 strict definition、Service 或提供方会使相应调用不可用,且不会留下陈旧的活对象。 + +lookup 注册表会在活 resolver 卸载后保留稳定的 wire 声明。SRC 解析仍会把该参数归类为 lookup,而调用会以 `lookup-unavailable` 失败;系统绝不会把传入的 ID 重新归类为普通 JSON 业务对象。在同一个 TypeRT Service 的生命周期内,以不同参数、wire 或规范类型 symbol 重新注册同一 key 会直接失败。 Registry 的 Host 根入口拥有完整 `TypeRTService` interface merge;Host 与 Client 共用的 registry 实现位于无环境声明的独立模块。Registry `/client` 入口只引用该共享实现,不经过 Host 根入口,因此不会把 Host Cordis 声明带入 Client Program。 @@ -312,11 +314,11 @@ agent.goals.create(request) Root `Context` 不 merge scoped `goals` 类型;只有 `AgentContext` 通过 `RemoteContextApi<'agent'>` 获得该属性。若调用方绕过类型从 Root 动态调用 scoped 方法,binder 明确报错。若 Client 已有同名 Cordis service,或两个 contribution 冲突占用同一 namespace/method,mount 直接失败,不覆盖现有服务。 -生成的 Remote JS 只包含 descriptor、symbol key 和 codec,不打包 Host Service 实现。API Service 可以据此创建真实函数,因此本方案不依赖 JavaScript Proxy;Proxy 可以作为实现选择,但不会成为类型或反射来源。 +生成的 Remote JS 只包含 descriptor、symbol key 和 codec,不打包 Host Service 实现。API Service 据此创建真实函数,因此运行时不依赖 JavaScript Proxy;Proxy 可以作为实现选择,但不会成为类型或反射来源。 ## 跨环境同构约束 -Remote API 是消费端能力,不等同于 Browser API。本期只实现 Browser Client 的 contribution 挂载、Connection RPC 调用和 Agent Scope 关联。 +Remote API 是消费端能力,不等同于 Browser API。已交付的运行时实现 Browser Client contribution 挂载、Connection RPC 调用和 Agent Scope 关联。 Remote DTS、Remote JS、`RemoteApi`、`InvocationDescriptor`、Remote RPC 数据协议和 Context binder 不得依赖 DOM、Browser module loader 或 HTTP。Browser Client 通过 Connection 把 descriptor 实体化的方法编码为 `/api` RPC 调用。 @@ -324,7 +326,7 @@ Remote DTS、Remote JS、`RemoteApi`、`InvocationDescriptor`、Remote RPC 数 TUI 的 runtime 挂载、carrier、Agent Scope 关联和 SRC 启动接线均不属于本期实现。 -Web 本身依赖 `lib/client.js` 等构建产物,因此启动 Web 前要求完整 `build:lib`。Host Remote 契约变化后必须重新执行 lib build,再启动或重启 Web;本方案不在第一阶段实现 Remote contract 的增量 watch。 +Web 本身依赖 `lib/client.js` 等构建产物,因此启动 Web 前要求完整 `build:lib`。Host Remote 契约变化后,开发者需重新执行 lib build,再启动或重启 Web;系统不实现 Remote contract 的增量 watch。 ## SRC 与 LIB 运行模式 @@ -340,11 +342,11 @@ LIB 运行时只加载 `lib` 中的 definition,不启动 TypeScript compiler CI 和发布运行 LIB。全仓 coverage 全部切换到 LIB 是独立后续工作,不阻塞本次直接方法调用实现。 -## Host Gateway 注册 +## Host Gateway 解析 -Host Gateway 同时观察 TypeRT Remote definition 和 Cordis Service 生命周期。当某个带 `typertGateway` facet 的 Service 与同 service key 的 definition 都可用时,Gateway 注册其 endpoint;两者到达顺序不影响结果。 +Host Gateway 向 Connection 注册一个 `/api` interceptor,不维护第二份 endpoint 注册表。ownership matcher 会从当前 TypeRT local 注册表解析各 endpoint,或扫描当前 Cordis Service,查找匹配的 `typertGateway` binding 与 SRC Remote 标记。因此 TypeRT definition 与业务 Service 可以按任意顺序到达。 -Gateway 启动时先读取 TypeRT definition 和 Cordis reflection store 的当前快照,再订阅 registry change 与 `internal/service`。它按 service key reconcile definition、活 Service 和 binding;Service 被替换或 dispose 时撤销对应 endpoint。definition、lookup provider 或 Context provider 撤销时,依赖它们的 endpoint 立即不可调用,不保留失效对象或降级为原始 ID 调用。 +每次调用都会重新从当前状态解析 descriptor、receiver、lookup 提供方与 Context 提供方。当前 strict descriptor 优先于 SRC。strict endpoint 一旦出现,即使随后撤回对应 descriptor,`TypeRTLocalRegistry.hasSeen()` 仍会在注册表剩余生命周期内保持对它的认领并禁止回退 SRC;重新注册 strict descriptor 即可恢复调用。移除 Service 或提供方会让调用明确失败;Gateway 既不保留失效对象,也不会以原始 lookup ID 调用方法。 普通 `@Remote` 调用保留原始 Service 实例作为 receiver。lookup 成功后,Gateway 按 descriptor 的参数顺序调用 `implementation ?? method` 指定的成员。 @@ -417,7 +419,7 @@ ctx.api.goals.create(sessionId, request) → Client result codec 验证并返回 CreateGoalResult ``` -Remote 不定义第二层 `{ ok, value/error }` response。成功值和 Gateway 错误直接使用既有 RPC response 的 `result`;Gateway adapter 负责把 endpoint、schema、lookup、Context、Service 和业务调用失败映射为 `RpcError`,Connection 负责传输该错误。 +Remote 不定义第二层 `{ ok, value/error }` response。成功值和 Gateway 错误直接使用既有 RPC response 的 `result`。当前 adapter 把所有 Gateway 与业务调用失败转换为既有 `RpcError` envelope,并统一使用 `code: 'internal'`;Gateway 的结构化错误分类仅在进程内保留,诊断信息则通过 message 跨 Connection 传递。 Gateway 不处理逐方法权限、调用者身份、取消、幂等或长连接状态。TypeRT endpoint 使用 Connection 的 trusted-host 策略;未认领 endpoint 保留旧 API Proxy 的 trust 和 privileged-method 策略。Connection/WebSocket 迁移后续独立完成。 @@ -438,11 +440,11 @@ Gateway 只向 Connection 注册 ownership matcher 和 RPC handler,不注册 H - Agent/Session 等业务对象包:拥有 lookup、Context provider、唯一 ID 类型和纯类型公共出口。 - 业务 Service 包:声明 binding、Remote 方法及其 request/result 类型,并导出生成的 `/remote` 子路径。 -## 首期实现范围 +## 已交付范围与后续工作 -第一条纵向链路实现 `@deepseek-ai/dsh-goal/remote → Browser Client API → Connection RPC /api → Host Gateway → GoalService.remoteExportCreate()`,并证明同一个带 Agent lookup 的 direct descriptor 同时支持 `ctx.api.goals.create(agentId, request)` 与 `agentCtx.goals.create(request)`。`@RemoteContext('agent')` 的 scoped receiver 语义继续保留为独立模式。 +已交付的纵向链路是 `@deepseek-ai/dsh-goal/remote → Browser Client API → Connection RPC /api → Host Gateway → GoalService.remoteExportCreate()`。同一个带 Agent lookup 的 direct descriptor 同时支持 `ctx.api.goals.create(agentId, request)` 与 `agentCtx.goals.create(request)`。`@RemoteContext('agent')` 仍是独立的 scoped receiver 模式。 -本期实现 Connection 的共享 channel interceptor 及当前 HTTP carrier 映射,但不实现 WebSocket 迁移、TUI runtime、TUI carrier 或 TUI Agent Scope 接线。本 RFC 也不设计 Permission/Approval 状态机、Session 事件流、调用授权、取消、重试、幂等和跨版本协议兼容。 +Connection 提供共享 channel interceptor 与当前 HTTP carrier 映射。WebSocket 迁移、TUI runtime 与 carrier、TUI Agent Scope 接线、Permission/Approval 状态机、Session 事件流、调用授权、取消、重试、幂等及跨版本协议兼容均不属于本决策。 ## Alternatives considered @@ -464,26 +466,24 @@ Gateway 只向 Connection 注册 ownership matcher 和 RPC handler,不注册 H **为 Remote 新建独立 transport、HTTP route 或 `/api2` channel。** 这会复制或拆分 Connection 的 Server ownership、rpcId、序列化、trust、错误和未来 WebSocket 生命周期。共享 `/api` interceptor 保留唯一物理 route,并让 Connection 继续以 API Proxy 作为回退 FetchHandler。 -## Acceptance criteria +## 验证 -- Goal Service 保留既有业务方法,在类末尾通过显式 `typertGateway` 和 `@Remote('create') remoteExportCreate(...)` 新增远程出口,不维护第二份路由、codec 或 Client 方法清单。 -- 一次干净 `build:lib` 先生成 Host Remote contract,再完成 Host 和 Client 消费端编译,并在业务包 `lib` 下产生可通过 `/remote` 导入的 JS、DTS 和 DTS map。 -- 导入 `@deepseek-ai/dsh-goal/remote` 后,消费 project 获得严格的 `api.goals.create(...)` 类型;不导入时该 namespace 不进入类型;从 `create` 跳转定义会通过 declaration map 到达 Host Service 的 `remoteExportCreate` 实现。 -- Client assembly 挂载同一个 import 得到的 JS contribution 后,TypeRT 能反射 endpoint、参数、结果、lookup、Context 和 Zod 信息,API Service 无需手写 stub 即可创建调用方法。 -- Remote DTS、Remote JS、`RemoteApi` 和 descriptor 协议不依赖 Browser 专属能力,且类型模型无法暴露未标记的 Goal Service 方法,为未来 TUI 同构接入保留边界。 -- `agent.goals.*` 通过 Cordis tracker 和 Context binder 取得调用 Scope,Root Context 不获得 Agent-only 类型,且不为每个 Scope 复制函数。 -- `/api/goals/create` 能把 `agentId` 解析为唯一 Agent 对象,调用原始 Goal Service receiver,并通过既有 RPC result/error 返回结果。 -- Gateway 挂到 Connection,Connection 把唯一 `/api` route 挂到 HTTP Server;Remote 不定义 HTTP route 或第二套 response envelope。 -- Connection 的复合 FetchHandler 将 TypeRT 认领的 endpoint 分发给 Gateway,仅在 Gateway 不认领时回退 API Proxy;已撤回的 strict endpoint 继续被认领并返回 unavailable。 -- 未认领 endpoint 保留既有 API Proxy trust、privileged-method、Permission/Approval 和 Session 事件流行为。 +- Goal Service 保留既有业务方法,并新增显式 `typertGateway` 与 `@Remote('create') remoteExportCreate(...)`,无需第二条路由、第二份 codec 或 Client 方法清单。 +- 一次干净的 `build:lib` 会在 Client 编译前生成 Host 与消费方 Remote 产物,包括业务包 `/remote` 下的 JS、DTS 和 declaration map。 +- 导入 `@deepseek-ai/dsh-goal/remote` 会加入严格的 `api.goals.create(...)` 类型,并可通过 declaration 导航到 `remoteExportCreate`;不导入时不会出现该 namespace。 +- 挂载同一次 import 得到的 JS contribution 会提供 endpoint、参数、结果、lookup、Context 和 Zod 反射,并在无需手写 stub 的情况下实体化调用。 +- Root 与 Agent-scoped 调用会经过真实的共享 `/api` carrier,将 `agentId` 解析为活 Agent,调用原始 Goal receiver,并通过既有 RPC envelope 返回。 +- Remote 产物与 map 仅包含已标记的方法,不依赖 Browser,从而为未来 TUI 保留相同的消费方边界。 +- 生命周期测试会撤回并重新挂载 descriptor、Service、lookup、Context 提供方和 Client namespace;依赖不可用时,调用会失败,且不会使用陈旧调用或回退原始 ID。 +- 未认领 endpoint 继续使用既有 API Proxy 路径,其 trust、privileged-method、Permission/Approval 与 Session 事件流行为保持不变。 -## Risks +## 后果 Remote API 类型依赖生成的 `lib` 声明,构建编排必须在 Host 和 Client 消费端编译前完成 contract pass;顺序错误会让干净构建依赖陈旧产物。 源码导航依赖 Remote package 同时发布 declaration map 和 map 指向的 `src`。package `files` 漏掉任一侧时类型仍可编译,但消费端跳转会停在生成 DTS,因此 workspace manifest 校验必须把两者作为同一发布契约。 -SRC 弱 descriptor 不验证普通 JSON 内部结构。Host Remote 签名变化后,Web 和严格类型消费者必须重新执行 lib build;第一阶段没有增量 contract watch。 +SRC 弱 descriptor 不验证普通 JSON 内部结构。Host Remote 签名变化后,Web 和严格类型消费方必须重新执行 lib build,因为系统没有增量 contract watcher。 公共类型唯一性要求业务 DTO 具有纯类型出口,可能暴露现有包中 Host 类型与实现入口混杂的问题。构建会拒绝这些边界,而不是复制类型掩盖问题。 @@ -494,3 +494,9 @@ Browser 与 Host 各自持有 Zod 实例,不能依赖对象 identity 跨 realm 消费端可以导入 Host 当前未挂载的 Remote contract。类型表示“该协议能力已被消费端选择”,不保证目标进程当前存在对应 Service;运行时 endpoint 不可用必须明确失败。 Connection 的通用 channel API 必须同时适合当前 HTTP carrier 和后续 WebSocket carrier。若接口把 `fetch`、HTTP request 或 route handle 暴露给 Gateway/API Service,WebSocket 迁移会再次穿透 Remote 层,因此这些物理对象必须留在 Connection 内部。 + +Remote endpoint 使用 Connection 的 `trusted-host` authority。系统默认接受 loopback;LAN 调用方必须通过显式 trusted-host 配置接入,但本层不增加逐方法调用方授权,因此每个 trusted host 都能调用已挂载的 Remote endpoint。 + +`hasSeen()` 优先保障 strict definition 的安全性,而非 SRC 可用性。strict descriptor 撤回时(例如 HMR 期间),Gateway 会继续认领 endpoint 并报告不可用,而不会回退到弱 SRC descriptor。重新注册即可恢复;只有重启 TypeRT 注册表才会忘记历史 strict definition。 + +Connection 提供 `AbortSignal`,但 Remote 业务签名没有取消参数。因此 Client 断连不会取消业务工作;取消仍作为后续工作,而不能由 transport handler 的形状暗示已经支持。 diff --git a/.agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml b/.agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml deleted file mode 100644 index 6e7a1a3a13..0000000000 --- a/.agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# side as of the last confirmed-consistent state. Both languages carry equal authority; -# after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.md -2026-08-02-typert-remote-method-calls.md: 61c8f61468621846fa8e8ff78d52313ae805aa17 -2026-08-02-typert-remote-method-calls.zh.md: 1e09965d2baba2db35301288f338cef15d947f36 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 08f26479ca..41c9c98515 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -308,7 +308,7 @@ export interface ConnectionConfig { } ``` -Source: [`packages/client/connection/src/index.ts:31`](../packages/client/connection/src/index.ts) +Source: [`packages/client/connection/src/index.ts:32`](../packages/client/connection/src/index.ts) ## `@deepseek-ai/dsh-client-hmr` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index a0fee79546..99ffaca7c7 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -2634,7 +2634,7 @@ listPackages(filter: TypertPackageFilter = {}): TypertPackageRecord[] toJSONSchema(key: string, params?: z.core.ToJSONSchemaParams): z.core.JSONSchema.BaseSchema ``` -Source: [`packages/typert/registry/src/service.ts:324`](../../packages/typert/registry/src/service.ts) +Source: [`packages/typert/registry/src/service.ts:346`](../../packages/typert/registry/src/service.ts) ## `ctx.typertGateway` — `TypertGatewayService` diff --git a/docs/core-data-structures/core.i18n.yaml b/docs/core-data-structures/core.i18n.yaml index a048f4e43d..461d1ef4fc 100644 --- a/docs/core-data-structures/core.i18n.yaml +++ b/docs/core-data-structures/core.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/core.md -core.md: eb96988abe096455c4f24ac220a6da3f266e690d -core.zh.md: 7334b3d3a5bd088f5467a72d7357f87c4c745487 +core.md: f7cf288715a3aec2f7037f12fc983e3172a77cef +core.zh.md: c17fd1335503c95e7f7f6f96cc286f567a8384e6 diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index eb96988abe..f7cf288715 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -20,6 +20,7 @@ Everything else is documented on a **sub-page**, not here. The rule that draws t | [llm-streaming.md](llm-streaming.md) | the `StreamChunk` wire protocol + adapter contract, `BlockAssembler`, the `LlmAdapter` seam | | [token-meter.md](token-meter.md) | immutable scalar and positional replay measurements with consumed-log revisions | | [scope.md](scope.md) | scoped registration identity, dispatch carriers, and the owned `Scope` context | +| [typert.md](typert.md) | Remote invocation descriptors, lookup/Context declarations, TypeRT registries, and the Host Gateway/Client API seams | | [goal.md](goal.md) | persisted goal identity, lifecycle snapshots, activation, change records, and round attribution | | [commands.md](commands.md) | the human-command seam: definitions, adapter discovery, direct invocation, results, and parsing views | | [session.md](session.md) | the full `SessionEventMap` variant catalog, `TurnTrigger`/`TurnEndReason`, `deriveMessages()`, execution enclosure, and standalone events | diff --git a/docs/core-data-structures/core.zh.md b/docs/core-data-structures/core.zh.md index 7334b3d3a5..c17fd13355 100644 --- a/docs/core-data-structures/core.zh.md +++ b/docs/core-data-structures/core.zh.md @@ -20,6 +20,7 @@ harness 是一个微内核:一个极小的核心加上众多插件。大多数 | [llm-streaming.md](llm-streaming.md) | `StreamChunk` 协议格式(wire format)+ 适配器契约(adapter contract)、`BlockAssembler`、`LlmAdapter` seam | | [token-meter.md](token-meter.md) | 不可变的标量与位置回放度量,附带已消费日志修订号 | | [scope.md](scope.md) | 作用域注册标识、dispatch 载体,以及拥有的 `Scope` 上下文 | +| [typert.md](typert.md) | Remote 调用 descriptor、lookup/Context 声明、TypeRT 注册表,以及 Host Gateway/Client API seam | | [goal.md](goal.md) | 持久 goal 标识、生命周期快照、激活、变更记录与 Round 归属 | | [commands.md](commands.md) | 人类命令 seam:定义、适配器发现、直接调用、结果与解析视图 | | [session.md](session.md) | 完整的 `SessionEventMap` 变体目录、`TurnTrigger`/`TurnEndReason`、`deriveMessages()`、执行封闭与独立事件 | diff --git a/docs/core-data-structures/typert.i18n.yaml b/docs/core-data-structures/typert.i18n.yaml new file mode 100644 index 0000000000..be40eeb20a --- /dev/null +++ b/docs/core-data-structures/typert.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/core-data-structures/typert.md +typert.md: 9f5c63fc554a43fd0248ed08a64dcff566c83b58 +typert.zh.md: 2b74c8325a510ba39d134fa6d463dab273239772 diff --git a/docs/core-data-structures/typert.md b/docs/core-data-structures/typert.md new file mode 100644 index 0000000000..9f5c63fc55 --- /dev/null +++ b/docs/core-data-structures/typert.md @@ -0,0 +1,196 @@ +# TypeRT remote calls + +English | [中文](typert.zh.md) + +Types shared by generated Remote artifacts, the Host Gateway, and consumer API assemblies. The [TypeRT Gateway Agent Note](../../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md) owns the architecture and transport decisions; this page records the literal public contracts from [`dsh-type-meta`](../../packages/typert/type-meta/src/types.ts) and [`dsh-host-api-gateway`](../../packages/host/api-gateway/src/types.ts). + +## Lookup and Context declarations + +Business-object packages extend two empty maps through declaration merging. A lookup associates one Host object type with its wire identity; a Context declaration associates one scoped Context kind with its wire identity. Generated descriptors name these keys, while runtime providers supply the live resolution behavior. + +```ts type-equiv +/** Merge-extensible Host object lookup declarations. */ +interface TypeRTLookupMap {} +``` + +```ts type-equiv +/** Merge-extensible scoped Context declarations. */ +interface TypeRTContextMap {} +``` + +The registry retains a lookup's wire declaration after its resolver unloads. SRC discovery therefore continues to classify the parameter as a lookup and fails unavailable instead of accepting the wire value as an ordinary business object. + +```ts type-equiv +/** Stable wire declaration retained after a lookup provider unloads. */ +interface TypeRTLookupDefinition { + /** Merge-declared lookup key. */ + readonly key: string + /** Source parameter name recognized by the SRC weak parser. */ + readonly parameter: string + /** Wire field replacing the Host object parameter. */ + readonly wire: string + /** Canonical Host type symbol used by strict generation. */ + readonly hostTypeSymbol: string + /** Canonical wire type symbol used by strict generation. */ + readonly wireTypeSymbol: string +} +``` + +## Invocation descriptors + +An `InvocationDescriptor` is local reflection, not a wire message. Host and consumer builds generate corresponding descriptors; the request sends only the endpoint and named `args`. Strict codecs carry generated schemas, while SRC codecs enforce JSON-safe values without structural type recovery. + +```ts type-equiv +/** Codec attached to one invocation parameter or result. */ +type TypeRTCodec = + | { + readonly mode: 'strict' + readonly typeSymbol: string + readonly schema: TypeRTSchema + } + | { + readonly mode: 'src-json' + } +``` + +```ts type-equiv +/** One ordered business parameter in a Remote invocation. */ +interface InvocationParameterDescriptor { + /** Source-level parameter name. */ + readonly name: string + /** Required key in the wire `args` object. */ + readonly wire: string + /** Whether the value is JSON or requires a registered Host lookup. */ + readonly source: 'json' | 'lookup' + /** Lookup key when `source` is `lookup`. */ + readonly lookup?: string + /** Boundary codec for the wire representation. */ + readonly codec: TypeRTCodec +} +``` + +```ts type-equiv +/** Carrier-independent description of one exported method invocation. */ +interface InvocationDescriptor { + /** Globally stable generated identity. */ + readonly id: string + /** Cordis service key owning the method. */ + readonly service: string + /** Wire namespace, defaulting to the service key. */ + readonly namespace: string + /** Public instance method name. */ + readonly method: string + /** Service member invoked when the exported method name is an alias. */ + readonly implementation?: string + /** Receiver selection mode. */ + readonly invocation: + | { readonly kind: 'direct' } + | { + readonly kind: 'context' + readonly context: string + readonly wire: string + readonly codec: TypeRTCodec + } + /** Optional consuming-Context projection for one direct lookup parameter. */ + readonly scope?: { + /** Context kind whose Client binder supplies the identity. */ + readonly context: string + /** Lookup parameter wire field replaced by the Context identity. */ + readonly wire: string + } + /** Ordered business parameters. */ + readonly parameters: readonly InvocationParameterDescriptor[] + /** Codec for the resolved method result. */ + readonly result: TypeRTCodec + /** Source declaration used only for diagnostics. */ + readonly sourceLocation?: InvocationSourceLocation +} +``` + +## TypeRT registry + +`ctx.typert` separates current-environment descriptors, explicitly selected Remote contributions, live lookup providers, and scoped Context providers. Registrations are Cordis-owned effects and return awaitable disposers. + +```ts type-equiv +/** Minimal TypeRT runtime consumed through dependency inversion. */ +interface TypeRTService { + readonly local: TypeRTLocalRegistry + readonly remotes: TypeRTRemoteRegistry + readonly lookups: TypeRTLookupRegistry + readonly contexts: TypeRTContextRegistry +} +``` + +Generated consumer declarations merge direct namespaces into the map inherited by `ClientApi`. + +```ts type-equiv +/** Merge-extensible direct namespace surface generated for Client API services. */ +interface TypeRTRemoteNamespaceMap {} +``` + +## Host Gateway + +Connection decodes its carrier envelope before calling `ctx.typertGateway`. The request carries exact named wire fields; infrastructure and boundary failures use the Gateway's in-process error taxonomy, although the current RPC adapter folds them into the transport's `internal` error code. + +```ts type-equiv +/** One Remote method request after a carrier has decoded its envelope. */ +interface InvokeRemoteRequest { + /** Remote namespace selected by the generated descriptor. */ + readonly namespace: string + /** Exported Service method name. */ + readonly method: string + /** Named wire values; fields must exactly match the descriptor. */ + readonly args: Readonly> +} +``` + +```ts type-equiv +/** Stable infrastructure and boundary failures emitted before or after business execution. */ +type TypertGatewayErrorCode = + | 'ambiguous-endpoint' + | 'arguments-invalid' + | 'binding-invalid' + | 'context-failed' + | 'context-not-found' + | 'context-unavailable' + | 'definition-unavailable' + | 'input-invalid' + | 'invocation-unavailable' + | 'lookup-failed' + | 'lookup-not-found' + | 'lookup-unavailable' + | 'method-unavailable' + | 'provider-mismatch' + | 'result-invalid' + | 'service-unavailable' + | 'signature-invalid' +``` + +```ts type-equiv +/** Host dispatcher consumed by Connection adapters. */ +interface TypertGateway { + /** + * Invoke one live Remote method without assuming a carrier or response envelope. + * @param request - decoded endpoint and named wire arguments. + * @returns the validated business result. + * @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; business errors retain their identity. + */ + invoke(request: InvokeRemoteRequest): Promise +} +``` + +## Consumer API + +`ctx.api` exposes only namespaces contributed by imported `/remote` artifacts. Mounting installs the generated descriptors and concrete root/scoped methods as one fiber-owned operation; no JavaScript Proxy or Host Service type enters the consumer. + +```ts type-equiv +/** Typed API service augmented by generated direct Remote namespaces. */ +interface ClientApi extends TypeRTRemoteNamespaceMap { + /** + * Mount one generated Host-for-Client contribution in the caller's fiber. + * @param contribution - explicitly selected Remote package artifact. + * @returns disposer withdrawing descriptors and concrete methods together. + */ + mount(contribution: TypeRTRemoteContribution): TypeRTDisposer +} +``` diff --git a/docs/core-data-structures/typert.zh.md b/docs/core-data-structures/typert.zh.md new file mode 100644 index 0000000000..2b74c8325a --- /dev/null +++ b/docs/core-data-structures/typert.zh.md @@ -0,0 +1,196 @@ +# TypeRT 远程调用 + +[English](typert.md) | 中文 + +以下类型由生成的 Remote 产物、Host Gateway 与消费方 API assembly 共用。[TypeRT Gateway Agent Note](../../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md) 负责架构与传输决策;本页记录 [`dsh-type-meta`](../../packages/typert/type-meta/src/types.ts) 和 [`dsh-host-api-gateway`](../../packages/host/api-gateway/src/types.ts) 中公共契约的字面定义。 + +## Lookup 与 Context 声明 + +业务对象包通过声明合并扩展两个空 map。lookup 将一种 Host 对象类型与其 wire identity 关联;Context 声明将一种 scoped Context 类别与其 wire identity 关联。生成的 descriptor 引用这些 key,运行时提供方则提供活对象解析行为。 + +```ts type-equiv +/** Merge-extensible Host object lookup declarations. */ +interface TypeRTLookupMap {} +``` + +```ts type-equiv +/** Merge-extensible scoped Context declarations. */ +interface TypeRTContextMap {} +``` + +lookup 的 resolver 卸载后,注册表仍会保留其 wire 声明。因此 SRC 发现过程会继续把该参数归类为 lookup,并因不可用而失败,而不会把 wire 值当作普通业务对象接受。 + +```ts type-equiv +/** Stable wire declaration retained after a lookup provider unloads. */ +interface TypeRTLookupDefinition { + /** Merge-declared lookup key. */ + readonly key: string + /** Source parameter name recognized by the SRC weak parser. */ + readonly parameter: string + /** Wire field replacing the Host object parameter. */ + readonly wire: string + /** Canonical Host type symbol used by strict generation. */ + readonly hostTypeSymbol: string + /** Canonical wire type symbol used by strict generation. */ + readonly wireTypeSymbol: string +} +``` + +## 调用 descriptor + +`InvocationDescriptor` 是本地反射信息,不是 wire message。Host 与消费方构建会生成彼此对应的 descriptor;请求只发送 endpoint 与具名 `args`。strict codec 携带生成的 schema,SRC codec 则在不恢复结构类型的前提下强制要求 JSON 安全值。 + +```ts type-equiv +/** Codec attached to one invocation parameter or result. */ +type TypeRTCodec = + | { + readonly mode: 'strict' + readonly typeSymbol: string + readonly schema: TypeRTSchema + } + | { + readonly mode: 'src-json' + } +``` + +```ts type-equiv +/** One ordered business parameter in a Remote invocation. */ +interface InvocationParameterDescriptor { + /** Source-level parameter name. */ + readonly name: string + /** Required key in the wire `args` object. */ + readonly wire: string + /** Whether the value is JSON or requires a registered Host lookup. */ + readonly source: 'json' | 'lookup' + /** Lookup key when `source` is `lookup`. */ + readonly lookup?: string + /** Boundary codec for the wire representation. */ + readonly codec: TypeRTCodec +} +``` + +```ts type-equiv +/** Carrier-independent description of one exported method invocation. */ +interface InvocationDescriptor { + /** Globally stable generated identity. */ + readonly id: string + /** Cordis service key owning the method. */ + readonly service: string + /** Wire namespace, defaulting to the service key. */ + readonly namespace: string + /** Public instance method name. */ + readonly method: string + /** Service member invoked when the exported method name is an alias. */ + readonly implementation?: string + /** Receiver selection mode. */ + readonly invocation: + | { readonly kind: 'direct' } + | { + readonly kind: 'context' + readonly context: string + readonly wire: string + readonly codec: TypeRTCodec + } + /** Optional consuming-Context projection for one direct lookup parameter. */ + readonly scope?: { + /** Context kind whose Client binder supplies the identity. */ + readonly context: string + /** Lookup parameter wire field replaced by the Context identity. */ + readonly wire: string + } + /** Ordered business parameters. */ + readonly parameters: readonly InvocationParameterDescriptor[] + /** Codec for the resolved method result. */ + readonly result: TypeRTCodec + /** Source declaration used only for diagnostics. */ + readonly sourceLocation?: InvocationSourceLocation +} +``` + +## TypeRT 注册表 + +`ctx.typert` 分开保存当前环境的 descriptor、显式选择的 Remote contribution、活 lookup 提供方与 scoped Context 提供方。各项注册都是由 Cordis 持有的 effect,并返回可等待的 disposer。 + +```ts type-equiv +/** Minimal TypeRT runtime consumed through dependency inversion. */ +interface TypeRTService { + readonly local: TypeRTLocalRegistry + readonly remotes: TypeRTRemoteRegistry + readonly lookups: TypeRTLookupRegistry + readonly contexts: TypeRTContextRegistry +} +``` + +生成的消费方声明会把 direct namespace 合并到 `ClientApi` 继承的 map 中。 + +```ts type-equiv +/** Merge-extensible direct namespace surface generated for Client API services. */ +interface TypeRTRemoteNamespaceMap {} +``` + +## Host Gateway + +Connection 会先解码 carrier envelope,再调用 `ctx.typertGateway`。请求携带精确的具名 wire 字段;基础设施与边界失败使用 Gateway 的进程内错误分类体系,但当前 RPC 适配器会把这些错误折叠为传输层的 `internal` 错误码。 + +```ts type-equiv +/** One Remote method request after a carrier has decoded its envelope. */ +interface InvokeRemoteRequest { + /** Remote namespace selected by the generated descriptor. */ + readonly namespace: string + /** Exported Service method name. */ + readonly method: string + /** Named wire values; fields must exactly match the descriptor. */ + readonly args: Readonly> +} +``` + +```ts type-equiv +/** Stable infrastructure and boundary failures emitted before or after business execution. */ +type TypertGatewayErrorCode = + | 'ambiguous-endpoint' + | 'arguments-invalid' + | 'binding-invalid' + | 'context-failed' + | 'context-not-found' + | 'context-unavailable' + | 'definition-unavailable' + | 'input-invalid' + | 'invocation-unavailable' + | 'lookup-failed' + | 'lookup-not-found' + | 'lookup-unavailable' + | 'method-unavailable' + | 'provider-mismatch' + | 'result-invalid' + | 'service-unavailable' + | 'signature-invalid' +``` + +```ts type-equiv +/** Host dispatcher consumed by Connection adapters. */ +interface TypertGateway { + /** + * Invoke one live Remote method without assuming a carrier or response envelope. + * @param request - decoded endpoint and named wire arguments. + * @returns the validated business result. + * @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; business errors retain their identity. + */ + invoke(request: InvokeRemoteRequest): Promise +} +``` + +## 消费方 API + +`ctx.api` 只暴露由已导入 `/remote` 产物贡献的 namespace。挂载会把生成的 descriptor 与具体的 root/scoped 方法作为一项由 fiber 持有的操作统一注册;JavaScript Proxy 与 Host 服务类型都不会进入消费方。 + +```ts type-equiv +/** Typed API service augmented by generated direct Remote namespaces. */ +interface ClientApi extends TypeRTRemoteNamespaceMap { + /** + * Mount one generated Host-for-Client contribution in the caller's fiber. + * @param contribution - explicitly selected Remote package artifact. + * @returns disposer withdrawing descriptors and concrete methods together. + */ + mount(contribution: TypeRTRemoteContribution): TypeRTDisposer +} +``` diff --git a/package.json b/package.json index 9d0cac6d5e..a327e36ec3 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "build:web": "pnpm --filter @deepseek-ai/dsh-frontend run build", "clean": "tsx scripts/clean.ts", "change-scope": "tsx scripts/change-scope.ts", - "typecheck": "tsc -b", + "typecheck": "npm run build:lib:contracts && tsc -b", "lint": "tsx scripts/run-oxlint.ts .", "lint:fix": "eslint --config eslint.format.config.mjs --fix . && tsx scripts/run-oxlint.ts . --fix", "duplication": "jscpd --config .jscpd.json packages scripts", diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 48f4aadeed..4fe2b12323 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -3097,7 +3097,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'TypertContribution', - declaration: 'export interface TypertContribution {\n readonly package: string;\n readonly face: TypertFace;\n readonly schemas: readonly TypertSchema[];\n readonly model: TypertPackageModel;\n readonly invocations?: readonly InvocationDescriptor[];\n}', + declaration: 'export interface TypertContribution {\n readonly package: string;\n readonly face: TypertFace;\n readonly schemas: readonly TypertSchema[];\n readonly model: TypertPackageModel;\n readonly invocations: readonly InvocationDescriptor[];\n}', }, { name: 'TypeRTDisposer', diff --git a/packages/host/api-gateway/src/client/index.ts b/packages/host/api-gateway/src/client/index.ts index 1f92bc0748..5cd8ab75d1 100644 --- a/packages/host/api-gateway/src/client/index.ts +++ b/packages/host/api-gateway/src/client/index.ts @@ -284,6 +284,7 @@ class ScopedRemoteNamespace extends Service { install(descriptor: InvocationDescriptor, projection: ScopedProjection, token: MountToken): void { this.assertMethodAvailable(descriptor.method) + if (this.methods.size === 0) this.ownerCtx.set(this.name, this) const method = descriptor.method Object.defineProperty(this, method, { configurable: true, diff --git a/packages/host/api-gateway/src/index.ts b/packages/host/api-gateway/src/index.ts index 2adfaa8387..64d5715719 100644 --- a/packages/host/api-gateway/src/index.ts +++ b/packages/host/api-gateway/src/index.ts @@ -12,7 +12,6 @@ import { type InvocationParameterDescriptor, type TypeRTCodec, type TypeRTGatewayBinding, - type TypeRTLookupProvider, } from '@deepseek-ai/dsh-type-meta' import type { InvokeRemoteRequest, @@ -149,6 +148,7 @@ export class TypertGatewayService extends Service implements TypertGateway { payload: unknown, _signal: AbortSignal, ): Promise { + // Remote methods have no cancellation parameter yet, so disconnects do not cancel business work. return this.invokeRpc(endpoint, payload) } @@ -229,10 +229,8 @@ export class TypertGatewayService extends Service implements TypertGateway { const parameters: InvocationParameterDescriptor[] = [] const wires = new Set() for (const name of names) { - const matches = this.ctx.typert.lookups.keys() - .map(key => ({ key, provider: this.ctx.typert.lookups.get(key) })) - .filter((entry): entry is { key: string; provider: TypeRTLookupProvider } => - entry.provider?.parameter === name) + const matches = this.ctx.typert.lookups.definitions() + .filter(definition => definition.parameter === name) if (matches.length > 1) { throw new TypertGatewayError( 'signature-invalid', @@ -246,7 +244,7 @@ export class TypertGatewayService extends Service implements TypertGateway { ? { name, wire: name, source: 'json', codec: { mode: 'src-json' } } : { name, - wire: match.provider.wire, + wire: match.wire, source: 'lookup', lookup: match.key, codec: { mode: 'src-json' }, @@ -540,7 +538,7 @@ function decode( field: string, ): unknown { try { - if (codec.mode === 'strict') return codec.schema.parse(value) + if (codec.mode === 'strict') value = codec.schema.parse(value) assertJsonValue(value, new Set()) return value } catch (cause) { diff --git a/packages/host/api-gateway/tests/client.spec.ts b/packages/host/api-gateway/tests/client.spec.ts index ab08ef09bc..2e00d29c0d 100644 --- a/packages/host/api-gateway/tests/client.spec.ts +++ b/packages/host/api-gateway/tests/client.spec.ts @@ -204,7 +204,13 @@ describe('Client TypeRT API', () => { }) it('rejects duplicate, live, scoped-service, and Context namespace collisions', async () => { - const ctx = await bench(vi.fn()) + const call = vi.fn() + .mockResolvedValue({ ok: true, value: { renamed: true } }) + const ctx = await bench(call) + const agentCtx = ctx.extend({ fixtureId: 'agent-remounted' }) as FixtureContext + ctx.typert.contexts.registerClient('fixture', { + identity: candidate => (candidate as Context & { fixtureId?: string }).fixtureId, + }) const direct = directDescriptor() const context = contextDescriptor() @@ -242,6 +248,13 @@ describe('Client TypeRT API', () => { package: '@fixture/multiple-scoped', descriptors: [directDescriptor(), contextDescriptor()], }) + await expect(agentCtx.goals.rename({ objective: 'remounted' })).resolves.toEqual({ renamed: true }) + expect(call).toHaveBeenLastCalledWith( + '/api', + 'goals/rename', + { args: { agentId: 'agent-remounted', request: { objective: 'remounted' } } }, + expect.any(AbortSignal), + ) await disposeMultipleScoped() }) diff --git a/packages/host/api-gateway/tests/gateway.spec.ts b/packages/host/api-gateway/tests/gateway.spec.ts index d5a3f9a8ee..4aeadeedb8 100644 --- a/packages/host/api-gateway/tests/gateway.spec.ts +++ b/packages/host/api-gateway/tests/gateway.spec.ts @@ -370,6 +370,19 @@ describe('TypertGatewayService', () => { })).resolves.toEqual({ agentId: 'agent-1', title: 'ship', scope: 'direct-src' }) }) + it('does not downgrade an observed SRC lookup after its provider unloads', async () => { + const { ctx, service } = await setup() + const dispose = registerAgentLookup(ctx, { id: 'agent-1' }) + await dispose() + + await expectCode(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'create', + args: { agentId: 'agent-1', request: { title: 'ship' } }, + }), 'lookup-unavailable') + expect(service.calls).toEqual([]) + }) + it('derives SRC Remote Context identity and preserves the scoped Proxy receiver', async () => { const { ctx } = await setup() const scoped = ctx.extend({ fixtureScope: 'agent-src' }) @@ -657,6 +670,22 @@ describe('TypertGatewayService', () => { }), 'result-invalid') }) + it('rejects non-JSON values after strict codec validation', async () => { + const { ctx, service } = await setup() + const descriptor = strictOnlyDescriptor() + registerStrict(ctx, [{ + ...descriptor, + result: strictCodec('@fixture/gateway#UnknownResult', z.unknown()), + }]) + service.nextResult = 1n + + await expectCode(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'strictOnly', + args: { request: { title: 'ship' } }, + }), 'result-invalid') + }) + it.each([ undefined, Number.NaN, diff --git a/packages/typert/generator/src/analyzer.ts b/packages/typert/generator/src/analyzer.ts index 5757d7cef5..f430d757fb 100644 --- a/packages/typert/generator/src/analyzer.ts +++ b/packages/typert/generator/src/analyzer.ts @@ -1318,6 +1318,8 @@ class FaceAnalyzer { * type evaluator. */ private resolvedRemoteCodecType(authoredType: ts.TypeNode): TypeNodeId { + const resolvedType = this.checker.getTypeFromTypeNode(authoredType) + this.assertRemoteJsonType(resolvedType, authoredType, new Set(), false) const completed = new Map() const active = new Map() const recursiveDeclarations = new Map() @@ -1474,7 +1476,107 @@ class FaceAnalyzer { active.delete(type) } } - return convert(this.checker.getTypeFromTypeNode(authoredType)) + return convert(resolvedType) + } + + private assertRemoteJsonType( + type: ts.Type, + site: ts.TypeNode, + active: Set, + allowUndefined: boolean, + ): void { + const flags = type.flags + if ((flags & ts.TypeFlags.Undefined) !== 0 && allowUndefined) return + if ((flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) !== 0) { + this.fail(site, `Remote boundary contains unconstrained ${this.checker.typeToString(type)} data`) + } + if ((flags & (ts.TypeFlags.BigIntLike | ts.TypeFlags.ESSymbolLike | ts.TypeFlags.Undefined | ts.TypeFlags.Void)) !== 0) { + this.fail(site, `Remote boundary contains non-JSON type ${this.checker.typeToString(type)}`) + } + if ((flags & (ts.TypeFlags.StringLike + | ts.TypeFlags.NumberLike + | ts.TypeFlags.BooleanLike + | ts.TypeFlags.Null + | ts.TypeFlags.Never)) !== 0) return + if (type.isUnion()) { + for (const member of type.types) this.assertRemoteJsonType(member, site, active, allowUndefined) + return + } + if (type.isIntersection()) { + const material = type.types.filter(member => !this.isRemotePhantomConstraint(member)) + if (material.length === 0) this.fail(site, 'Remote boundary contains a symbol-only object') + for (const member of material) this.assertRemoteJsonType(member, site, active, false) + return + } + if ((flags & ts.TypeFlags.TypeParameter) !== 0) { + this.fail(site, 'Remote boundary contains an unresolved type parameter') + } + if ((flags & ts.TypeFlags.Object) === 0) { + this.fail(site, `Remote boundary contains non-JSON type ${this.checker.typeToString(type)}`) + } + const symbol = type.getSymbol() + const declaration = symbol?.valueDeclaration ?? symbol?.declarations?.[0] + if (declaration !== undefined && (ts.isClassDeclaration(declaration) || ts.isClassExpression(declaration))) { + this.fail(site, `Remote boundary contains class instance ${symbol?.name ?? this.checker.typeToString(type)}`) + } + if (type.getCallSignatures().length > 0 || type.getConstructSignatures().length > 0) { + this.fail(site, 'Remote boundary contains callable or constructable data') + } + if (active.has(type)) return + active.add(type) + try { + if (this.checker.isTupleType(type)) { + const reference = type as ts.TypeReference + const target = reference.target as ts.TupleType + const arguments_ = this.checker.getTypeArguments(reference) + arguments_.forEach((argument, index) => { + const elementFlags = target.elementFlags[index] ?? ts.ElementFlags.Required + this.assertRemoteJsonType( + argument, + site, + active, + (elementFlags & ts.ElementFlags.Optional) !== 0, + ) + }) + return + } + if (this.checker.isArrayType(type) || this.checker.isArrayLikeType(type)) { + const element = this.checker.getIndexTypeOfType(type, ts.IndexKind.Number) + if (element === undefined) this.fail(site, 'Remote boundary array has no element type') + this.assertRemoteJsonType(element, site, active, false) + return + } + const properties = this.checker.getPropertiesOfType(type) + if (properties.some(property => property.getName().startsWith('__@'))) { + this.fail(site, 'Remote boundary contains a symbol-keyed property') + } + for (const property of properties) { + const propertyDeclaration = property.valueDeclaration ?? property.declarations?.[0] + const propertyType = this.checker.getTypeOfSymbolAtLocation(property, propertyDeclaration ?? site) + this.assertRemoteJsonType( + propertyType, + site, + active, + (property.flags & ts.SymbolFlags.Optional) !== 0, + ) + } + for (const info of this.checker.getIndexInfosOfType(type)) { + if ((info.keyType.flags & ts.TypeFlags.ESSymbolLike) !== 0) { + this.fail(site, 'Remote boundary contains a symbol index signature') + } + this.assertRemoteJsonType(info.type, site, active, false) + } + } finally { + active.delete(type) + } + } + + private isRemotePhantomConstraint(type: ts.Type): boolean { + if ((type.flags & ts.TypeFlags.Unknown) !== 0) return true + if ((type.flags & ts.TypeFlags.Any) !== 0 || (type.flags & ts.TypeFlags.Object) === 0) return false + if (type.getCallSignatures().length > 0 || type.getConstructSignatures().length > 0) return false + if (this.checker.getIndexInfosOfType(type).length > 0) return false + return this.checker.getPropertiesOfType(type).every(property => property.getName().startsWith('__@')) } private resolvedCycleReference( diff --git a/packages/typert/generator/tests/remote-model.spec.ts b/packages/typert/generator/tests/remote-model.spec.ts index 90056e673e..cb6e6e6060 100644 --- a/packages/typert/generator/tests/remote-model.spec.ts +++ b/packages/typert/generator/tests/remote-model.spec.ts @@ -284,6 +284,32 @@ export type GenericResult = { expect(() => analyzeRemote(root, false)).toThrow(/non-JSON class parameter Agent requires a TypeRTLookupMap entry/) }) + it.each([ + ['bigint', 'bigint'], + ['symbol', 'symbol'], + ['undefined', 'undefined'], + ['any', 'unconstrained any'], + ['unknown', 'unconstrained unknown'], + ])('rejects non-JSON Remote boundary type %s', (type, message) => { + const root = copyFixture() + editFile(root, 'packages/remote/src/types.ts', source => source.replace( + ' readonly title: string\n}', + ` readonly title: string\n readonly invalid: ${type}\n}`, + )) + + expect(() => analyzeRemote(root, false)).toThrow(new RegExp(message)) + }) + + it('keeps optional JSON object fields valid', () => { + const root = copyFixture() + editFile(root, 'packages/remote/src/types.ts', source => source.replace( + ' readonly title: string\n}', + ' readonly title: string\n readonly note?: string\n}', + )) + + expect(() => analyzeRemote(root)).not.toThrow() + }) + it('rejects a Remote Context without a static Context declaration', () => { const root = copyFixture() editFile(root, 'packages/remote/src/index.ts', source => source.replace("@RemoteContext('agent')", "@RemoteContext('missing')")) diff --git a/packages/typert/loader/src/index.ts b/packages/typert/loader/src/index.ts index fee1098340..efe0fa6f94 100644 --- a/packages/typert/loader/src/index.ts +++ b/packages/typert/loader/src/index.ts @@ -135,10 +135,8 @@ export function validateTypertManifest(pkgName: string, exported: unknown): Type requireMembers(pkgName, object.members, `object "${object.name as string}"`) requireTypes(pkgName, object.types, `object "${object.name as string}"`) } - if (manifest.invocations !== undefined) { - for (const value of requireArray(pkgName, manifest.invocations, 'TYPERT.invocations')) { - requireInvocation(pkgName, value) - } + for (const value of requireArray(pkgName, manifest.invocations, 'TYPERT.invocations')) { + requireInvocation(pkgName, value) } return manifest as unknown as TypertContribution } diff --git a/packages/typert/loader/tests/loader.spec.ts b/packages/typert/loader/tests/loader.spec.ts index 1e7e553605..ec407f82d9 100644 --- a/packages/typert/loader/tests/loader.spec.ts +++ b/packages/typert/loader/tests/loader.spec.ts @@ -60,6 +60,7 @@ function typertSource(pkgName: string, entryName: string): string { ' face: \'host\',', ` schemas: [{ name: '${entryName}', schema: ${entryName} }],`, ' model: { services: [], events: [], objects: [] },', + ' invocations: [],', '}', '', ].join('\n') @@ -262,6 +263,7 @@ describe('typert loader', () => { ' face: \'host\',', ' schemas: [{ name: \'Pending\', schema: Pending }],', ' model: { services: [], events: [], objects: [] },', + ' invocations: [],', '}', '', ].join('\n'), @@ -295,7 +297,7 @@ describe('typert loader', () => { root = await mkdtemp(join(tmpdir(), 'dsh-typert-loader-')) await linkZod(root) await writePackage(root, '@fixture/broken', { - typertSource: 'export const TYPERT = { package: \'@fixture/broken\', face: \'host\', schemas: [{ name: \'\', schema: {} }], model: { services: [], events: [], objects: [] } }\n', + typertSource: 'export const TYPERT = { package: \'@fixture/broken\', face: \'host\', schemas: [{ name: \'\', schema: {} }], model: { services: [], events: [], objects: [] }, invocations: [] }\n', }) const ctx = await boot() await ctx.loader.create({ name: '@fixture/broken' }) @@ -410,6 +412,7 @@ describe('validateTypertManifest', () => { face: 'host', schemas: [{ name: 'A', schema: zodish }], model: { services: [], events: [], objects: [] }, + invocations: [], }).schemas).toHaveLength(1) expect(() => validateTypertManifest('pkg', undefined)).toThrow('no TYPERT manifest object') @@ -490,12 +493,14 @@ describe('validateTypertManifest', () => { })).toThrow('object has a missing or empty exportName') }) - it('validates strict invocation descriptors and accepts legacy manifests without them', () => { - const legacy = completeManifest(zodish) - expect(validateTypertManifest('pkg', legacy)).toBe(legacy) + it('requires and validates strict invocation descriptors', () => { + const base = completeManifest(zodish) + const { invocations: _invocations, ...missingInvocations } = base + expect(() => validateTypertManifest('pkg', missingInvocations)) + .toThrow('TYPERT.invocations must be an array') const descriptor = strictInvocation() - const manifest = { ...legacy, invocations: [descriptor] } + const manifest = { ...base, invocations: [descriptor] } expect(validateTypertManifest('pkg', manifest)).toBe(manifest) const scoped = { ...descriptor, @@ -508,53 +513,53 @@ describe('validateTypertManifest', () => { codec: strictCodec('pkg#AgentId'), }, ...descriptor.parameters], } - expect(validateTypertManifest('pkg', { ...legacy, invocations: [scoped] }).invocations) + expect(validateTypertManifest('pkg', { ...base, invocations: [scoped] }).invocations) .toEqual([scoped]) - expect(() => validateTypertManifest('pkg', { ...legacy, invocations: {} })) + expect(() => validateTypertManifest('pkg', { ...base, invocations: {} })) .toThrow('TYPERT.invocations must be an array') expect(() => validateTypertManifest('pkg', { - ...legacy, + ...base, invocations: [{ ...descriptor, invocation: { kind: 'future' } }], })).toThrow('receiver kind must be "direct" or "context"') expect(() => validateTypertManifest('pkg', { - ...legacy, + ...base, invocations: [{ ...descriptor, result: { mode: 'src-json' } }], })).toThrow('result codec must use a strict codec') expect(() => validateTypertManifest('pkg', { - ...legacy, + ...base, invocations: [{ ...descriptor, result: { mode: 'strict', typeSymbol: 'pkg#Result', schema: zodish } }], })).toThrow('result codec is not backed by a zod v4 schema') expect(() => validateTypertManifest('pkg', { - ...legacy, + ...base, invocations: [{ ...descriptor, parameters: [{ ...descriptor.parameters[0], source: 'future' }], }], })).toThrow('parameter source must be "json" or "lookup"') expect(() => validateTypertManifest('pkg', { - ...legacy, + ...base, invocations: [{ ...descriptor, parameters: [{ ...descriptor.parameters[0], source: 'lookup' }], }], })).toThrow('lookup parameter has a missing or empty lookup') expect(() => validateTypertManifest('pkg', { - ...legacy, + ...base, invocations: [{ ...descriptor, parameters: [{ ...descriptor.parameters[0], lookup: 'agent' }], }], })).toThrow('JSON parameter declares a lookup') expect(() => validateTypertManifest('pkg', { - ...legacy, + ...base, invocations: [{ ...descriptor, parameters: [descriptor.parameters[0], { ...descriptor.parameters[0], name: 'again' }], }], })).toThrow('repeats wire field "request"') expect(() => validateTypertManifest('pkg', { - ...legacy, + ...base, invocations: [{ ...descriptor, invocation: { @@ -566,19 +571,19 @@ describe('validateTypertManifest', () => { }], })).toThrow('repeats Context wire field "request"') expect(() => validateTypertManifest('pkg', { - ...legacy, + ...base, invocations: [{ ...scoped, scope: null }], })).toThrow('scope must be an object') expect(() => validateTypertManifest('pkg', { - ...legacy, + ...base, invocations: [{ ...scoped, scope: { wire: 'agentId' } }], })).toThrow('scope has a missing or empty context') expect(() => validateTypertManifest('pkg', { - ...legacy, + ...base, invocations: [{ ...scoped, scope: { context: 'agent' } }], })).toThrow('scope has a missing or empty wire') expect(() => validateTypertManifest('pkg', { - ...legacy, + ...base, invocations: [{ ...scoped, invocation: { @@ -590,11 +595,11 @@ describe('validateTypertManifest', () => { }], })).toThrow('Context receiver cannot declare a direct scope projection') expect(() => validateTypertManifest('pkg', { - ...legacy, + ...base, invocations: [{ ...scoped, scope: { context: 'agent', wire: 'missingId' } }], })).toThrow('must select its only lookup parameter') expect(() => validateTypertManifest('pkg', { - ...legacy, + ...base, invocations: [{ ...scoped, parameters: [...scoped.parameters, { @@ -607,11 +612,11 @@ describe('validateTypertManifest', () => { }], })).toThrow('must select its only lookup parameter') expect(() => validateTypertManifest('pkg', { - ...legacy, + ...base, invocations: [{ ...scoped, scope: { context: 'other', wire: 'agentId' } }], })).toThrow('must select its only lookup parameter') expect(() => validateTypertManifest('pkg', { - ...legacy, + ...base, invocations: [{ ...descriptor, sourceLocation: { file: 'src/index.ts', line: 0, column: 1 } }], })).toThrow('sourceLocation.line must be a positive integer') }) @@ -646,6 +651,7 @@ function completeManifest(zodish: object) { package: 'pkg', face: 'host', schemas: [{ name: 'Schema', schema: zodish }], + invocations: [], model: { services: [{ key: 'service', diff --git a/packages/typert/registry/src/service.ts b/packages/typert/registry/src/service.ts index 4973732fad..6749cdbeb9 100644 --- a/packages/typert/registry/src/service.ts +++ b/packages/typert/registry/src/service.ts @@ -17,6 +17,7 @@ import type { TypeRTHostContextProvider, TypeRTLocalRegistry, TypeRTLookupHost, + TypeRTLookupDefinition, TypeRTLookupMap, TypeRTLookupProvider, TypeRTLookupRegistry, @@ -212,6 +213,7 @@ class RemoteStore { class LookupStore { private readonly providers = new Map>() + private readonly definitions = new Map() private readonly changes: ChangeSource constructor(report: ReportObserverError) { @@ -228,6 +230,7 @@ class LookupStore { >, ) => this.register(ctx, key, provider), get: key => this.providers.get(key)?.provider, + definitions: () => [...this.definitions.values()], keys: () => [...this.providers.keys()], subscribe: listener => this.changes.subscribe(ctx, listener), } @@ -240,10 +243,22 @@ class LookupStore { validateNonempty('lookup Host type symbol', provider.hostTypeSymbol) validateNonempty('lookup wire type symbol', provider.wireTypeSymbol) if (this.providers.has(key)) throw new Error(`typert: lookup "${key}" is already registered`) + const definition: TypeRTLookupDefinition = { + key, + parameter: provider.parameter, + wire: provider.wire, + hostTypeSymbol: provider.hostTypeSymbol, + wireTypeSymbol: provider.wireTypeSymbol, + } + const known = this.definitions.get(key) + if (known !== undefined && !lookupDefinitionEquals(known, definition)) { + throw new Error(`typert: lookup "${key}" changed its wire declaration during this registry lifetime`) + } const owner = {} const entry: ProviderEntry = { provider, owner } - const { providers, changes } = this + const { definitions, providers, changes } = this return ctx.effect(function* () { + definitions.set(key, definition) providers.set(key, entry) changes.emit({ kind: 'lookup', key }) yield () => { @@ -256,6 +271,13 @@ class LookupStore { } } +function lookupDefinitionEquals(left: TypeRTLookupDefinition, right: TypeRTLookupDefinition): boolean { + return left.parameter === right.parameter + && left.wire === right.wire + && left.hostTypeSymbol === right.hostTypeSymbol + && left.wireTypeSymbol === right.wireTypeSymbol +} + class ContextStore { private readonly hosts = new Map>() private readonly clients = new Map>() @@ -377,7 +399,7 @@ export class TypertRegistry extends Service implements TypeRTService { register(contribution: TypertContribution): TypeRTDisposer { const packageRecord = this.validatePackage(contribution) const schemaRecords = this.validateSchemas(contribution) - const invocations = contribution.invocations ?? [] + const invocations = contribution.invocations this.localStore.validate(invocations) const owner = {} const { schemas, packages, localStore } = this diff --git a/packages/typert/registry/src/types.ts b/packages/typert/registry/src/types.ts index 6ba0e0f1f2..4dcfc4b7a1 100644 --- a/packages/typert/registry/src/types.ts +++ b/packages/typert/registry/src/types.ts @@ -83,12 +83,7 @@ export interface TypertContribution { readonly face: TypertFace readonly schemas: readonly TypertSchema[] readonly model: TypertPackageModel - /** Host invocation definitions; absent on artifacts generated before Remote support. */ - readonly invocations?: readonly InvocationDescriptor[] -} - -/** Generated Host contribution with strict Remote invocation definitions. */ -export interface TypertLocalContribution extends TypertContribution { + /** Host invocation definitions, empty when the package exports no Remote methods. */ readonly invocations: readonly InvocationDescriptor[] } diff --git a/packages/typert/registry/tests/typert.spec.ts b/packages/typert/registry/tests/typert.spec.ts index 95f8bc871f..51e7594749 100644 --- a/packages/typert/registry/tests/typert.spec.ts +++ b/packages/typert/registry/tests/typert.spec.ts @@ -36,6 +36,7 @@ function toolsContribution(schema: z.ZodType = z.object({ name: z.string() })): package: '@deepseek-ai/dsh-tools', face: 'host', schemas: [{ name: 'ToolInput', schema }], + invocations: [], model: { services: [{ key: 'tools', @@ -329,11 +330,19 @@ describe('TypertRegistry', () => { }) expect(ctx.typert.lookups.get('fixture')?.resolve('agent-1')).toBe(object) + expect(ctx.typert.lookups.definitions()).toEqual([{ + key: 'fixture', + parameter: 'agent', + wire: 'agentId', + hostTypeSymbol: '@fixture/agent#Agent', + wireTypeSymbol: '@fixture/session#SessionId', + }]) expect(ctx.typert.contexts.getHost('registryFixture')?.resolve('agent-1')).toBe(scoped) expect(ctx.typert.contexts.getClient('registryFixture')?.identity(scoped)).toBe('agent-1') await Promise.all([disposeClient(), disposeHost(), disposeLookup()]) expect(ctx.typert.lookups.keys()).toEqual([]) + expect(ctx.typert.lookups.definitions()).toHaveLength(1) expect(ctx.typert.contexts.getHost('registryFixture')).toBeUndefined() expect(ctx.typert.contexts.getClient('registryFixture')).toBeUndefined() }) @@ -378,6 +387,15 @@ describe('TypertRegistry', () => { ]) await Promise.all([disposeLookupSubscription(), disposeContextSubscription()]) + for (const changed of [ + { ...lookup, parameter: 'session' }, + { ...lookup, wire: 'sessionId' }, + { ...lookup, hostTypeSymbol: '@fixture#Session' }, + { ...lookup, wireTypeSymbol: '@fixture#SessionId' }, + ]) { + expect(() => ctx.typert.lookups.register('fixture', changed)) + .toThrow('changed its wire declaration during this registry lifetime') + } ctx.typert.lookups.register('fixture', lookup) expect(changes).toHaveLength(6) }) diff --git a/packages/typert/type-meta/src/index.ts b/packages/typert/type-meta/src/index.ts index 1e79bb2e55..92438ee0fa 100644 --- a/packages/typert/type-meta/src/index.ts +++ b/packages/typert/type-meta/src/index.ts @@ -20,6 +20,7 @@ export type { TypeRTHostContextProvider, TypeRTLocalRegistry, TypeRTLookup, + TypeRTLookupDefinition, TypeRTLookupHost, TypeRTLookupMap, TypeRTLookupProvider, diff --git a/packages/typert/type-meta/src/types.ts b/packages/typert/type-meta/src/types.ts index 87ab091075..f9ed7ffa97 100644 --- a/packages/typert/type-meta/src/types.ts +++ b/packages/typert/type-meta/src/types.ts @@ -189,6 +189,20 @@ export interface TypeRTLookupProvider { resolve(id: Wire): Host | undefined } +/** Stable wire declaration retained after a lookup provider unloads. */ +export interface TypeRTLookupDefinition { + /** Merge-declared lookup key. */ + readonly key: string + /** Source parameter name recognized by the SRC weak parser. */ + readonly parameter: string + /** Wire field replacing the Host object parameter. */ + readonly wire: string + /** Canonical Host type symbol used by strict generation. */ + readonly hostTypeSymbol: string + /** Canonical wire type symbol used by strict generation. */ + readonly wireTypeSymbol: string +} + /** Host resolver for one scoped Remote Context kind. */ export interface TypeRTHostContextProvider { /** Wire field carrying the Context identity. */ @@ -291,6 +305,8 @@ export interface TypeRTLookupRegistry { * @returns the live provider, or `undefined` when absent. */ get(key: string): TypeRTLookupProvider | undefined + /** @returns lookup declarations observed during this TypeRT Service lifetime. */ + definitions(): readonly TypeRTLookupDefinition[] /** @returns a snapshot of registered provider keys. */ keys(): readonly string[] /** diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 84b957e633..edadc3f134 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -1494,6 +1494,66 @@ "doc": "docs/core-data-structures/settings.md", "symbol": "SettingsPathOp", "source": "packages/settings/settings/src/index.ts" + }, + { + "doc": "docs/core-data-structures/typert.md", + "symbol": "TypeRTLookupMap", + "source": "packages/typert/type-meta/src/types.ts" + }, + { + "doc": "docs/core-data-structures/typert.md", + "symbol": "TypeRTContextMap", + "source": "packages/typert/type-meta/src/types.ts" + }, + { + "doc": "docs/core-data-structures/typert.md", + "symbol": "TypeRTLookupDefinition", + "source": "packages/typert/type-meta/src/types.ts" + }, + { + "doc": "docs/core-data-structures/typert.md", + "symbol": "TypeRTCodec", + "source": "packages/typert/type-meta/src/types.ts" + }, + { + "doc": "docs/core-data-structures/typert.md", + "symbol": "InvocationParameterDescriptor", + "source": "packages/typert/type-meta/src/types.ts" + }, + { + "doc": "docs/core-data-structures/typert.md", + "symbol": "InvocationDescriptor", + "source": "packages/typert/type-meta/src/types.ts" + }, + { + "doc": "docs/core-data-structures/typert.md", + "symbol": "TypeRTService", + "source": "packages/typert/type-meta/src/types.ts" + }, + { + "doc": "docs/core-data-structures/typert.md", + "symbol": "TypeRTRemoteNamespaceMap", + "source": "packages/typert/type-meta/src/types.ts" + }, + { + "doc": "docs/core-data-structures/typert.md", + "symbol": "InvokeRemoteRequest", + "source": "packages/host/api-gateway/src/types.ts" + }, + { + "doc": "docs/core-data-structures/typert.md", + "symbol": "TypertGatewayErrorCode", + "source": "packages/host/api-gateway/src/types.ts" + }, + { + "doc": "docs/core-data-structures/typert.md", + "symbol": "TypertGateway", + "source": "packages/host/api-gateway/src/types.ts" + }, + { + "doc": "docs/core-data-structures/typert.md", + "symbol": "ClientApi", + "source": "packages/host/api-gateway/src/client/index.ts" } ] } From 22bec5e63f1656a7c0c3a931a8293f1fb4a223a5 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 6 Aug 2026 18:13:15 +0800 Subject: [PATCH 25/63] feat(typert): propagate Remote cancellation --- ...08-02-typert-remote-method-calls.i18n.yaml | 4 +- .../2026-08-02-typert-remote-method-calls.md | 37 ++++++++++------ ...026-08-02-typert-remote-method-calls.zh.md | 37 ++++++++++------ docs/core-data-structures/typert.i18n.yaml | 4 +- docs/core-data-structures/typert.md | 11 ++++- docs/core-data-structures/typert.zh.md | 11 ++++- .../cordis/tool-cordis/src/api-catalog.ts | 4 +- packages/host/api-gateway/README.i18n.yaml | 4 +- packages/host/api-gateway/README.md | 4 +- packages/host/api-gateway/README.zh.md | 4 +- packages/host/api-gateway/src/client/index.ts | 14 ++++-- packages/host/api-gateway/src/index.ts | 26 ++++++++--- packages/host/api-gateway/src/types.ts | 2 + .../host/api-gateway/tests/client.spec.ts | 37 ++++++++++++++-- .../host/api-gateway/tests/gateway.spec.ts | 43 +++++++++++++++++-- packages/typert/generator/src/analyzer.ts | 23 +++++++++- packages/typert/generator/src/emitter.ts | 4 ++ packages/typert/generator/src/model.ts | 3 ++ .../remote-model/packages/remote/src/index.ts | 3 +- .../generator/tests/remote-model.spec.ts | 31 +++++++++++-- packages/typert/loader/src/index.ts | 6 +++ packages/typert/loader/tests/loader.spec.ts | 13 ++++++ packages/typert/registry/src/service.ts | 3 ++ packages/typert/registry/tests/typert.spec.ts | 5 +++ packages/typert/type-meta/README.i18n.yaml | 4 +- packages/typert/type-meta/README.md | 2 + packages/typert/type-meta/README.zh.md | 2 + packages/typert/type-meta/src/types.ts | 5 +++ 28 files changed, 280 insertions(+), 66 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml index 752a5d4c8b..bd83c38a3e 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md -2026-08-02-typert-remote-method-calls.md: 91ab8e44ff8aedf666fe3426b85b54491deb340c -2026-08-02-typert-remote-method-calls.zh.md: 73abd53109d871076aa41af39825c80c35ac3f26 +2026-08-02-typert-remote-method-calls.md: 4268539ecf0d40a9e8080e0571992cc2c5d724af +2026-08-02-typert-remote-method-calls.zh.md: f9f426f2fb80c74cb9ebaef15e801ccfcf67e027 diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md index 91ab8e44ff..4268539ecf 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md @@ -76,6 +76,8 @@ An endpoint selects exactly one invocation mode. A flow that needs an explicit ` Business packages depend only on the lightweight `@deepseek-ai/dsh-type-meta`. It provides declaration protocols for decorators, `bindTypeRTGateway()`, lookup, Remote Context, and descriptors, without depending on the TypeScript compiler, Zod, HTTP, or the Client runtime. +A method that cooperatively supports cancellation declares `signal: AbortSignal` as its final Host parameter. This reserved parameter is not a business value, lookup, or JSON field. The generated consumer method exposes it as a final optional parameter so ordinary calls remain unchanged while callers that own cancellation can pass a signal. + ## Decorators and the explicit Gateway facet A decorator only states that a method participates in the Remote contract. It performs no runtime type reflection and injects no hidden symbol into a Service constructor. The arguments to `@Remote('create')` and `@RemoteContext('agent', 'create')` are external method names, while the actual member remains named `remoteExportCreate`. The member name becomes the external method name only when no alias is provided. `typertGateway` is the sole explicit marker that a Service has joined the Gateway, making this capability visible on both the business class and its runtime instance. @@ -126,6 +128,7 @@ InvocationDescriptor { parameters: [ { name, wire, source: json | lookup, lookup?, codec } ] + cancellation?: { parameter: 'signal' } result: codec sourceLocation } @@ -135,7 +138,7 @@ InvocationDescriptor { The strict generator writes `scope` only when a direct method has exactly one lookup parameter, a `TypeRTContextMap` declaration with the same name exists, and both use the same wire type symbol. `scope.wire` must identify that lookup parameter. It declares that a consumer may fill this parameter from the Context in which the call occurs, without changing the Host receiver or endpoint. No scoped projection is generated when there are multiple lookups, no Context declaration, or mismatched wire types; a type mismatch is a build error. -Parameter order comes from the method signature. HTTP fields come from parameter names or lookup declarations. The Gateway does not infer optional fields, Context types, lookup types, or missing arguments from request contents, and it does not synthesize business defaults. +Parameter order comes from the method signature. HTTP fields come from parameter names or lookup declarations. A cancellation descriptor reserves only the final `signal` position and keeps it outside named `args`; Connection or a direct Gateway caller supplies the actual signal. The Gateway does not infer optional fields, Context types, lookup types, or missing arguments from request contents, and it does not synthesize business defaults. A LIB codec contains a Zod schema and a canonical `typeSymbol` consisting of "package + public subpath + export name." An SRC codec is marked only as `src-json`. When the Host and consumer run in different JavaScript realms, each holds its own Zod instances, but both sets are generated from the same TypeRT model and symbol keys. @@ -239,6 +242,7 @@ interface TypeRTRemoteNamespace$676f616c73 { create: ( agentId: SessionId, request: CreateGoalRequest, + signal?: AbortSignal, ) => Promise } @@ -246,6 +250,7 @@ interface TypeRTRemoteMap { 'goals/create': ( agentId: SessionId, request: CreateGoalRequest, + signal?: AbortSignal, ) => Promise } @@ -256,6 +261,7 @@ interface TypeRTRemoteNamespaceMap { interface TypeRTRemoteContextMap { 'agent:goals/create': ( request: CreateGoalRequest, + signal?: AbortSignal, ) => Promise } ``` @@ -296,7 +302,7 @@ Client business packages depend only on `@deepseek-ai/dsh-client-remotes/client` `ctx.api.mount()` registers a contribution with `TypeRT.remotes`, and its disposer is owned by the Cordis fiber that called the method. Duplicate endpoints, conflicting invocation modes for the same namespace and method, or conflicts between a descriptor and an existing type identity fail immediately. -The API Service materializes each `@Remote` descriptor as a real function on the root `api`. The function constructs named `args` in descriptor parameter order, applies the Client's strict codec, and then calls `ctx.connection.rpc.call('/api', endpoint, { args })`. +The API Service materializes each `@Remote` descriptor as a real function on the root `api`. The function constructs named `args` in descriptor parameter order, applies the Client's strict codec, and then calls `ctx.connection.rpc.call('/api', endpoint, { args }, signal)`. For a cancellation-aware descriptor, the generated function accepts a final optional signal and combines it with the contribution mount lifetime; unmounting therefore cancels every in-flight carrier call, while a caller can cancel one call independently. Neither a direct descriptor with `scope` nor a `@RemoteContext` descriptor copies functions into every Agent Scope. The API Service creates one root singleton Cordis Service for each scoped namespace and materializes methods on that Service. When `agent.goals.create()` is called, the Cordis tracker rebinds the Service's `this.ctx` to the current Agent Context. The method then asks the corresponding Context binder for identity from `this.ctx`. A direct scoped projection substitutes this identity at the lookup position named by `scope.wire`; a Context descriptor writes the identity into the receiver's separate wire field. Both issue the same kind of `/api` call. @@ -332,11 +338,11 @@ The Web already depends on build artifacts such as `lib/client.js`, so it requir SRC supports local source startup. The `WeakMap` records created by `@Remote` and `@RemoteContext()` provide method names and invocation modes. At runtime, the system reads ordered parameter names from the JavaScript function signature and combines them with registered lookup/Context providers to produce a permissive descriptor. -For example, `@Remote('create') remoteExportCreate(agent, request)` resolves to the external method `create`, implementation member `remoteExportCreate`, and two top-level parameters. Lookup registration rewrites `agent` to the wire field `agentId`, while `request` is passed as a same-named JSON parameter. SRC does not start a `ts.Program`, use a preload or loader hook, generate or rewrite source, or inspect the internal structure of an ordinary JSON object. +For example, `@Remote('create') remoteExportCreate(agent, request, signal)` resolves to the external method `create`, implementation member `remoteExportCreate`, two top-level business parameters, and one cancellation injection point. Lookup registration rewrites `agent` to the wire field `agentId`, `request` is passed as a same-named JSON parameter, and the final `signal` stays outside the payload. SRC does not start a `ts.Program`, use a preload or loader hook, generate or rewrite source, or inspect the internal structure of an ordinary JSON object. A signature that SRC cannot resolve unambiguously fails when the Service mounts. It does not guess at object destructuring, ambiguity caused by default parameters, rest parameters, nested lookups, or complex types. -LIB supports CI, releases, and the prerequisite Web build. TypeRT scans the complete Host project and checks Remote decorators, explicit bindings, service keys, endpoint conflicts, lookup/Context declarations, public-symbol reachability, JSON codecs, and result codecs, then generates strict descriptors. +LIB supports CI, releases, and the prerequisite Web build. TypeRT scans the complete Host project and checks Remote decorators, explicit bindings, service keys, endpoint conflicts, lookup/Context declarations, public-symbol reachability, JSON codecs, result codecs, and that a reserved final `signal` parameter has the global `AbortSignal` type, then generates strict descriptors. At runtime, LIB only loads definitions from `lib`; it does not start the TypeScript compiler. The subsequent association of Services, lookup, Context resolution, invocation, and response encoding in the Host Gateway does not depend on whether a descriptor came from permissive SRC parsing or strict LIB generation. @@ -348,17 +354,18 @@ The Host Gateway registers one `/api` interceptor with Connection and does not m Invocation resolves the descriptor, receiver, lookup providers, and Context provider again from current state. A current strict descriptor takes precedence over SRC. After a strict endpoint has appeared, `TypeRTLocalRegistry.hasSeen()` keeps it owned when that descriptor is withdrawn and forbids SRC fallback for the remainder of the registry lifetime; re-registering the strict descriptor restores calls. Removing a Service or provider makes invocation fail explicitly, and the Gateway neither retains invalid objects nor invokes a method with a raw lookup ID. -An ordinary `@Remote` call retains the original Service instance as receiver. After lookups succeed, the Gateway calls the member identified by `implementation ?? method` with parameters in descriptor order. +An ordinary `@Remote` call retains the original Service instance as receiver. After lookups succeed, the Gateway calls the member identified by `implementation ?? method` with parameters in descriptor order, followed by the carrier signal when the descriptor declares cancellation. A `@RemoteContext('agent')` call first asks the Agent Context provider to resolve the wire identity, then reads the descriptor's service key from that Context and invokes the scoped receiver. The business method receives neither a hidden Context parameter nor an Agent ID. ```text -ctx.typertGateway.invoke({ namespace, method, args }) +ctx.typertGateway.invoke({ namespace, method, args, signal }) → 查找本地 InvocationDescriptor 与 live receiver → 按参数 descriptor 读取具名 wire 字段 → codec 解码普通值或 lookup ID → lookup provider 把 ID 解析为活对象 → direct 使用原 Service;context 先解析 scoped Context 和 Service +→ cancellation descriptor 存在时把 signal 追加到业务参数末尾 → Reflect.apply(receiver[implementation ?? method], receiver, orderedArgs) → result codec 编码业务结果 ``` @@ -373,10 +380,10 @@ Connection owns one `/api` route on the HTTP Server. The Gateway mounts a synchr ctx.connection.rpc.intercept( '/api', endpoint => ownsRemoteEndpoint(endpoint), - (endpoint, payload) => { + (endpoint, payload, signal) => { const { namespace, method } = parseEndpoint(endpoint) const { args } = parsePayload(payload) - return ctx.typertGateway.invoke({ namespace, method, args }) + return ctx.typertGateway.invoke({ namespace, method, args, signal }) }, ) ``` @@ -405,15 +412,16 @@ The Remote payload is a named JSON object, not a positional array, and does not The complete path is: ```text -ctx.api.goals.create(sessionId, request) +ctx.api.goals.create(sessionId, request, signal?) → Client InvocationDescriptor 编码 { args: { agentId, request } } -→ ctx.connection.rpc.call('/api', 'goals/create', { args }) +→ Client 合并 caller signal 与 contribution mount lifetime +→ ctx.connection.rpc.call('/api', 'goals/create', { args }, signal) → Connection 创建 rpcId 和既有 client-request envelope → 当前 carrier 发送 POST /api/goals/create → Connection Host half 执行共享 trust,再由 bridge 创建标准 Request → 复合 FetchHandler 判断 endpoint ownership 并选择目标 FetchHandler -→ TypeRT interceptor 调用 ctx.typertGateway.invoke(...) -→ Host InvocationDescriptor 解码、lookup、receiver 解析和 Reflect.apply +→ TypeRT interceptor 调用 ctx.typertGateway.invoke(..., request.signal) +→ Host InvocationDescriptor 解码、lookup、receiver 解析并把 signal 注入 Reflect.apply → result codec 编码 → Connection 写入既有 RPC result 并回送相同 rpcId → Client result codec 验证并返回 CreateGoalResult @@ -421,7 +429,7 @@ ctx.api.goals.create(sessionId, request) Remote does not define a second-layer `{ ok, value/error }` response. Successful values and Gateway errors use the existing RPC response's `result` directly. The current adapter converts every Gateway and business-invocation failure to the existing `RpcError` envelope with `code: 'internal'`; the Gateway's structured error category remains available only in-process, while the message carries the diagnostic across Connection. -The Gateway does not handle per-method permissions, caller identity, cancellation, idempotency, or long-lived connection state. TypeRT endpoints use Connection's trusted-host policy; unclaimed endpoints retain the legacy API Proxy's trust and privileged-method policies. Connection's WebSocket migration remains separate follow-up work. +The Gateway does not handle per-method permissions, caller identity, idempotency, or long-lived connection state. It only propagates cooperative cancellation from Connection into explicitly cancellation-aware business methods. TypeRT endpoints use Connection's trusted-host policy; unclaimed endpoints retain the legacy API Proxy's trust and privileged-method policies. Connection's WebSocket migration remains separate follow-up work. ## Connection and protocol boundaries @@ -475,6 +483,7 @@ Connection supplies the shared-channel interceptor and current HTTP carrier mapp - Root and Agent-scoped calls cross the real shared `/api` carrier, resolve `agentId` to the live Agent, invoke the original Goal receiver, and return through the existing RPC envelope. - The Remote artifacts and maps contain only marked methods and no Browser dependency, preserving the same consumer boundary for a future TUI. - Lifecycle tests withdraw and remount descriptors, Services, lookups, Context providers, and Client namespaces; unavailable dependencies fail without stale calls or raw-ID fallback. +- Cancellation tests cover strict generation, SRC final-name recognition, Client signal fusion, Connection-to-Gateway propagation, and Host injection outside wire `args`. - Unclaimed endpoints continue through the existing API Proxy path with its trust, privileged-method, Permission/Approval, and Session event-stream behavior unchanged. ## Consequences @@ -499,4 +508,4 @@ Remote endpoints use Connection's `trusted-host` authority. Loopback is accepted `hasSeen()` favors strict-definition safety over SRC availability. While a strict descriptor is withdrawn, such as during HMR, the Gateway continues to claim the endpoint and reports it unavailable instead of falling back to a weak SRC descriptor. Re-registration restores it; only a TypeRT registry restart forgets the historical strict definition. -Connection supplies an `AbortSignal`, but Remote business signatures have no cancellation parameter. A client disconnect therefore does not cancel business work; cancellation remains deferred rather than being implied by the transport handler shape. +Cancellation-aware Remote signatures receive Connection's request `AbortSignal`, so an HTTP disconnect or Client-side abort reaches ongoing business work without entering the JSON protocol. Cancellation remains cooperative: methods without the reserved final parameter continue running, and a method that receives the signal must pass it to its own cancellable operations or observe it directly. diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md index 73abd53109..f9f426f2fb 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md @@ -76,6 +76,8 @@ export class ScopedGoalService extends Service { 业务包只依赖轻量的 `@deepseek-ai/dsh-type-meta`。它提供 decorator、`bindTypeRTGateway()`、lookup、Remote Context 和 descriptor 的声明协议,不依赖 TypeScript compiler、Zod、HTTP 或 Client runtime。 +支持协作式取消的方法会把 `signal: AbortSignal` 声明为最后一个 Host 参数。这个保留参数不是业务值、lookup 或 JSON 字段。生成的消费方方法将其暴露为最后一个可选参数,因此普通调用保持不变,而拥有取消控制权的调用方可以传入 signal。 + ## Decorator 与显式 Gateway facet Decorator 只表达“该方法参与 Remote 契约”,不负责运行时类型反射,也不向 Service constructor 注入隐藏 symbol。`@Remote('create')` 和 `@RemoteContext('agent', 'create')` 的参数是外部方法名,实际成员名保持 `remoteExportCreate`;未给别名时才使用成员名作为外部方法名。`typertGateway` 是 Service 加入 Gateway 的唯一显式标志,使业务类和运行时实例都能直接看出这项能力。 @@ -126,6 +128,7 @@ InvocationDescriptor { parameters: [ { name, wire, source: json | lookup, lookup?, codec } ] + cancellation?: { parameter: 'signal' } result: codec sourceLocation } @@ -135,7 +138,7 @@ InvocationDescriptor { 严格生成器只在 direct 方法恰好包含一个 lookup 参数、同名 `TypeRTContextMap` 声明存在且两者使用同一 wire 类型 symbol 时写入 `scope`。`scope.wire` 必须指向该 lookup 参数;它声明消费端可以从调用所在 Context 补入这个参数,不改变 Host receiver 或 endpoint。多个 lookup、缺少 Context 声明或 wire 类型不一致时不生成 scoped 投影,其中类型不一致属于构建错误。 -参数顺序来自方法签名,HTTP 字段来自参数名或 lookup 声明。Gateway 不根据请求内容推断可选字段、Context 类型、lookup 类型或缺失参数,也不会合成业务默认值。 +参数顺序来自方法签名,HTTP 字段来自参数名或 lookup 声明。取消 descriptor 只保留最后一个 `signal` 位置,并使其不进入具名 `args`;实际 signal 由 Connection 或直接调用 Gateway 的调用方提供。Gateway 不根据请求内容推断可选字段、Context 类型、lookup 类型或缺失参数,也不会合成业务默认值。 LIB codec 带有 Zod schema 和“package + 公共 subpath + export name”的规范 `typeSymbol`;SRC codec 只标记 `src-json`。Host 和消费端运行在不同 JavaScript realm 时会各自持有 Zod 实例,但这些实例由同一 TypeRT 模型和 symbol key 生成。 @@ -239,6 +242,7 @@ interface TypeRTRemoteNamespace$676f616c73 { create: ( agentId: SessionId, request: CreateGoalRequest, + signal?: AbortSignal, ) => Promise } @@ -246,6 +250,7 @@ interface TypeRTRemoteMap { 'goals/create': ( agentId: SessionId, request: CreateGoalRequest, + signal?: AbortSignal, ) => Promise } @@ -256,6 +261,7 @@ interface TypeRTRemoteNamespaceMap { interface TypeRTRemoteContextMap { 'agent:goals/create': ( request: CreateGoalRequest, + signal?: AbortSignal, ) => Promise } ``` @@ -296,7 +302,7 @@ Client 业务包只引用 `@deepseek-ai/dsh-client-remotes/client`,不直接 `ctx.api.mount()` 把 contribution 注册到 `TypeRT.remotes`,并由调用该方法的 Cordis fiber 持有 disposer。endpoint 重复、同一 namespace/method 模式冲突或 descriptor 与现有类型身份冲突时直接失败。 -API Service 把 `@Remote` descriptor 实体化为根 `api` 上的真实函数。函数按 descriptor 的位置参数顺序构造具名 `args`,执行 Client strict codec,然后调用 `ctx.connection.rpc.call('/api', endpoint, { args })`。 +API Service 把 `@Remote` descriptor 实体化为根 `api` 上的真实函数。函数按 descriptor 的位置参数顺序构造具名 `args`,执行 Client strict codec,然后调用 `ctx.connection.rpc.call('/api', endpoint, { args }, signal)`。对于支持取消的 descriptor,生成的函数接受最后一个可选 signal,并将其与 contribution 的挂载生命周期合并;因此卸载会取消所有正在进行的 carrier 调用,而调用方也可以单独取消一次调用。 带 `scope` 的 direct descriptor 和 `@RemoteContext` descriptor 都不为每个 Agent Scope 复制函数。API Service 为每个 scoped namespace 建立一个 root singleton Cordis Service,并在该 Service 上实体化方法;Cordis tracker 在 `agent.goals.create()` 调用时把 Service 的 `this.ctx` rebind 到当前 Agent Context。方法再通过对应 Context binder 从 `this.ctx` 取得 identity。direct scoped 投影用 identity 替代 `scope.wire` 指定的 lookup 位置,Context descriptor 则把 identity 写入 receiver 的独立 wire 字段;两者都发起同一种 `/api` 调用。 @@ -332,11 +338,11 @@ Web 本身依赖 `lib/client.js` 等构建产物,因此启动 Web 前要求完 SRC 面向本地源码启动。`@Remote` 和 `@RemoteContext()` 的 WeakMap 记录给出方法名和调用模式,运行时从 JavaScript 函数签名读取顺序参数名,并结合已注册 lookup/Context provider 生成弱 descriptor。 -例如 `@Remote('create') remoteExportCreate(agent, request)` 解析为外部方法 `create`、实现成员 `remoteExportCreate` 和两个顶层参数;lookup 注册把 `agent` 改写为 wire 字段 `agentId`,`request` 按同名 JSON 参数传递。SRC 不启动 `ts.Program`,不使用 preload、loader hook、源码生成或模块改写,也不检查普通 JSON 对象的内部结构。 +例如 `@Remote('create') remoteExportCreate(agent, request, signal)` 解析为外部方法 `create`、实现成员 `remoteExportCreate`、两个顶层业务参数和一个取消注入点;lookup 注册把 `agent` 改写为 wire 字段 `agentId`,`request` 按同名 JSON 参数传递,最后一个 `signal` 则留在 payload 之外。SRC 不启动 `ts.Program`,不使用 preload、loader hook、源码生成或模块改写,也不检查普通 JSON 对象的内部结构。 SRC 无法明确解析的签名在 Service 挂载时失败。对象解构、默认参数造成的歧义、rest 参数、嵌套 lookup 和复杂类型不做猜测。 -LIB 面向 CI、发布和 Web 前置构建。TypeRT 扫描完整 Host project,检查 Remote decorator、显式 binding、service key、endpoint 冲突、lookup/Context 声明、公共符号可达性、JSON codec 和结果 codec,并生成严格 descriptor。 +LIB 面向 CI、发布和 Web 前置构建。TypeRT 扫描完整 Host project,检查 Remote decorator、显式 binding、service key、endpoint 冲突、lookup/Context 声明、公共符号可达性、JSON codec、结果 codec,以及保留的最后一个 `signal` 参数是否具有全局 `AbortSignal` 类型,并生成严格 descriptor。 LIB 运行时只加载 `lib` 中的 definition,不启动 TypeScript compiler。Host Gateway 后续的 Service 关联、lookup、Context 解析、调用和响应编码不区分 descriptor 来自 SRC 弱解析还是 LIB 严格生成。 @@ -348,17 +354,18 @@ Host Gateway 向 Connection 注册一个 `/api` interceptor,不维护第二份 每次调用都会重新从当前状态解析 descriptor、receiver、lookup 提供方与 Context 提供方。当前 strict descriptor 优先于 SRC。strict endpoint 一旦出现,即使随后撤回对应 descriptor,`TypeRTLocalRegistry.hasSeen()` 仍会在注册表剩余生命周期内保持对它的认领并禁止回退 SRC;重新注册 strict descriptor 即可恢复调用。移除 Service 或提供方会让调用明确失败;Gateway 既不保留失效对象,也不会以原始 lookup ID 调用方法。 -普通 `@Remote` 调用保留原始 Service 实例作为 receiver。lookup 成功后,Gateway 按 descriptor 的参数顺序调用 `implementation ?? method` 指定的成员。 +普通 `@Remote` 调用保留原始 Service 实例作为 receiver。lookup 成功后,Gateway 按 descriptor 的参数顺序调用 `implementation ?? method` 指定的成员;若 descriptor 声明取消,则在这些参数之后追加 carrier signal。 `@RemoteContext('agent')` 调用先由 Agent Context provider 解析 wire identity,再从该 Context 读取 descriptor 的 service key 并调用 scoped receiver。业务方法不会收到隐藏 Context 参数或 Agent ID。 ```text -ctx.typertGateway.invoke({ namespace, method, args }) +ctx.typertGateway.invoke({ namespace, method, args, signal }) → 查找本地 InvocationDescriptor 与 live receiver → 按参数 descriptor 读取具名 wire 字段 → codec 解码普通值或 lookup ID → lookup provider 把 ID 解析为活对象 → direct 使用原 Service;context 先解析 scoped Context 和 Service +→ cancellation descriptor 存在时把 signal 追加到业务参数末尾 → Reflect.apply(receiver[implementation ?? method], receiver, orderedArgs) → result codec 编码业务结果 ``` @@ -373,10 +380,10 @@ Connection 在 HTTP Server 上持有唯一 `/api` route。Gateway 把同步 endp ctx.connection.rpc.intercept( '/api', endpoint => ownsRemoteEndpoint(endpoint), - (endpoint, payload) => { + (endpoint, payload, signal) => { const { namespace, method } = parseEndpoint(endpoint) const { args } = parsePayload(payload) - return ctx.typertGateway.invoke({ namespace, method, args }) + return ctx.typertGateway.invoke({ namespace, method, args, signal }) }, ) ``` @@ -405,15 +412,16 @@ Remote payload 使用具名 JSON 对象,不使用位置数组,也不发送 ` 完整链路为: ```text -ctx.api.goals.create(sessionId, request) +ctx.api.goals.create(sessionId, request, signal?) → Client InvocationDescriptor 编码 { args: { agentId, request } } -→ ctx.connection.rpc.call('/api', 'goals/create', { args }) +→ Client 合并 caller signal 与 contribution mount lifetime +→ ctx.connection.rpc.call('/api', 'goals/create', { args }, signal) → Connection 创建 rpcId 和既有 client-request envelope → 当前 carrier 发送 POST /api/goals/create → Connection Host half 执行共享 trust,再由 bridge 创建标准 Request → 复合 FetchHandler 判断 endpoint ownership 并选择目标 FetchHandler -→ TypeRT interceptor 调用 ctx.typertGateway.invoke(...) -→ Host InvocationDescriptor 解码、lookup、receiver 解析和 Reflect.apply +→ TypeRT interceptor 调用 ctx.typertGateway.invoke(..., request.signal) +→ Host InvocationDescriptor 解码、lookup、receiver 解析并把 signal 注入 Reflect.apply → result codec 编码 → Connection 写入既有 RPC result 并回送相同 rpcId → Client result codec 验证并返回 CreateGoalResult @@ -421,7 +429,7 @@ ctx.api.goals.create(sessionId, request) Remote 不定义第二层 `{ ok, value/error }` response。成功值和 Gateway 错误直接使用既有 RPC response 的 `result`。当前 adapter 把所有 Gateway 与业务调用失败转换为既有 `RpcError` envelope,并统一使用 `code: 'internal'`;Gateway 的结构化错误分类仅在进程内保留,诊断信息则通过 message 跨 Connection 传递。 -Gateway 不处理逐方法权限、调用者身份、取消、幂等或长连接状态。TypeRT endpoint 使用 Connection 的 trusted-host 策略;未认领 endpoint 保留旧 API Proxy 的 trust 和 privileged-method 策略。Connection/WebSocket 迁移后续独立完成。 +Gateway 不处理逐方法权限、调用者身份、幂等或长连接状态。它只把 Connection 的协作式取消传播给显式支持取消的业务方法。TypeRT endpoint 使用 Connection 的 trusted-host 策略;未认领 endpoint 保留旧 API Proxy 的 trust 和 privileged-method 策略。Connection/WebSocket 迁移后续独立完成。 ## Connection 与协议边界 @@ -475,6 +483,7 @@ Connection 提供共享 channel interceptor 与当前 HTTP carrier 映射。WebS - Root 与 Agent-scoped 调用会经过真实的共享 `/api` carrier,将 `agentId` 解析为活 Agent,调用原始 Goal receiver,并通过既有 RPC envelope 返回。 - Remote 产物与 map 仅包含已标记的方法,不依赖 Browser,从而为未来 TUI 保留相同的消费方边界。 - 生命周期测试会撤回并重新挂载 descriptor、Service、lookup、Context 提供方和 Client namespace;依赖不可用时,调用会失败,且不会使用陈旧调用或回退原始 ID。 +- 取消测试覆盖严格生成、SRC 末位参数名识别、Client signal 合并、Connection 到 Gateway 的传播,以及 Host 在 wire `args` 之外的注入。 - 未认领 endpoint 继续使用既有 API Proxy 路径,其 trust、privileged-method、Permission/Approval 与 Session 事件流行为保持不变。 ## 后果 @@ -499,4 +508,4 @@ Remote endpoint 使用 Connection 的 `trusted-host` authority。系统默认接 `hasSeen()` 优先保障 strict definition 的安全性,而非 SRC 可用性。strict descriptor 撤回时(例如 HMR 期间),Gateway 会继续认领 endpoint 并报告不可用,而不会回退到弱 SRC descriptor。重新注册即可恢复;只有重启 TypeRT 注册表才会忘记历史 strict definition。 -Connection 提供 `AbortSignal`,但 Remote 业务签名没有取消参数。因此 Client 断连不会取消业务工作;取消仍作为后续工作,而不能由 transport handler 的形状暗示已经支持。 +支持取消的 Remote 签名会接收 Connection 请求的 `AbortSignal`,因此 HTTP 断连或 Client 侧 abort 能在不进入 JSON 协议的情况下传递到正在进行的业务工作。取消仍是协作式的:没有保留末位参数的方法会继续运行;收到 signal 的方法必须将它传给自身支持取消的操作,或自行观测它。 diff --git a/docs/core-data-structures/typert.i18n.yaml b/docs/core-data-structures/typert.i18n.yaml index be40eeb20a..a5484d06c4 100644 --- a/docs/core-data-structures/typert.i18n.yaml +++ b/docs/core-data-structures/typert.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/typert.md -typert.md: 9f5c63fc554a43fd0248ed08a64dcff566c83b58 -typert.zh.md: 2b74c8325a510ba39d134fa6d463dab273239772 +typert.md: da6e229ff6a2300c36f5734ad05c621a5e63082d +typert.zh.md: b3b0e8897756b5b4f9b645522cc5a1b27eac1d33 diff --git a/docs/core-data-structures/typert.md b/docs/core-data-structures/typert.md index 9f5c63fc55..da6e229ff6 100644 --- a/docs/core-data-structures/typert.md +++ b/docs/core-data-structures/typert.md @@ -38,7 +38,7 @@ interface TypeRTLookupDefinition { ## Invocation descriptors -An `InvocationDescriptor` is local reflection, not a wire message. Host and consumer builds generate corresponding descriptors; the request sends only the endpoint and named `args`. Strict codecs carry generated schemas, while SRC codecs enforce JSON-safe values without structural type recovery. +An `InvocationDescriptor` is local reflection, not a wire message. Host and consumer builds generate corresponding descriptors; the request sends only the endpoint and named `args`. Strict codecs carry generated schemas, while SRC codecs enforce JSON-safe values without structural type recovery. Cancellation is an out-of-band carrier signal injected after business parameters and never enters `args`. ```ts type-equiv /** Codec attached to one invocation parameter or result. */ @@ -100,6 +100,11 @@ interface InvocationDescriptor { } /** Ordered business parameters. */ readonly parameters: readonly InvocationParameterDescriptor[] + /** Transport cancellation injected after business parameters instead of entering wire args. */ + readonly cancellation?: { + /** Reserved final Host method parameter. */ + readonly parameter: 'signal' + } /** Codec for the resolved method result. */ readonly result: TypeRTCodec /** Source declaration used only for diagnostics. */ @@ -130,7 +135,7 @@ interface TypeRTRemoteNamespaceMap {} ## Host Gateway -Connection decodes its carrier envelope before calling `ctx.typertGateway`. The request carries exact named wire fields; infrastructure and boundary failures use the Gateway's in-process error taxonomy, although the current RPC adapter folds them into the transport's `internal` error code. +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 use the Gateway's in-process error taxonomy, although the current RPC adapter folds them into the transport's `internal` error code. ```ts type-equiv /** One Remote method request after a carrier has decoded its envelope. */ @@ -141,6 +146,8 @@ interface InvokeRemoteRequest { readonly method: string /** Named wire values; fields must exactly match the descriptor. */ readonly args: Readonly> + /** Carrier or direct-caller cancellation injected only into cancellation-aware methods. */ + readonly signal?: AbortSignal } ``` diff --git a/docs/core-data-structures/typert.zh.md b/docs/core-data-structures/typert.zh.md index 2b74c8325a..b3b0e88977 100644 --- a/docs/core-data-structures/typert.zh.md +++ b/docs/core-data-structures/typert.zh.md @@ -38,7 +38,7 @@ interface TypeRTLookupDefinition { ## 调用 descriptor -`InvocationDescriptor` 是本地反射信息,不是 wire message。Host 与消费方构建会生成彼此对应的 descriptor;请求只发送 endpoint 与具名 `args`。strict codec 携带生成的 schema,SRC codec 则在不恢复结构类型的前提下强制要求 JSON 安全值。 +`InvocationDescriptor` 是本地反射信息,不是 wire message。Host 与消费方构建会生成彼此对应的 descriptor;请求只发送 endpoint 与具名 `args`。strict codec 携带生成的 schema,SRC codec 则在不恢复结构类型的前提下强制要求 JSON 安全值。取消通过带外 carrier signal 表达:它在业务参数之后注入,绝不进入 `args`。 ```ts type-equiv /** Codec attached to one invocation parameter or result. */ @@ -100,6 +100,11 @@ interface InvocationDescriptor { } /** Ordered business parameters. */ readonly parameters: readonly InvocationParameterDescriptor[] + /** Transport cancellation injected after business parameters instead of entering wire args. */ + readonly cancellation?: { + /** Reserved final Host method parameter. */ + readonly parameter: 'signal' + } /** Codec for the resolved method result. */ readonly result: TypeRTCodec /** Source declaration used only for diagnostics. */ @@ -130,7 +135,7 @@ interface TypeRTRemoteNamespaceMap {} ## Host Gateway -Connection 会先解码 carrier envelope,再调用 `ctx.typertGateway`。请求携带精确的具名 wire 字段;基础设施与边界失败使用 Gateway 的进程内错误分类体系,但当前 RPC 适配器会把这些错误折叠为传输层的 `internal` 错误码。 +Connection 会先解码 carrier envelope,再调用 `ctx.typertGateway`。请求将精确的具名 wire 字段与 carrier 的取消 signal 分开携带;基础设施与边界失败使用 Gateway 的进程内错误分类体系,但当前 RPC 适配器会把这些错误折叠为传输层的 `internal` 错误码。 ```ts type-equiv /** One Remote method request after a carrier has decoded its envelope. */ @@ -141,6 +146,8 @@ interface InvokeRemoteRequest { readonly method: string /** Named wire values; fields must exactly match the descriptor. */ readonly args: Readonly> + /** Carrier or direct-caller cancellation injected only into cancellation-aware methods. */ + readonly signal?: AbortSignal } ``` diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 4fe2b12323..d8d067ce3e 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -2097,7 +2097,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'InvocationDescriptor', - declaration: 'export interface InvocationDescriptor {\n readonly id: string;\n readonly service: string;\n readonly namespace: string;\n readonly method: string;\n readonly implementation?: string;\n readonly invocation: {\n readonly kind: \'direct\';\n } | {\n readonly kind: \'context\';\n readonly context: string;\n readonly wire: string;\n readonly codec: TypeRTCodec;\n };\n readonly scope?: {\n readonly context: string;\n readonly wire: string;\n };\n readonly parameters: readonly InvocationParameterDescriptor[];\n readonly result: TypeRTCodec;\n readonly sourceLocation?: InvocationSourceLocation;\n}', + declaration: 'export interface InvocationDescriptor {\n readonly id: string;\n readonly service: string;\n readonly namespace: string;\n readonly method: string;\n readonly implementation?: string;\n readonly invocation: {\n readonly kind: \'direct\';\n } | {\n readonly kind: \'context\';\n readonly context: string;\n readonly wire: string;\n readonly codec: TypeRTCodec;\n };\n readonly scope?: {\n readonly context: string;\n readonly wire: string;\n };\n readonly parameters: readonly InvocationParameterDescriptor[];\n readonly cancellation?: {\n readonly parameter: \'signal\';\n };\n readonly result: TypeRTCodec;\n readonly sourceLocation?: InvocationSourceLocation;\n}', }, { name: 'InvocationParameterDescriptor', @@ -2109,7 +2109,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}', + declaration: 'export interface InvokeRemoteRequest {\n readonly namespace: string;\n readonly method: string;\n readonly args: Readonly>;\n readonly signal?: AbortSignal;\n}', }, { name: 'JsonSchemaNode', diff --git a/packages/host/api-gateway/README.i18n.yaml b/packages/host/api-gateway/README.i18n.yaml index 747aa65665..a1c22433f3 100644 --- a/packages/host/api-gateway/README.i18n.yaml +++ b/packages/host/api-gateway/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/api-gateway/README.md -README.md: cc80bb19fec15414aa0857154a8a36fb4f642672 -README.zh.md: 6febb1cfe4fc7fa4c5a17e1e4f6a21e2ee03e295 +README.md: 9cb6e7e1c0a23789ab4ab2c999b5a6c2d4cd32f9 +README.zh.md: 609580ceb77649ba8df6103093a72092c9ccc8a1 diff --git a/packages/host/api-gateway/README.md b/packages/host/api-gateway/README.md index cc80bb19fe..9cb6e7e1c0 100644 --- a/packages/host/api-gateway/README.md +++ b/packages/host/api-gateway/README.md @@ -12,11 +12,13 @@ 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 unclaimed endpoints to API Proxy. Direct `invoke()` calls preserve business errors; `TypertGatewayError` distinguishes failures owned by dispatch, binding, providers, lookup, Context, arguments, and codecs. +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. + ## Client service: `ClientApi` (ctx key: `api`) `ctx.api.mount()` validates and registers a generated Host-for-Client contribution, then installs concrete direct and scoped methods for the calling Cordis fiber. Duplicate endpoints, namespace collisions, and descriptors without strict generated codecs fail before methods become callable. -Each call validates positional inputs, constructs the descriptor's exact named `args`, and sends it through `ctx.connection.rpc.call('/api', endpoint, ...)`. The returned value is validated before reaching application code. Withdrawing a contribution removes its descriptors and methods together, aborts in-flight calls, and makes retained method handles reject. +Each call validates positional inputs, constructs the descriptor's exact named `args`, and sends it through `ctx.connection.rpc.call('/api', endpoint, ...)`. Generated cancellation-aware methods accept a final optional `AbortSignal`; the Client combines it with the contribution mount lifetime before calling Connection. The returned value is validated before reaching application code. Withdrawing a contribution removes its descriptors and methods together, aborts in-flight calls, and makes retained method handles reject. Generated declaration merges provide the TypeScript API. The Client entry contains no Host Service or Host Cordis interface merge, and method lookup and invocation use ordinary objects and functions rather than a JavaScript Proxy. diff --git a/packages/host/api-gateway/README.zh.md b/packages/host/api-gateway/README.zh.md index 6febb1cfe4..609580ceb7 100644 --- a/packages/host/api-gateway/README.zh.md +++ b/packages/host/api-gateway/README.zh.md @@ -12,11 +12,13 @@ Connection 可用时,Host 入口会在 Connection 共享的 `/api` FetchHandler 上注册 trusted-host interceptor。Connection 把这个复合 handler 交给 HTTP bridge;handler 将已认领 endpoint 分发给 Gateway,未认领 endpoint 则交给 API Proxy。直接调用 `invoke()` 会保留业务错误;`TypertGatewayError` 可区分分发、绑定、提供方、查找、Context、参数和编解码器各自负责的故障。 +支持取消的 Remote 方法会把 `signal: AbortSignal` 声明为最后一个 Host 参数。signal 是 descriptor 元数据,而不是 wire 参数:Connection 将它提供给 Gateway,Gateway 则在已解码的业务参数之后注入它。SRC 识别这个保留的末位参数名,严格生成还要求它具有全局 `AbortSignal` 类型。 + ## Client 服务:`ClientApi`(ctx key:`api`) `ctx.api.mount()` 会校验并注册生成的 Host-for-Client 贡献项,然后为发起调用的 Cordis fiber 安装具体的直接方法和作用域方法。重复端点、命名空间冲突,以及缺少生成的严格编解码器的描述符,都会在方法可调用前报错。 -每次调用都会校验位置参数,构造与描述符完全匹配的具名 `args`,再通过 `ctx.connection.rpc.call('/api', endpoint, ...)` 发送。返回值经过校验后才会交给应用代码。撤回贡献项会同时移除其描述符和方法、中止正在进行的调用,并使外部仍持有的方法句柄在调用时返回拒绝。 +每次调用都会校验位置参数,构造与描述符完全匹配的具名 `args`,再通过 `ctx.connection.rpc.call('/api', endpoint, ...)` 发送。生成的支持取消的方法接受最后一个可选 `AbortSignal`;Client 会在调用 Connection 前将它与贡献项的挂载生命周期合并。返回值经过校验后才会交给应用代码。撤回贡献项会同时移除其描述符和方法、中止正在进行的调用,并使外部仍持有的方法句柄在调用时返回拒绝。 生成的声明合并提供 TypeScript API。Client 入口不包含 Host 服务或 Host Cordis 接口合并;方法查找和调用使用普通对象与函数,而不使用 JavaScript Proxy。 diff --git a/packages/host/api-gateway/src/client/index.ts b/packages/host/api-gateway/src/client/index.ts index 5cd8ab75d1..292df54152 100644 --- a/packages/host/api-gateway/src/client/index.ts +++ b/packages/host/api-gateway/src/client/index.ts @@ -223,9 +223,13 @@ class ClientApiService extends Service implements ClientApi { const endpoint = endpointOf(descriptor) if (!token.active) throw new Error(`client api: Remote method ${endpoint} is no longer mounted`) const expected = descriptor.parameters.length - (projection?.parameterIndex === undefined ? 0 : 1) - if (values.length !== expected) { + const hasCallerSignal = descriptor.cancellation !== undefined && values.length === expected + 1 + if (values.length !== expected && !hasCallerSignal) { + const contract = descriptor.cancellation === undefined + ? `${String(expected)} argument(s)` + : `${String(expected)} business argument(s) plus an optional AbortSignal` throw new Error( - `client api: ${endpoint} expected ${String(expected)} argument(s), got ${String(values.length)}`, + `client api: ${endpoint} expected ${contract}, got ${String(values.length)}`, ) } const args: Record = {} @@ -248,7 +252,11 @@ class ClientApiService extends Service implements ClientApi { }) const connection = this.ownerCtx.get('connection') as ConnectionHandle | undefined if (connection === undefined) throw new Error(`client api: ${endpoint} has no active Connection`) - const result = await connection.rpc.call('/api', endpoint, { args }, token.abort.signal) + const callerSignal = hasCallerSignal ? values[expected] as AbortSignal | undefined : undefined + const signal = callerSignal === undefined + ? token.abort.signal + : AbortSignal.any([token.abort.signal, callerSignal]) + const result = await connection.rpc.call('/api', endpoint, { args }, signal) if (!mountActive(token)) throw new Error(`client api: Remote method ${endpoint} was withdrawn during invocation`) if (!result.ok) throw remoteFailure(endpoint, result.error) return parse(descriptor.result, result.value, endpoint, 'result') diff --git a/packages/host/api-gateway/src/index.ts b/packages/host/api-gateway/src/index.ts index 64d5715719..c4a61cef8d 100644 --- a/packages/host/api-gateway/src/index.ts +++ b/packages/host/api-gateway/src/index.ts @@ -36,6 +36,7 @@ interface ResolvedBinding { } type ConnectionRpcResult = Awaited> +const NEVER_ABORTED_SIGNAL = new AbortController().signal /** Dispatch failure produced outside the invoked business method. */ export class TypertGatewayError extends Error { @@ -129,6 +130,7 @@ export class TypertGatewayService extends Service implements TypertGateway { } validateBinding(receiver, descriptor.service, descriptor.namespace, endpoint) const args = descriptor.parameters.map(parameter => this.resolveParameter(parameter, request.args, endpoint)) + if (descriptor.cancellation !== undefined) args.push(request.signal ?? NEVER_ABORTED_SIGNAL) const implementation = descriptor.implementation ?? descriptor.method const method = Reflect.get(receiver, implementation) as unknown if (typeof method !== 'function') { @@ -146,13 +148,12 @@ export class TypertGatewayService extends Service implements TypertGateway { private async dispatchRpc( endpoint: string, payload: unknown, - _signal: AbortSignal, + signal: AbortSignal, ): Promise { - // Remote methods have no cancellation parameter yet, so disconnects do not cancel business work. - return this.invokeRpc(endpoint, payload) + return this.invokeRpc(endpoint, payload, signal) } - private async invokeRpc(endpoint: string, payload: unknown): Promise { + private async invokeRpc(endpoint: string, payload: unknown, signal: AbortSignal): Promise { try { const segments = endpoint.split('/') if (segments.length !== 2 || segments[0] === '' || segments[1] === '') { @@ -171,6 +172,7 @@ export class TypertGatewayService extends Service implements TypertGateway { namespace, method, args: payload.args, + signal, }) return { ok: true, value } } catch (error) { @@ -226,9 +228,22 @@ export class TypertGatewayService extends Service implements TypertGateway { endpoint: string, ): InvocationDescriptor { const names = methodParameterNames(binding.service, marker.method, endpoint) + const signalIndex = names.indexOf('signal') + if (signalIndex >= 0 && signalIndex !== names.length - 1) { + throw new TypertGatewayError( + 'signature-invalid', + endpoint, + 'SRC cancellation parameter signal must be the final parameter', + { field: 'signal' }, + ) + } + const cancellation = signalIndex >= 0 + ? { parameter: 'signal' as const } + : undefined + const businessNames = cancellation === undefined ? names : names.slice(0, -1) const parameters: InvocationParameterDescriptor[] = [] const wires = new Set() - for (const name of names) { + for (const name of businessNames) { const matches = this.ctx.typert.lookups.definitions() .filter(definition => definition.parameter === name) if (matches.length > 1) { @@ -295,6 +310,7 @@ export class TypertGatewayService extends Service implements TypertGateway { ...(marker.method === method ? {} : { implementation: marker.method }), invocation: receiver, parameters, + ...(cancellation === undefined ? {} : { cancellation }), result: { mode: 'src-json' }, } } diff --git a/packages/host/api-gateway/src/types.ts b/packages/host/api-gateway/src/types.ts index eea2bdc4f1..b7f36eb340 100644 --- a/packages/host/api-gateway/src/types.ts +++ b/packages/host/api-gateway/src/types.ts @@ -11,6 +11,8 @@ export interface InvokeRemoteRequest { readonly method: string /** Named wire values; fields must exactly match the descriptor. */ readonly args: Readonly> + /** Carrier or direct-caller cancellation injected only into cancellation-aware methods. */ + readonly signal?: AbortSignal } /** Stable infrastructure and boundary failures emitted before or after business execution. */ diff --git a/packages/host/api-gateway/tests/client.spec.ts b/packages/host/api-gateway/tests/client.spec.ts index 2e00d29c0d..3ad00ff0fc 100644 --- a/packages/host/api-gateway/tests/client.spec.ts +++ b/packages/host/api-gateway/tests/client.spec.ts @@ -17,11 +17,18 @@ declare module '@deepseek-ai/dsh-type-meta' { } interface TypeRTRemoteMap { - 'goals/create': (agentId: string, request: { readonly objective: string }) => Promise<{ readonly ref: string }> + 'goals/create': ( + agentId: string, + request: { readonly objective: string }, + signal?: AbortSignal, + ) => Promise<{ readonly ref: string }> } interface TypeRTRemoteContextMap { - 'fixture:goals/create': (request: { readonly objective: string }) => Promise<{ readonly ref: string }> + 'fixture:goals/create': ( + request: { readonly objective: string }, + signal?: AbortSignal, + ) => Promise<{ readonly ref: string }> 'fixture:goals/rename': (request: { readonly objective: string }) => Promise<{ readonly renamed: boolean }> } @@ -58,6 +65,7 @@ function directDescriptor(): InvocationDescriptor { source: 'json', codec: { mode: 'strict', typeSymbol: '@fixture#CreateRequest', schema: requestSchema }, }], + cancellation: { parameter: 'signal' }, result: { mode: 'strict', typeSymbol: '@fixture#CreateResult', schema: createResultSchema }, } } @@ -114,6 +122,19 @@ describe('Client TypeRT API', () => { { args: { agentId: 'agent-1', request: { objective: 'ship' } } }, expect.any(AbortSignal), ) + const callerAbort = new AbortController() + await expect(ctx.api.goals.create( + 'agent-1', + { objective: 'cancel me' }, + callerAbort.signal, + )).resolves.toEqual({ ref: 'goal-1' }) + const combinedSignal = call.mock.calls.at(-1)?.[3] + expect(combinedSignal).toBeInstanceOf(AbortSignal) + expect(combinedSignal).not.toBe(callerAbort.signal) + const cancellation = new Error('caller cancelled') + callerAbort.abort(cancellation) + expect(combinedSignal?.aborted).toBe(true) + expect(combinedSignal?.reason).toBe(cancellation) await expect(ctx.api.goals.create('', { objective: 'ship' })).rejects.toThrow('rejected "agentId"') call.mockResolvedValueOnce({ ok: true, value: { ref: 1 } }) @@ -299,10 +320,18 @@ describe('Client TypeRT API', () => { .mockResolvedValue({ ok: true, value: { ref: 'goal-1' } }) const ctx = await bench(call) const descriptor = directDescriptor() - const dispose = ctx.api.mount({ package: '@fixture/goals', descriptors: [descriptor] }) + const dispose = ctx.api.mount({ + package: '@fixture/goals', + descriptors: [descriptor, contextDescriptor()], + }) const create = ctx.api.goals.create as unknown as (...args: unknown[]) => Promise + const goals = (ctx as FixtureContext).goals + const rename = goals.rename as unknown as (...args: unknown[]) => Promise - await expect(create('agent-1')).rejects.toThrow('expected 2 argument(s), got 1') + await expect(create('agent-1')).rejects.toThrow('expected 2 business argument(s) plus an optional AbortSignal, got 1') + await expect(create('agent-1', { objective: 'ship' }, undefined, 'extra')) + .rejects.toThrow('got 4') + await expect(rename.call(goals)).rejects.toThrow('expected 1 argument(s), got 0') await expect((ctx as FixtureContext).goals.create({ objective: 'ship' })) .rejects.toThrow('no Client Context binder') diff --git a/packages/host/api-gateway/tests/gateway.spec.ts b/packages/host/api-gateway/tests/gateway.spec.ts index 4aeadeedb8..c05bfefb93 100644 --- a/packages/host/api-gateway/tests/gateway.spec.ts +++ b/packages/host/api-gateway/tests/gateway.spec.ts @@ -45,6 +45,7 @@ const emptyModel: TypertContribution['model'] = { class GoalService extends Service { readonly typertGateway = bindTypeRTGateway(this, 'goals') readonly calls: string[] = [] + lastSignal: AbortSignal | undefined nextResult: unknown = undefined businessError: Error | undefined @@ -53,8 +54,9 @@ class GoalService extends Service { } @Remote - create(agent: FixtureAgent, request: { readonly title: string }): unknown { + create(agent: FixtureAgent, request: { readonly title: string }, signal: AbortSignal): unknown { this.calls.push('create') + this.lastSignal = signal return { agentId: agent.id, title: request.title, @@ -224,6 +226,19 @@ class RestParameterService extends Service { } } +class NonFinalSignalService extends Service { + readonly typertGateway = bindTypeRTGateway(this, 'nonFinalSignal', { namespace: 'invalid-signal' }) + + constructor(ctx: Context) { + super(ctx, 'nonFinalSignal') + } + + @Remote + run(signal: AbortSignal, value: string): string { + return signal.aborted ? '' : value + } +} + class WrongBindingService extends Service { readonly typertGateway = bindTypeRTGateway(this, 'notWrongBinding', { namespace: 'wrong-binding' }) @@ -334,13 +349,24 @@ describe('TypertGatewayService', () => { registerAgentLookup(ctx, agent) registerStrict(ctx, [createDescriptor()]) const caller = ctx.extend({ fixtureScope: 'direct-caller' }) + const abort = new AbortController() await expect(caller.typertGateway.invoke({ namespace: 'goals', method: 'create', args: { agentId: 'agent-1', request: { title: ' ship ' } }, + signal: abort.signal, })).resolves.toEqual({ agentId: 'agent-1', title: 'ship', scope: 'direct-caller' }) expect(service.calls).toEqual(['create']) + expect(service.lastSignal).toBe(abort.signal) + + await expect(caller.typertGateway.invoke({ + namespace: 'goals', + method: 'create', + args: { agentId: 'agent-1', request: { title: 'again' } }, + })).resolves.toEqual({ agentId: 'agent-1', title: 'again', scope: 'direct-caller' }) + expect(service.lastSignal).toBeInstanceOf(AbortSignal) + expect(service.lastSignal?.aborted).toBe(false) }) it('resolves strict Remote Context identity without adding a business argument', async () => { @@ -358,16 +384,19 @@ describe('TypertGatewayService', () => { }) it('derives SRC direct lookup and JSON parameters from marker and parameter names', async () => { - const { ctx } = await setup() + const { ctx, service } = await setup() const agent = { id: 'agent-1' } registerAgentLookup(ctx, agent) const caller = ctx.extend({ fixtureScope: 'direct-src' }) + const abort = new AbortController() await expect(caller.typertGateway.invoke({ namespace: 'goals', method: 'create', args: { agentId: 'agent-1', request: { title: 'ship' } }, + signal: abort.signal, })).resolves.toEqual({ agentId: 'agent-1', title: 'ship', scope: 'direct-src' }) + expect(service.lastSignal).toBe(abort.signal) }) it('does not downgrade an observed SRC lookup after its provider unloads', async () => { @@ -605,6 +634,7 @@ describe('TypertGatewayService', () => { { plugin: DefaultParameterService, namespace: 'invalid-default', args: { value: 'x' } }, { plugin: DestructuredParameterService, namespace: 'invalid-destructure', args: { value: { value: 'x' } } }, { plugin: RestParameterService, namespace: 'invalid-rest', args: { values: ['x'] } }, + { plugin: NonFinalSignalService, namespace: 'invalid-signal', args: { value: 'x' } }, ] as const for (const testCase of cases) { const ctx = await setupGateway() @@ -874,7 +904,8 @@ describe('TypertGatewayService', () => { expect(connection.matches?.('goals')).toBe(false) expect(connection.matches?.('goals/missing')).toBe(false) expect(connection.matches?.('legacy/list')).toBe(false) - const signal = new AbortController().signal + const abort = new AbortController() + const signal = abort.signal const handler = connection.handler if (handler === undefined) throw new Error('fixture Connection did not retain the /api interceptor') await expect(handler('goals/create', { @@ -883,6 +914,10 @@ describe('TypertGatewayService', () => { ok: true, value: { agentId: 'agent-1', title: 'ship', scope: 'rpc-caller' }, }) + const service = rawGoalService(ctx) + expect(service.lastSignal).toBe(signal) + abort.abort(new Error('client disconnected')) + expect(service.lastSignal?.aborted).toBe(true) const invalid = await handler('goals/create', { invalid: true }, signal) expect(invalid).toMatchObject({ ok: false, @@ -904,7 +939,6 @@ describe('TypertGatewayService', () => { expect(result.error.message).toContain('plain-object args field') } - const service = rawGoalService(ctx) service.businessError = 'non-error failure' as unknown as Error await expect(handler('goals/fail', { args: { request: null } }, signal)).resolves.toEqual({ ok: false, @@ -1099,6 +1133,7 @@ function createDescriptor(): InvocationDescriptor { })), }, ], + cancellation: { parameter: 'signal' }, result: strictCodec('@fixture/gateway#CreateResult', z.object({ agentId: z.string(), title: z.string(), diff --git a/packages/typert/generator/src/analyzer.ts b/packages/typert/generator/src/analyzer.ts index f430d757fb..87a23f17f5 100644 --- a/packages/typert/generator/src/analyzer.ts +++ b/packages/typert/generator/src/analyzer.ts @@ -963,8 +963,9 @@ class FaceAnalyzer { const lookups = this.lookupDeclarations() const lookupByHost = new Map(lookups.map(lookup => [lookup.hostSymbol, lookup])) const parameters: InvocationParameterModel[] = [] + let cancellation: InvocationModel['cancellation'] const wires = new Set() - for (const parameter of method.parameters) { + for (const [parameterIndex, parameter] of method.parameters.entries()) { if (!ts.isIdentifier(parameter.name)) { this.fail(parameter, 'Remote parameters must use identifier bindings') } @@ -973,6 +974,18 @@ class FaceAnalyzer { if (parameter.questionToken !== undefined) this.fail(parameter, 'Remote parameters cannot be optional') if (parameter.name.text === 'this') this.fail(parameter, 'Remote methods cannot declare an explicit this parameter') const authoredType = this.requiredType(parameter, parameter.type, 'parameter') + const cancellationName = parameter.name.text === 'signal' + const cancellationType = this.isGlobalAbortSignal(authoredType) + if (cancellationName || cancellationType) { + if (!cancellationName || !cancellationType) { + this.fail(parameter, 'Remote cancellation must use a parameter named signal with the global AbortSignal type') + } + if (parameterIndex !== method.parameters.length - 1) { + this.fail(parameter, 'Remote cancellation signal must be the final parameter') + } + cancellation = { parameter: 'signal' } + continue + } const hostSymbol = this.symbolAtType(authoredType) const lookup = hostSymbol === undefined ? undefined : lookupByHost.get(this.symbolId(hostSymbol)) let modeled: InvocationParameterModel @@ -1065,6 +1078,7 @@ class FaceAnalyzer { invocation: receiver, ...(scope === undefined ? {} : { scope }), parameters, + ...(cancellation === undefined ? {} : { cancellation }), result: this.remoteBoundary( resultType, `${registration.name}#${binding.namespace}/${exportedMethod}:result`, @@ -1181,6 +1195,13 @@ class FaceAnalyzer { return resultType } + private isGlobalAbortSignal(type: ts.TypeNode): boolean { + const symbol = this.symbolAtType(type) + if (symbol?.name !== 'AbortSignal') return false + return symbol.declarations?.some(declaration => + isStandardLibraryFile(declaration.getSourceFile().fileName)) === true + } + private lookupDeclarations(): readonly StaticLookupDeclaration[] { if (this.staticLookups !== undefined) return this.staticLookups const byKey = new Map() diff --git a/packages/typert/generator/src/emitter.ts b/packages/typert/generator/src/emitter.ts index 63b1ee7ace..c8b9ab4195 100644 --- a/packages/typert/generator/src/emitter.ts +++ b/packages/typert/generator/src/emitter.ts @@ -315,6 +315,9 @@ export class FaceModelEmitter { lines.push(' },') }) lines.push(' ],') + if (invocation.cancellation !== undefined) { + lines.push(" cancellation: { parameter: 'signal' },") + } lines.push(` result: ${indent(strictCodec( invocation.result, schemas.boundary(resultBoundaryKey(invocation)), @@ -459,6 +462,7 @@ export class FaceModelEmitter { const parameters = invocation.parameters.filter(parameter => !scoped || invocation.invocation.kind === 'context' || parameter.wire !== invocation.scope?.wire).map(parameter => `${safeIdentifier(parameter.wire)}: ${this.renderer.renderType(parameter.boundary.type, referenceNames)}`) + if (invocation.cancellation !== undefined) parameters.push('signal?: AbortSignal') const result = this.renderer.renderType(invocation.result.type, referenceNames) return `(${parameters.join(', ')}) => Promise<${result}>` } diff --git a/packages/typert/generator/src/model.ts b/packages/typert/generator/src/model.ts index 7f15c8407c..81bc6a91a1 100644 --- a/packages/typert/generator/src/model.ts +++ b/packages/typert/generator/src/model.ts @@ -140,6 +140,9 @@ export interface InvocationModel { readonly wire: string } readonly parameters: readonly InvocationParameterModel[] + readonly cancellation?: { + readonly parameter: 'signal' + } readonly result: RemoteBoundaryModel readonly location: SourceLocation } diff --git a/packages/typert/generator/tests/fixtures/remote-model/packages/remote/src/index.ts b/packages/typert/generator/tests/fixtures/remote-model/packages/remote/src/index.ts index 816a13a5a7..115b3b87a6 100644 --- a/packages/typert/generator/tests/fixtures/remote-model/packages/remote/src/index.ts +++ b/packages/typert/generator/tests/fixtures/remote-model/packages/remote/src/index.ts @@ -12,7 +12,8 @@ export class GoalService { readonly typertGateway = bindTypeRTGateway(this, 'goals') @Remote - async create(agent: Agent, request: CreateGoalRequest): Promise { + async create(agent: Agent, request: CreateGoalRequest, signal: AbortSignal): Promise { + signal.throwIfAborted() return { ref: `${agent.id}:${request.title}` } } diff --git a/packages/typert/generator/tests/remote-model.spec.ts b/packages/typert/generator/tests/remote-model.spec.ts index cb6e6e6060..d5838f39ce 100644 --- a/packages/typert/generator/tests/remote-model.spec.ts +++ b/packages/typert/generator/tests/remote-model.spec.ts @@ -16,6 +16,7 @@ interface RuntimeSchema { interface RuntimeDescriptor { readonly id: string + readonly cancellation?: { readonly parameter: 'signal' } readonly parameters: readonly { readonly wire: string readonly codec: { readonly schema: RuntimeSchema } @@ -83,6 +84,7 @@ describe('Remote model generation', { timeout: 60_000 }, () => { boundary: { typeSymbol: '@fixture/remote/types#CreateGoalRequest' }, }, ], + cancellation: { parameter: 'signal' }, result: { typeSymbol: '@fixture/remote/types#CreateGoalResult' }, }) expect(model.invocations[1]).toMatchObject({ @@ -107,12 +109,12 @@ describe('Remote model generation', { timeout: 60_000 }, () => { expect(artifact?.js).toContain('invocations: [') expect(artifact?.remote?.dts).toContain( - "'goals/create': (agentId: AgentId, request: CreateGoalRequest) => Promise", + "'goals/create': (agentId: AgentId, request: CreateGoalRequest, signal?: AbortSignal) => Promise", ) expect(artifact?.remote?.dts).toContain('interface TypeRTRemoteNamespace$676f616c73 {\n create:') expect(artifact?.remote?.dts).toContain("'goals': TypeRTRemoteNamespace$676f616c73") expect(artifact?.remote?.dts).toContain( - "'agent:goals/create': (request: CreateGoalRequest) => Promise", + "'agent:goals/create': (request: CreateGoalRequest, signal?: AbortSignal) => Promise", ) expect(artifact?.remote?.dts).toContain( "'agent:goals/rename': (request: RenameGoalRequest) => Promise", @@ -124,6 +126,7 @@ describe('Remote model generation', { timeout: 60_000 }, () => { const generated = await import(`data:text/javascript,${encodeURIComponent(executable)}`) as RuntimeRemoteModule expect(generated.TYPERT_REMOTE.package).toBe('@fixture/remote') const create = generated.TYPERT_REMOTE.descriptors[0] + expect(create?.cancellation).toEqual({ parameter: 'signal' }) expect(create?.parameters[1]?.codec.schema.safeParse({ title: 'ship' }).success).toBe(true) expect(create?.parameters[1]?.codec.schema.safeParse({ title: 1 }).success).toBe(false) expect(create?.result.schema.safeParse({ ref: 'goal-1' }).success).toBe(true) @@ -234,8 +237,8 @@ export type GenericResult = { edit: (source: string) => source .replace('export class GoalService', 'export abstract class GoalService') .replace( - ' async create(agent: Agent, request: CreateGoalRequest): Promise {\n return { ref: `${agent.id}:${request.title}` }\n }', - ' abstract create(agent: Agent, request: CreateGoalRequest): Promise', + ' async create(agent: Agent, request: CreateGoalRequest, signal: AbortSignal): Promise {\n signal.throwIfAborted()\n return { ref: `${agent.id}:${request.title}` }\n }', + ' abstract create(agent: Agent, request: CreateGoalRequest, signal: AbortSignal): Promise', ), message: 'Remote methods must have a concrete implementation', }, @@ -267,6 +270,24 @@ export type GenericResult = { edit: (source: string) => source.replace('request: CreateGoalRequest', 'request?: CreateGoalRequest'), message: 'Remote parameters cannot be optional', }, + { + name: 'wrong cancellation type', + edit: (source: string) => source.replace('signal: AbortSignal', 'signal: string'), + message: 'cancellation must use a parameter named signal with the global AbortSignal type', + }, + { + name: 'wrong cancellation name', + edit: (source: string) => source.replace('signal: AbortSignal', 'abort: AbortSignal'), + message: 'cancellation must use a parameter named signal with the global AbortSignal type', + }, + { + name: 'non-final cancellation', + edit: (source: string) => source.replace( + 'agent: Agent, request: CreateGoalRequest, signal: AbortSignal', + 'agent: Agent, signal: AbortSignal, request: CreateGoalRequest', + ), + message: 'cancellation signal must be the final parameter', + }, ])('rejects $name', ({ edit, message }) => { const root = copyFixture() editFile(root, 'packages/remote/src/index.ts', edit) @@ -399,12 +420,14 @@ declare const create: TypeRTRemoteMap['goals/create'] declare const createScoped: TypeRTRemoteContextMap['agent:goals/create'] declare const rename: TypeRTRemoteContextMap['agent:goals/rename'] const created: Promise = create('agent-1', { title: 'ship' }) +const cancellable: Promise = create('agent-1', { title: 'ship' }, new AbortController().signal) const createdScoped: Promise = createScoped({ title: 'ship' }) const renamed: Promise = rename({ ref: 'goal-1', title: 'land' }) declare const ctx: { api: TypeRTRemoteNamespaceMap } const navigated: Promise = ctx.api.goals.create('agent-1', { title: 'navigate' }) void contribution void created +void cancellable void createdScoped void renamed void navigated diff --git a/packages/typert/loader/src/index.ts b/packages/typert/loader/src/index.ts index efe0fa6f94..575d066e0d 100644 --- a/packages/typert/loader/src/index.ts +++ b/packages/typert/loader/src/index.ts @@ -226,6 +226,12 @@ function requireInvocation(pkgName: string, value: unknown): void { parameters.set(wire, parameter) requireStrictCodec(pkgName, parameter.codec, `invocation "${id}" parameter codec`) } + if (invocation.cancellation !== undefined) { + const cancellation = requireObject(pkgName, invocation.cancellation, `invocation "${id}" cancellation`) + if (cancellation.parameter !== 'signal') { + throw new Error(`typert-loader: ${pkgName} invocation "${id}" cancellation parameter must be "signal"`) + } + } if (invocation.scope !== undefined) { if (receiver.kind !== 'direct') { throw new Error(`typert-loader: ${pkgName} invocation "${id}" Context receiver cannot declare a direct scope projection`) diff --git a/packages/typert/loader/tests/loader.spec.ts b/packages/typert/loader/tests/loader.spec.ts index ec407f82d9..750cc92e57 100644 --- a/packages/typert/loader/tests/loader.spec.ts +++ b/packages/typert/loader/tests/loader.spec.ts @@ -83,6 +83,7 @@ function invocationTypertSource(pkgName: string): string { ' name: \'request\', wire: \'request\', source: \'json\',', ` codec: { mode: 'strict', typeSymbol: '${pkgName}/types#Request', schema: Text },`, ' }],', + " cancellation: { parameter: 'signal' },", ` result: { mode: 'strict', typeSymbol: '${pkgName}/types#Result', schema: Text },`, ' sourceLocation: { file: \'src/index.ts\', line: 8, column: 3 },', ' }],', @@ -157,6 +158,7 @@ describe('typert loader', () => { id: '@fixture/invocation#goals/create', invocation: { kind: 'direct' }, parameters: [{ wire: 'request', source: 'json' }], + cancellation: { parameter: 'signal' }, sourceLocation: { file: 'src/index.ts', line: 8, column: 3 }, }) expect(descriptor?.parameters[0]?.codec.mode).toBe('strict') @@ -502,6 +504,9 @@ describe('validateTypertManifest', () => { const descriptor = strictInvocation() const manifest = { ...base, invocations: [descriptor] } expect(validateTypertManifest('pkg', manifest)).toBe(manifest) + const cancellable = { ...descriptor, cancellation: { parameter: 'signal' } } + expect(validateTypertManifest('pkg', { ...base, invocations: [cancellable] }).invocations) + .toEqual([cancellable]) const scoped = { ...descriptor, scope: { context: 'agent', wire: 'agentId' }, @@ -526,6 +531,14 @@ describe('validateTypertManifest', () => { ...base, invocations: [{ ...descriptor, result: { mode: 'src-json' } }], })).toThrow('result codec must use a strict codec') + expect(() => validateTypertManifest('pkg', { + ...base, + invocations: [{ ...descriptor, cancellation: null }], + })).toThrow('cancellation must be an object') + expect(() => validateTypertManifest('pkg', { + ...base, + invocations: [{ ...descriptor, cancellation: { parameter: 'abort' } }], + })).toThrow('cancellation parameter must be "signal"') expect(() => validateTypertManifest('pkg', { ...base, invocations: [{ ...descriptor, result: { mode: 'strict', typeSymbol: 'pkg#Result', schema: zodish } }], diff --git a/packages/typert/registry/src/service.ts b/packages/typert/registry/src/service.ts index 6749cdbeb9..229dc7affc 100644 --- a/packages/typert/registry/src/service.ts +++ b/packages/typert/registry/src/service.ts @@ -562,6 +562,9 @@ function validateInvocation(descriptor: InvocationDescriptor): void { } validateCodec(parameter.codec, `${descriptor.id} parameter ${parameter.name}`) } + if (descriptor.cancellation !== undefined && descriptor.cancellation.parameter !== 'signal') { + throw new Error(`typert: invocation "${descriptor.id}" cancellation parameter must be "signal"`) + } if (descriptor.scope !== undefined) { if (descriptor.invocation.kind !== 'direct') { throw new Error(`typert: invocation "${descriptor.id}" Context receiver cannot declare a direct scope projection`) diff --git a/packages/typert/registry/tests/typert.spec.ts b/packages/typert/registry/tests/typert.spec.ts index 51e7594749..5603ce8954 100644 --- a/packages/typert/registry/tests/typert.spec.ts +++ b/packages/typert/registry/tests/typert.spec.ts @@ -411,6 +411,7 @@ describe('TypertRegistry', () => { ...invocation('@fixture/remote#strict'), implementation: 'remoteExportCreate', parameters: [{ name: 'request', wire: 'request', source: 'json', codec: strict }], + cancellation: { parameter: 'signal' }, result: strict, } const dispose = ctx.typert.remotes.register({ package: '@fixture/strict', descriptors: [strictInvocation] }) @@ -420,6 +421,10 @@ describe('TypertRegistry', () => { [{ ...invocation(), id: '' }, 'invocation id'], [{ ...invocation(), namespace: 'bad/name' }, 'namespace'], [{ ...invocation(), implementation: 'bad/name' }, 'implementation method'], + [{ + ...invocation(), + cancellation: { parameter: 'abort' } as unknown as { readonly parameter: 'signal' }, + }, 'cancellation parameter'], [{ ...invocation(), parameters: [ diff --git a/packages/typert/type-meta/README.i18n.yaml b/packages/typert/type-meta/README.i18n.yaml index 90d93152b7..9751c4c088 100644 --- a/packages/typert/type-meta/README.i18n.yaml +++ b/packages/typert/type-meta/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/typert/type-meta/README.md -README.md: 9dd8dadd07b219c7471c8851262958d4d9e96a43 -README.zh.md: 5716f56d988c6d2dd9cd237346c3b02ec9ae7c4e +README.md: 95716446c01c7fd510cdf55a82509b5b8af6f3ae +README.zh.md: 0d30b3122265d9bb3caa289345f843fe67377be3 diff --git a/packages/typert/type-meta/README.md b/packages/typert/type-meta/README.md index 9dd8dadd07..95716446c0 100644 --- a/packages/typert/type-meta/README.md +++ b/packages/typert/type-meta/README.md @@ -11,6 +11,8 @@ Compiler-independent declarations shared by business packages, generated TypeRT - `bindTypeRTGateway(this, serviceKey, options?)` creates the visible, frozen binding between a Service instance, its exact Cordis key, and its wire namespace. - `remoteMethods(service)` returns a detached declaration-order snapshot used by the Gateway's SRC fallback. +A Host method opts into cooperative cancellation by declaring `signal: AbortSignal` as its final parameter. `InvocationDescriptor.cancellation` records that reserved injection point; the signal never becomes a JSON parameter or lookup field. SRC recognizes the final parameter name, while strict generation also verifies the global `AbortSignal` type. + Decorator initializers retain markers in a module-private `WeakMap` keyed by the Service prototype. They do not add constructor symbols, prototype properties, parameter metadata, or runtime reflection fields. The Service opts in explicitly through its `typertGateway` binding field. ## TypeRT protocol diff --git a/packages/typert/type-meta/README.zh.md b/packages/typert/type-meta/README.zh.md index 5716f56d98..0d30b31222 100644 --- a/packages/typert/type-meta/README.zh.md +++ b/packages/typert/type-meta/README.zh.md @@ -11,6 +11,8 @@ - `bindTypeRTGateway(this, serviceKey, options?)` 在服务实例、其准确的 Cordis key 与协议命名空间之间创建可见且冻结的绑定。 - `remoteMethods(service)` 返回按声明顺序排列、与内部状态分离的快照,供 Gateway 的 SRC 回退路径使用。 +Host 方法通过将 `signal: AbortSignal` 声明为最后一个参数来启用协作式取消。`InvocationDescriptor.cancellation` 记录这个保留的注入点;signal 绝不会成为 JSON 参数或 lookup 字段。SRC 识别末位参数名,严格生成还会校验它是否具有全局 `AbortSignal` 类型。 + 装饰器初始化器将标记保存在以服务 prototype 为键的模块私有 `WeakMap` 中。它们不会在构造函数上添加 symbol,也不会添加 prototype 属性、参数元数据或运行时反射字段。服务通过自身的 `typertGateway` 绑定字段显式接入。 ## TypeRT 协议 diff --git a/packages/typert/type-meta/src/types.ts b/packages/typert/type-meta/src/types.ts index f9ed7ffa97..6de5c7f823 100644 --- a/packages/typert/type-meta/src/types.ts +++ b/packages/typert/type-meta/src/types.ts @@ -157,6 +157,11 @@ export interface InvocationDescriptor { } /** Ordered business parameters. */ readonly parameters: readonly InvocationParameterDescriptor[] + /** Transport cancellation injected after business parameters instead of entering wire args. */ + readonly cancellation?: { + /** Reserved final Host method parameter. */ + readonly parameter: 'signal' + } /** Codec for the resolved method result. */ readonly result: TypeRTCodec /** Source declaration used only for diagnostics. */ From 1ea5507bf893cae71de68da12d534d6d5dca6d03 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 6 Aug 2026 18:43:31 +0800 Subject: [PATCH 26/63] fix(typert): close remote gateway review gaps --- ...08-02-typert-remote-method-calls.i18n.yaml | 4 +- .../2026-08-02-typert-remote-method-calls.md | 2 +- ...026-08-02-typert-remote-method-calls.zh.md | 2 +- packages/goal/goal/tests/goal.spec.ts | 16 ++++++++ .../host/api-gateway/tests/gateway.spec.ts | 12 ++++-- packages/typert/registry/src/service.ts | 3 +- scripts/run-gates.ts | 1 + vitest.config.ts | 30 +++------------ vitest.e2e.config.ts | 4 +- vitest.shared.ts | 37 +++++++++++++++++++ vitest.snapshot.config.ts | 4 +- 11 files changed, 77 insertions(+), 38 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml index bd83c38a3e..57e1054dfc 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md -2026-08-02-typert-remote-method-calls.md: 4268539ecf0d40a9e8080e0571992cc2c5d724af -2026-08-02-typert-remote-method-calls.zh.md: f9f426f2fb80c74cb9ebaef15e801ccfcf67e027 +2026-08-02-typert-remote-method-calls.md: 552e910b403312c7c7a1cec3a14c0dc1f9cc4380 +2026-08-02-typert-remote-method-calls.zh.md: 18b8c1687d2c01aa23bb7cb9402fccf85fec333d diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md index 4268539ecf..552e910b40 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md @@ -452,7 +452,7 @@ The Gateway registers only its ownership matcher and RPC handler with Connection The shipped vertical path is `@deepseek-ai/dsh-goal/remote → Browser Client API → Connection RPC /api → Host Gateway → GoalService.remoteExportCreate()`. The same direct descriptor with an Agent lookup supports both `ctx.api.goals.create(agentId, request)` and `agentCtx.goals.create(request)`. `@RemoteContext('agent')` remains the distinct scoped-receiver mode. -Connection supplies the shared-channel interceptor and current HTTP carrier mapping. WebSocket migration, the TUI runtime and carrier, TUI Agent Scope wiring, Permission/Approval state machines, Session event streams, call authorization, cancellation, retries, idempotency, and cross-version protocol compatibility remain outside this decision. +Connection supplies the shared-channel interceptor and current HTTP carrier mapping. WebSocket migration, the TUI runtime and carrier, TUI Agent Scope wiring, Permission/Approval state machines, Session event streams, call authorization, retries, idempotency, and cross-version protocol compatibility remain outside this decision. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md index f9f426f2fb..18b8c1687d 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md @@ -452,7 +452,7 @@ Gateway 只向 Connection 注册 ownership matcher 和 RPC handler,不注册 H 已交付的纵向链路是 `@deepseek-ai/dsh-goal/remote → Browser Client API → Connection RPC /api → Host Gateway → GoalService.remoteExportCreate()`。同一个带 Agent lookup 的 direct descriptor 同时支持 `ctx.api.goals.create(agentId, request)` 与 `agentCtx.goals.create(request)`。`@RemoteContext('agent')` 仍是独立的 scoped receiver 模式。 -Connection 提供共享 channel interceptor 与当前 HTTP carrier 映射。WebSocket 迁移、TUI runtime 与 carrier、TUI Agent Scope 接线、Permission/Approval 状态机、Session 事件流、调用授权、取消、重试、幂等及跨版本协议兼容均不属于本决策。 +Connection 提供共享 channel interceptor 与当前 HTTP carrier 映射。WebSocket 迁移、TUI runtime 与 carrier、TUI Agent Scope 接线、Permission/Approval 状态机、Session 事件流、调用授权、重试、幂等及跨版本协议兼容均不属于本决策。 ## Alternatives considered diff --git a/packages/goal/goal/tests/goal.spec.ts b/packages/goal/goal/tests/goal.spec.ts index 38eea7cf61..2dd5885cc7 100644 --- a/packages/goal/goal/tests/goal.spec.ts +++ b/packages/goal/goal/tests/goal.spec.ts @@ -245,6 +245,22 @@ describe('GoalService creation and replay', () => { }) describe('GoalService mutations', () => { + it('exposes the supported mutation sequence through Remote wrappers', async () => { + const { ctx, agent } = await harness() + const created = ctx.goals.remoteExportCreate(agent, { objective: 'remote lifecycle' }) + const edited = ctx.goals.remoteExportEdit(agent, created.ref, { objective: 'edited remotely' }) + const paused = ctx.goals.remoteExportPause(agent, edited) + const resumed = ctx.goals.remoteExportResume(agent, paused) + const completed = ctx.goals.remoteExportComplete(agent, resumed) + const cleared = ctx.goals.remoteExportClear(agent, completed) + + expect(edited).toMatchObject({ objective: 'edited remotely', revision: 2 }) + expect(paused).toMatchObject({ phase: 'paused', revision: 3 }) + expect(resumed).toMatchObject({ phase: 'active', revision: 4 }) + expect(completed).toMatchObject({ phase: 'complete', revision: 5 }) + expect(cleared).toEqual({ id: created.ref.id, revision: 6 }) + }) + it('edits with compare-and-set revisions and rejects empty edits', async () => { const { ctx, agent } = await harness() const created = ctx.goals.create(agent, { objective: 'old', maxGoalRounds: 4 }) diff --git a/packages/host/api-gateway/tests/gateway.spec.ts b/packages/host/api-gateway/tests/gateway.spec.ts index c05bfefb93..6558a7ca47 100644 --- a/packages/host/api-gateway/tests/gateway.spec.ts +++ b/packages/host/api-gateway/tests/gateway.spec.ts @@ -998,14 +998,16 @@ describe('TypertGatewayService', () => { }), }) expect(invalid.status).toBe(200) - await expect(invalid.json()).resolves.toMatchObject({ + const invalidBody = await invalid.json() as unknown + expect(invalidBody).toMatchObject({ type: 'server-response', rpcId: 'rpc-invalid', result: { ok: false, - error: { code: 'internal', message: expect.stringContaining('plain-object args field') }, + error: { code: 'internal' }, }, }) + expect(JSON.stringify(invalidBody)).toContain('plain-object args field') await removeStrict() strictActive = false @@ -1020,14 +1022,16 @@ describe('TypertGatewayService', () => { }), }) expect(withdrawn.status).toBe(200) - await expect(withdrawn.json()).resolves.toMatchObject({ + const withdrawnBody = await withdrawn.json() as unknown + expect(withdrawnBody).toMatchObject({ type: 'server-response', rpcId: 'rpc-withdrawn', result: { ok: false, - error: { code: 'internal', message: expect.stringContaining('strict definition was withdrawn') }, + error: { code: 'internal' }, }, }) + expect(JSON.stringify(withdrawnBody)).toContain('strict definition was withdrawn') const unclaimed = await fetch(`${server.origin}/api/legacy/list`, { method: 'POST' }) expect(unclaimed.status).toBe(404) diff --git a/packages/typert/registry/src/service.ts b/packages/typert/registry/src/service.ts index 229dc7affc..d04f38cde5 100644 --- a/packages/typert/registry/src/service.ts +++ b/packages/typert/registry/src/service.ts @@ -562,7 +562,8 @@ function validateInvocation(descriptor: InvocationDescriptor): void { } validateCodec(parameter.codec, `${descriptor.id} parameter ${parameter.name}`) } - if (descriptor.cancellation !== undefined && descriptor.cancellation.parameter !== 'signal') { + const cancellation = descriptor.cancellation as { readonly parameter: string } | undefined + if (cancellation !== undefined && cancellation.parameter !== 'signal') { throw new Error(`typert: invocation "${descriptor.id}" cancellation parameter must be "signal"`) } if (descriptor.scope !== undefined) { diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index f7669eac7e..6d6a76e476 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -601,6 +601,7 @@ function builtBinSmokeGate(needs: string[] = ['build']): Gate { 'packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts', 'packages/subagent/subagent-codex/tests/loader-composition.e2e.ts', 'packages/subagent/subagent-claude-code/tests/loader-composition.e2e.ts', + 'packages/client/remotes/tests/built-lib.e2e.ts', // The worker-entry packages' built bundles: the only automated proof // that lib/index.js resolves its sibling lib/worker.cjs under plain node // (the e2e lane runs unbuilt, so these files self-skip there). diff --git a/vitest.config.ts b/vitest.config.ts index 4c4c668b94..56a5a1575b 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -3,8 +3,7 @@ import { fileURLToPath } from 'node:url' import tsconfigPaths from 'vite-tsconfig-paths' import { resolvePwshPath } from './packages/bash/pwsh-local/src/resolve.ts' import { defineConfig } from 'vitest/config' -import ts from 'typescript' -import { vitestExecArgv } from './vitest.shared.ts' +import { standardDecoratorPlugin, vitestExecArgv } from './vitest.shared.ts' import { COVERAGE_EXEMPT_ENV, coverageExemptHeavySuites } from './scripts/coverage-exempt.ts' // Prints exact `path:line:col` records for every uncovered statement, branch @@ -18,29 +17,6 @@ const uncoveredLocationsReporter = fileURLToPath(new URL('./scripts/coverage-unc // map applies to every test file. paths must win over package exports so built // lib/ never loads a second module-singleton copy. const pathsPlugin = (): ReturnType => tsconfigPaths({ projects: ['./tsconfig.base.json'] }) -const decoratorSyntax = /^\s*@[A-Za-z_$][\w$]*/m - -const standardDecoratorPlugin = () => ({ - name: 'dsh-standard-decorators', - enforce: 'pre' as const, - transform(code: string, id: string) { - const file = id.split('?', 1)[0]! - if (!/\.[cm]?tsx?$/.test(file) || !decoratorSyntax.test(code)) return - const result = ts.transpileModule(code, { - fileName: file, - compilerOptions: { - target: ts.ScriptTarget.ES2024, - module: ts.ModuleKind.ESNext, - jsx: file.endsWith('x') ? ts.JsxEmit.ReactJSX : undefined, - sourceMap: true, - }, - }) - return { - code: result.outputText.replace(/\n?\/\/# sourceMappingURL=.*$/u, '\n'), - map: result.sourceMapText, - } - }, -}) const windowsUnsupportedPackages = process.platform === 'win32' ? [ @@ -203,6 +179,10 @@ export default defineConfig({ 'packages/client/hmr/src/invariant.ts', 'packages/client/connection/src/index.ts', 'packages/client/connection/src/http-bridge.ts', + // This assembly imports generated Host-for-Client code that exists + // only in lib; the post-build built-bin smoke executes both entries. + 'packages/client/remotes/src/index.ts', + 'packages/client/remotes/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/connection/src/client/fixture.ts', diff --git a/vitest.e2e.config.ts b/vitest.e2e.config.ts index d8e6aa53a7..f898d2d9da 100644 --- a/vitest.e2e.config.ts +++ b/vitest.e2e.config.ts @@ -1,6 +1,6 @@ import tsconfigPaths from 'vite-tsconfig-paths' import { defineConfig } from 'vitest/config' -import { vitestExecArgv } from './vitest.shared.ts' +import { standardDecoratorPlugin, vitestExecArgv } from './vitest.shared.ts' // Real-API suite, separate because it spends tokens. Each test self-skips without // its provider credential for keyless CI; credentialed workflows preflight the @@ -36,7 +36,7 @@ export default defineConfig({ // Built-artifact e2e suites are unaffected: their built-ness lives in // subprocesses and createRequire lookups, which bypass vite resolution // entirely. - plugins: [tsconfigPaths({ projects: ['./tsconfig.base.json'] })], + plugins: [tsconfigPaths({ projects: ['./tsconfig.base.json'] }), standardDecoratorPlugin()], test: { execArgv: vitestExecArgv, setupFiles: ['./scripts/test-invariants.ts'], diff --git a/vitest.shared.ts b/vitest.shared.ts index 506fabb380..7c6ca2bee8 100644 --- a/vitest.shared.ts +++ b/vitest.shared.ts @@ -1,5 +1,42 @@ +import ts from 'typescript' + +const decoratorSyntax = /^\s*@[A-Za-z_$][\w$]*/m + /** * Worker arguments that keep process-wide Web Storage from shadowing jsdom storage. * Node lists the positive spelling in `allowedNodeEnvironmentFlags` for this negatable flag. */ export const vitestExecArgv = process.allowedNodeEnvironmentFlags.has('--webstorage') ? ['--no-webstorage'] : [] + +/** + * Transform standard TypeScript decorators before Vite's default parser sees source files. + * @returns a pre-transform Vite plugin shared by source-mode test configurations. + */ +export function standardDecoratorPlugin() { + return { + name: 'dsh-standard-decorators', + enforce: 'pre' as const, + transform(code: string, id: string) { + const file = id.split('?', 1)[0]! + if (!/\.[cm]?tsx?$/.test(file) || !decoratorSyntax.test(code)) return + const result = ts.transpileModule(code, { + fileName: file, + compilerOptions: { + target: ts.ScriptTarget.ES2024, + module: ts.ModuleKind.ESNext, + jsx: file.endsWith('x') ? ts.JsxEmit.ReactJSX : undefined, + sourceMap: true, + }, + }) + return { + code: result.outputText + .replace( + /^(\s*)(__esDecorate\()/gmu, + '$1/* v8 ignore next -- compiler-synthetic decorator accessors have no source behavior */ $2', + ) + .replace(/\n?\/\/# sourceMappingURL=.*$/u, '\n'), + map: result.sourceMapText, + } + }, + } +} diff --git a/vitest.snapshot.config.ts b/vitest.snapshot.config.ts index 455ecfb4d4..cfa7d12e17 100644 --- a/vitest.snapshot.config.ts +++ b/vitest.snapshot.config.ts @@ -1,7 +1,7 @@ import { availableParallelism } from 'node:os' import tsconfigPaths from 'vite-tsconfig-paths' import { defineConfig } from 'vitest/config' -import { vitestExecArgv } from './vitest.shared.ts' +import { standardDecoratorPlugin, vitestExecArgv } from './vitest.shared.ts' const DEFAULT_SNAPSHOT_MAX_CONCURRENCY = 5 @@ -40,7 +40,7 @@ export default defineConfig({ // Same resolution note as vitest.config.ts: bare workspace names resolve // through the tsconfig.base.json paths facade; the native option cannot do // this (the root tsconfig is a solution file with no paths). - plugins: [tsconfigPaths({ projects: ['./tsconfig.base.json'] })], + plugins: [tsconfigPaths({ projects: ['./tsconfig.base.json'] }), standardDecoratorPlugin()], test: { execArgv: vitestExecArgv, setupFiles: ['./scripts/test-invariants.ts'], From e8f2ab89bb98c81374f570386793173f4c718aa2 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 6 Aug 2026 23:04:25 +0800 Subject: [PATCH 27/63] refactor(typert): bind remote services through base class --- ...08-02-typert-remote-method-calls.i18n.yaml | 4 +- .../2026-08-02-typert-remote-method-calls.md | 24 ++++---- ...026-08-02-typert-remote-method-calls.zh.md | 24 ++++---- packages/goal/goal/src/index.ts | 9 +-- packages/host/api-gateway/README.i18n.yaml | 4 +- packages/host/api-gateway/README.md | 2 +- packages/host/api-gateway/README.zh.md | 2 +- packages/typert/generator/src/analyzer.ts | 50 ++++++++++++++-- .../remote-model/packages/remote/src/index.ts | 8 ++- .../fixtures/remote-model/type-meta.d.ts | 13 ++++ .../generator/tests/remote-model.spec.ts | 60 +++++++++++++++++-- packages/typert/type-meta/README.i18n.yaml | 4 +- packages/typert/type-meta/README.md | 7 ++- packages/typert/type-meta/README.zh.md | 7 ++- packages/typert/type-meta/src/index.ts | 18 ++++++ .../type-meta/tests/fixtures/source-launch.ts | 11 ++-- .../typert/type-meta/tests/type-meta.spec.ts | 27 +++++++-- 17 files changed, 213 insertions(+), 61 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml index 57e1054dfc..3808a8d363 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md -2026-08-02-typert-remote-method-calls.md: 552e910b403312c7c7a1cec3a14c0dc1f9cc4380 -2026-08-02-typert-remote-method-calls.zh.md: 18b8c1687d2c01aa23bb7cb9402fccf85fec333d +2026-08-02-typert-remote-method-calls.md: ade8eb827ae765677be8dcdb0ffec965c67bc4ab +2026-08-02-typert-remote-method-calls.zh.md: 2de887a2a0e46148fbb2b5ac52cfd7e3b2305b8d diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md index 552e910b40..ade8eb827a 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md @@ -16,7 +16,7 @@ The Host and Browser Client use separate TypeScript Programs because each side a ## Decision -A business Service declares callable methods with `@Remote` or `@RemoteContext()` and explicitly joins the Gateway through `bindTypeRTGateway()`. TypeRT generates the Host-local reflection artifact and a platform-independent Remote consumer projection from the Host Program. The Client Program continues to generate its own local reflection artifact independently. +A business Service extends `GatewayService` and declares callable methods with `@Remote` or `@RemoteContext()`. A Service that already has another base class may instead expose the same binding through `bindTypeRTGateway()`. TypeRT generates the Host-local reflection artifact and a platform-independent Remote consumer projection from the Host Program. The Client Program continues to generate its own local reflection artifact independently. The Remote consumer projection contains `.d.ts`, `.d.ts.map`, and `.js` files. The `.d.ts` exposes only methods marked with a Remote decorator and refers to the business package's single public type symbols. The `.d.ts.map` navigates consumer API methods back to their Host business method implementations. The `.js` carries endpoint, parameter, Context, and Zod information for the same contract. At the assembly layer, the Browser Client mounts the required Remote JS contributions onto the Client API Service. The projection and API abstraction remain platform-independent so that a future TUI can reuse them. @@ -26,7 +26,7 @@ The Remote consumer projection contains `.d.ts`, `.d.ts.map`, and `.js` files. T | Component | Cordis service | Responsibility | |---|---|---| -| `@deepseek-ai/dsh-type-meta` | Declares only the minimal `ctx.typert` protocol | Decorators, bindings, descriptors, lookup/Context, and the Remote map; no dependency on the compiler, Zod, Connection, or Browser | +| `@deepseek-ai/dsh-type-meta` | Declares only the minimal `ctx.typert` protocol | `GatewayService`, decorators, binding fallback, descriptors, lookup/Context, and the Remote map; no dependency on the compiler, Zod, Connection, or Browser | | TypeRT registry | `ctx.typert` | Separately stores reflection for the current environment, imported Remote contributions, lookup providers, and Context providers | | TypeRT generator/loader | No new business service | Generates three kinds of `lib` artifacts from the Host/Client Programs and registers the current environment's artifacts with `ctx.typert` | | Host API Gateway's Host face | `ctx.typertGateway` | Associates Host definitions with live Services, decodes parameters, resolves receivers, invokes methods, and encodes results | @@ -43,8 +43,10 @@ The Host Gateway does not depend on concrete implementations of `ctx.agents`, `c Ordinary direct calls use `@Remote`. When migrating to an existing Service or Registry, do not rename or alter existing methods. Add `remoteExport*` entry points at the end of the class and use decorator arguments to declare their short API names. A method explicitly declares every required business object in a top-level parameter position: ```text -export class GoalService extends Service { - readonly typertGateway = bindTypeRTGateway(this, 'goals') +export class GoalService extends GatewayService { + constructor(ctx: Context) { + super(ctx, 'goals') + } create(agent: Agent, request: CreateGoalRequest): CreateGoalResult { // Existing business method remains unchanged. @@ -57,13 +59,15 @@ export class GoalService extends Service { } ``` -`goals` is an explicit Cordis service key and is the default wire namespace. Override it through an option to `bindTypeRTGateway()` only when the protocol namespace genuinely needs to differ from the service key. +`goals` is the explicit Cordis service key passed to `super()` and is the default wire namespace. Pass a `namespace` option as the third argument only when the protocol namespace genuinely needs to differ from the service key. Use `@RemoteContext()` when the Service receiver must be resolved within an isolated kind of Context. Context identity does not enter the business method's parameters: ```text -export class ScopedGoalService extends Service { - readonly typertGateway = bindTypeRTGateway(this, 'goals') +export class ScopedGoalService extends GatewayService { + constructor(ctx: Context) { + super(ctx, 'goals') + } @RemoteContext('agent', 'create') remoteExportCreate(request: CreateGoalRequest): Promise { @@ -74,17 +78,17 @@ export class ScopedGoalService extends Service { An endpoint selects exactly one invocation mode. A flow that needs an explicit `Agent` parameter uses `@Remote`. A flow that first switches to an Agent Context and then resolves a scoped receiver uses `@RemoteContext('agent')`. TypeRT does not infer either mode from the method body or from a missing parameter. -Business packages depend only on the lightweight `@deepseek-ai/dsh-type-meta`. It provides declaration protocols for decorators, `bindTypeRTGateway()`, lookup, Remote Context, and descriptors, without depending on the TypeScript compiler, Zod, HTTP, or the Client runtime. +Business packages depend only on the lightweight `@deepseek-ai/dsh-type-meta`. It provides `GatewayService` and declaration protocols for decorators, the binding fallback, lookup, Remote Context, and descriptors, without depending on the TypeScript compiler, Zod, HTTP, or the Client runtime. A method that cooperatively supports cancellation declares `signal: AbortSignal` as its final Host parameter. This reserved parameter is not a business value, lookup, or JSON field. The generated consumer method exposes it as a final optional parameter so ordinary calls remain unchanged while callers that own cancellation can pass a signal. ## Decorators and the explicit Gateway facet -A decorator only states that a method participates in the Remote contract. It performs no runtime type reflection and injects no hidden symbol into a Service constructor. The arguments to `@Remote('create')` and `@RemoteContext('agent', 'create')` are external method names, while the actual member remains named `remoteExportCreate`. The member name becomes the external method name only when no alias is provided. `typertGateway` is the sole explicit marker that a Service has joined the Gateway, making this capability visible on both the business class and its runtime instance. +A decorator only states that a method participates in the Remote contract. It performs no runtime type reflection and injects no hidden symbol into a Service constructor. The arguments to `@Remote('create')` and `@RemoteContext('agent', 'create')` are external method names, while the actual member remains named `remoteExportCreate`. The member name becomes the external method name only when no alias is provided. Inheriting `GatewayService` is the normal explicit declaration that a Service has joined the Gateway; its public readonly `typertGateway` field keeps the binding visible on the runtime instance. In SRC mode, the decorator may record the prototype, method name, and invocation mode in a `WeakMap` internal to `dsh-type-meta`. It writes no custom properties to a Service instance, prototype, constructor, or method function. -In LIB mode, the TypeRT compiler performs strict method discovery, type resolution, and descriptor generation. Generation neither rewrites business source nor secretly supplies generated arguments to `bindTypeRTGateway()`. +In LIB mode, the TypeRT compiler performs strict method discovery, type resolution, and descriptor generation. It accepts a literal service key in `GatewayService`'s direct `super()` call or the explicit binding fallback; generation neither rewrites business source nor injects hidden registration metadata. ## Lookup and Remote Context registration diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md index 18b8c1687d..2de887a2a0 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md @@ -16,7 +16,7 @@ Host 与 Browser Client 使用独立的 TypeScript Program,因为两边会以 ## 决策 -业务 Service 通过 `@Remote` 或 `@RemoteContext()` 声明可调用方法,并通过 `bindTypeRTGateway()` 显式加入 Gateway。TypeRT 从 Host Program 生成 Host 本地反射产物和平台无关的 Remote 消费端投影;Client Program 继续独立生成自己的本地反射产物。 +业务 Service 继承 `GatewayService`,并通过 `@Remote` 或 `@RemoteContext()` 声明可调用方法;已有其他基类的 Service 可以改用 `bindTypeRTGateway()` 暴露同一绑定。TypeRT 从 Host Program 生成 Host 本地反射产物和平台无关的 Remote 消费端投影;Client Program 继续独立生成自己的本地反射产物。 Remote 消费端投影同时包含 `.d.ts`、`.d.ts.map` 和 `.js`。`.d.ts` 只暴露被 Remote decorator 标记的方法,并引用业务包唯一的公共类型符号;`.d.ts.map` 把消费端 API 方法导航回 Host 业务方法实现;`.js` 携带同一契约的 endpoint、参数、Context 和 Zod 信息。Browser Client 在 assembly 层把需要的 Remote JS 贡献集中挂到 Client API Service;该投影和 API 抽象保持平台无关,以便未来 TUI 复用。 @@ -26,7 +26,7 @@ Remote 消费端投影同时包含 `.d.ts`、`.d.ts.map` 和 `.js`。`.d.ts` 只 | 组件 | Cordis 服务 | 职责 | |---|---|---| -| `@deepseek-ai/dsh-type-meta` | 只声明 `ctx.typert` 的最小协议 | decorator、binding、descriptor、lookup/Context 和 Remote map;不依赖 compiler、Zod、Connection 或 Browser | +| `@deepseek-ai/dsh-type-meta` | 只声明 `ctx.typert` 的最小协议 | `GatewayService`、decorator、binding 回退、descriptor、lookup/Context 和 Remote map;不依赖 compiler、Zod、Connection 或 Browser | | TypeRT registry | `ctx.typert` | 分开保存当前环境 reflection、导入的 Remote contribution、lookup provider 和 Context provider | | TypeRT generator/loader | 无新增业务服务 | 从 Host/Client Program 生成三类 `lib` 产物,并把当前环境产物注册到 `ctx.typert` | | Host API Gateway 的 Host face | `ctx.typertGateway` | 关联 Host definition 与活 Service,解码参数、解析 receiver、调用方法和编码结果 | @@ -43,8 +43,10 @@ Host Gateway 不依赖 `ctx.agents`、`ctx.sessions`、`ctx.goals` 或 `ctx.http 普通直接调用使用 `@Remote`。迁移到现存 Service 或 Registry 时不重命名、不改变存量方法;类末尾新增 `remoteExport*` 出口,并由 decorator 参数声明短 API 名。方法需要哪个业务对象,就在顶层参数位置显式声明该对象: ```text -export class GoalService extends Service { - readonly typertGateway = bindTypeRTGateway(this, 'goals') +export class GoalService extends GatewayService { + constructor(ctx: Context) { + super(ctx, 'goals') + } create(agent: Agent, request: CreateGoalRequest): CreateGoalResult { // Existing business method remains unchanged. @@ -57,13 +59,15 @@ export class GoalService extends Service { } ``` -`goals` 是明确的 Cordis service key,并默认作为 wire namespace。只有协议 namespace 确实需要与 service key 不同时,才通过 `bindTypeRTGateway()` 的选项覆盖。 +`goals` 是传给 `super()` 的明确 Cordis service key,并默认作为 wire namespace。只有协议 namespace 确实需要与 service key 不同时,才通过第三个参数传入 `namespace` 选项。 需要在某类隔离 Context 中查找 Service receiver 时使用 `@RemoteContext()`。Context identity 不进入业务方法参数: ```text -export class ScopedGoalService extends Service { - readonly typertGateway = bindTypeRTGateway(this, 'goals') +export class ScopedGoalService extends GatewayService { + constructor(ctx: Context) { + super(ctx, 'goals') + } @RemoteContext('agent', 'create') remoteExportCreate(request: CreateGoalRequest): Promise { @@ -74,17 +78,17 @@ export class ScopedGoalService extends Service { 同一个 endpoint 只能选择一种调用模式。需要显式 `Agent` 参数的流程使用 `@Remote`;需要切换到 Agent Context 再解析 scoped receiver 的流程使用 `@RemoteContext('agent')`,两者不会由 TypeRT 根据方法体或参数缺失自动猜测。 -业务包只依赖轻量的 `@deepseek-ai/dsh-type-meta`。它提供 decorator、`bindTypeRTGateway()`、lookup、Remote Context 和 descriptor 的声明协议,不依赖 TypeScript compiler、Zod、HTTP 或 Client runtime。 +业务包只依赖轻量的 `@deepseek-ai/dsh-type-meta`。它提供 `GatewayService`,以及 decorator、binding 回退、lookup、Remote Context 和 descriptor 的声明协议,不依赖 TypeScript compiler、Zod、HTTP 或 Client runtime。 支持协作式取消的方法会把 `signal: AbortSignal` 声明为最后一个 Host 参数。这个保留参数不是业务值、lookup 或 JSON 字段。生成的消费方方法将其暴露为最后一个可选参数,因此普通调用保持不变,而拥有取消控制权的调用方可以传入 signal。 ## Decorator 与显式 Gateway facet -Decorator 只表达“该方法参与 Remote 契约”,不负责运行时类型反射,也不向 Service constructor 注入隐藏 symbol。`@Remote('create')` 和 `@RemoteContext('agent', 'create')` 的参数是外部方法名,实际成员名保持 `remoteExportCreate`;未给别名时才使用成员名作为外部方法名。`typertGateway` 是 Service 加入 Gateway 的唯一显式标志,使业务类和运行时实例都能直接看出这项能力。 +Decorator 只表达“该方法参与 Remote 契约”,不负责运行时类型反射,也不向 Service constructor 注入隐藏 symbol。`@Remote('create')` 和 `@RemoteContext('agent', 'create')` 的参数是外部方法名,实际成员名保持 `remoteExportCreate`;未给别名时才使用成员名作为外部方法名。继承 `GatewayService` 是 Service 加入 Gateway 的常规显式声明;其 public readonly `typertGateway` 字段使运行时实例上的绑定保持可见。 SRC 运行时允许 decorator 在 `dsh-type-meta` 内部的 `WeakMap` 记录 prototype、方法名和调用模式。它不向 Service 实例、prototype、constructor 或方法函数写入自定义属性。 -LIB 的严格方法发现、类型解析和 descriptor 生成由 TypeRT compiler 完成。生成过程不改写业务源码,也不向 `bindTypeRTGateway()` 偷注生成参数。 +LIB 的严格方法发现、类型解析和 descriptor 生成由 TypeRT compiler 完成。它接受 `GatewayService` 直接 `super()` 调用中的字面量 service key,或显式 binding 回退;生成过程不改写业务源码,也不注入隐藏注册元数据。 ## Lookup 与 Remote Context 注册 diff --git a/packages/goal/goal/src/index.ts b/packages/goal/goal/src/index.ts index 0997aad0dc..312e3a70d9 100644 --- a/packages/goal/goal/src/index.ts +++ b/packages/goal/goal/src/index.ts @@ -5,14 +5,14 @@ */ import { randomUUID } from 'node:crypto' -import { Context, Service } from 'cordis' +import { Context } from 'cordis' import z from 'schemastery' import { z as zod } from 'zod' import type { ZodType } from 'zod' import { agentEvents } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' -import { Remote, bindTypeRTGateway } from '@deepseek-ai/dsh-type-meta' +import { GatewayService, Remote } from '@deepseek-ai/dsh-type-meta' // Type-only: resolves ctx.sessionProjections for the optional unit child. import type {} from '@deepseek-ai/dsh-session-projection' import { @@ -180,7 +180,7 @@ function resolveBlockReason(reason: unknown): GoalBlockReason { } /** Goal service (`ctx.goals`) backed exclusively by the owning session log. */ -export class GoalService extends Service { +export class GoalService extends GatewayService { static inject = ['agents'] static Config: z = z.object({ @@ -190,9 +190,6 @@ export class GoalService extends Service { private readonly resolved: ResolvedConfig private readonly caches = new WeakMap() - /** Explicit participation in the TypeRT Gateway under the Cordis service key. */ - readonly typertGateway = bindTypeRTGateway(this, 'goals') - constructor(ctx: Context, config: Config = {}) { super(ctx, 'goals') this.resolved = { diff --git a/packages/host/api-gateway/README.i18n.yaml b/packages/host/api-gateway/README.i18n.yaml index a1c22433f3..273a493c24 100644 --- a/packages/host/api-gateway/README.i18n.yaml +++ b/packages/host/api-gateway/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/api-gateway/README.md -README.md: 9cb6e7e1c0a23789ab4ab2c999b5a6c2d4cd32f9 -README.zh.md: 609580ceb77649ba8df6103093a72092c9ccc8a1 +README.md: 43e8f464e2a2790d05628a7fba61143a6a5ab26a +README.zh.md: 761045d0c1afc17dfc230f9f45849c46e4e579fc diff --git a/packages/host/api-gateway/README.md b/packages/host/api-gateway/README.md index 9cb6e7e1c0..43e8f464e2 100644 --- a/packages/host/api-gateway/README.md +++ b/packages/host/api-gateway/README.md @@ -6,7 +6,7 @@ Two-sided Remote control for Host and Client Cordis environments. The Host entry ## Host service: `TypertGatewayService` (ctx key: `typertGateway`) -`ctx.typertGateway.invoke()` resolves the current descriptor and Cordis Service for each call, validates exact named arguments, resolves registered object or Context identities, invokes the public business method, and validates its result. Business Services declare participation with `bindTypeRTGateway()` and `@Remote` or `@RemoteContext` from [`dsh-type-meta`](../../typert/type-meta/README.md). +`ctx.typertGateway.invoke()` resolves the current descriptor and Cordis Service for each call, validates exact named arguments, resolves registered object or Context identities, invokes the public business method, and validates its result. Business Services extend `GatewayService` and mark methods with `@Remote` or `@RemoteContext` from [`dsh-type-meta`](../../typert/type-meta/README.md); `bindTypeRTGateway()` remains available when another base class owns inheritance. Strict mode reads generated invocation descriptors from `ctx.typert.local`. Lookup parameters use registered `ctx.typert.lookups` providers, while `@RemoteContext` resolves its receiver through a registered Host Context provider. SRC mode is a development fallback for endpoints that have never had a strict definition; it parses simple parameter names and accepts only JSON-safe values for non-lookup parameters. Withdrawing an observed strict definition fails instead of weakening validation. diff --git a/packages/host/api-gateway/README.zh.md b/packages/host/api-gateway/README.zh.md index 609580ceb7..761045d0c1 100644 --- a/packages/host/api-gateway/README.zh.md +++ b/packages/host/api-gateway/README.zh.md @@ -6,7 +6,7 @@ ## Host 服务:`TypertGatewayService`(ctx key:`typertGateway`) -每次调用时,`ctx.typertGateway.invoke()` 都会解析当前的描述符和 Cordis 服务,校验具名参数是否完全匹配,解析已注册的对象或 Context 身份标识,调用公开的业务方法,并校验其结果。业务服务调用 `bindTypeRTGateway()` 并使用 [`dsh-type-meta`](../../typert/type-meta/README.md) 提供的 `@Remote` 或 `@RemoteContext` 装饰器,以显式声明接入。 +每次调用时,`ctx.typertGateway.invoke()` 都会解析当前的描述符和 Cordis 服务,校验具名参数是否完全匹配,解析已注册的对象或 Context 身份标识,调用公开的业务方法,并校验其结果。业务服务继承 [`dsh-type-meta`](../../typert/type-meta/README.md) 的 `GatewayService`,并用 `@Remote` 或 `@RemoteContext` 标记方法;已有其他基类时仍可改用 `bindTypeRTGateway()`。 严格模式从 `ctx.typert.local` 读取生成的调用描述符。查找参数使用已向 `ctx.typert.lookups` 注册的提供方,`@RemoteContext` 则通过已注册的 Host Context 提供方解析其接收者。SRC 模式是开发阶段的回退路径,适用于从未具备严格定义的端点;它解析简单参数名,并且只允许非查找参数使用可安全表示为 JSON 的值。已观测到的严格定义一旦撤回,系统会直接报错,而不会降低校验强度。 diff --git a/packages/typert/generator/src/analyzer.ts b/packages/typert/generator/src/analyzer.ts index 87a23f17f5..ecc7d8aa6b 100644 --- a/packages/typert/generator/src/analyzer.ts +++ b/packages/typert/generator/src/analyzer.ts @@ -142,7 +142,7 @@ interface StaticContextDeclaration { interface GatewayBinding { readonly service: string readonly namespace: string - readonly site: ts.PropertyDeclaration + readonly site: ts.Node } type ReferenceSite = ts.TypeReferenceNode | ts.ExpressionWithTypeArguments | ts.ImportTypeNode @@ -927,7 +927,10 @@ class FaceAnalyzer { if (first === undefined) continue const binding = this.gatewayBinding(statement) if (binding === undefined) { - this.fail(first.method, 'Remote methods require readonly typertGateway = bindTypeRTGateway(this, serviceKey)') + this.fail( + first.method, + 'Remote methods require GatewayService or readonly typertGateway = bindTypeRTGateway(this, serviceKey)', + ) } for (const { method, invocation } of marked) { result.push(this.invocationModel(registration, binding, method, invocation)) @@ -1089,6 +1092,15 @@ class FaceAnalyzer { } private gatewayBinding(declaration: ts.ClassDeclaration): GatewayBinding | undefined { + const field = this.gatewayFieldBinding(declaration) + const base = this.gatewayServiceBinding(declaration) + if (field !== undefined && base !== undefined) { + this.fail(field.site, 'GatewayService subclasses must not declare a second typertGateway binding') + } + return field ?? base + } + + private gatewayFieldBinding(declaration: ts.ClassDeclaration): GatewayBinding | undefined { const candidates = declaration.members.filter((member): member is ts.PropertyDeclaration => ts.isPropertyDeclaration(member) && memberName(member.name) === 'typertGateway') const [property, duplicate] = candidates @@ -1111,10 +1123,38 @@ class FaceAnalyzer { if (call.arguments[0]?.kind !== ts.SyntaxKind.ThisKeyword) { this.fail(call.arguments[0] ?? call, 'bindTypeRTGateway() first argument must be this') } + return this.gatewayBindingArguments(call, property) + } + + private gatewayServiceBinding(declaration: ts.ClassDeclaration): GatewayBinding | undefined { + const heritage = (declaration.heritageClauses ?? []) + .filter(clause => clause.token === ts.SyntaxKind.ExtendsKeyword) + .flatMap(clause => [...clause.types]) + .find(type => this.isTypeMetaSymbol(type.expression, 'GatewayService')) + if (heritage === undefined) return undefined + + const constructor = declaration.members.find(ts.isConstructorDeclaration) + if (constructor?.body === undefined) { + this.fail(heritage, 'GatewayService subclasses must declare a constructor with super(ctx, serviceKey)') + } + const call = constructor.body.statements.flatMap((statement) => { + if (!ts.isExpressionStatement(statement) || !ts.isCallExpression(statement.expression)) return [] + return statement.expression.expression.kind === ts.SyntaxKind.SuperKeyword ? [statement.expression] : [] + })[0] + if (call === undefined) { + this.fail(constructor, 'GatewayService constructor must call super(ctx, serviceKey) directly') + } + if (call.arguments.length < 2 || call.arguments.length > 3) { + this.fail(call, 'GatewayService super() requires context, service key, and an optional options object') + } + return this.gatewayBindingArguments(call, heritage) + } + + private gatewayBindingArguments(call: ts.CallExpression, site: ts.Node): GatewayBinding { const serviceArgument = call.arguments[1] - if (serviceArgument === undefined) this.fail(call, 'bindTypeRTGateway() service key must be a string literal') + if (serviceArgument === undefined) this.fail(call, 'Gateway service key must be a string literal') const service = stringLiteralValue(serviceArgument) - if (service === undefined) this.fail(serviceArgument, 'bindTypeRTGateway() service key must be a string literal') + if (service === undefined) this.fail(serviceArgument, 'Gateway service key must be a string literal') let namespace = service const options = call.arguments[2] if (options !== undefined) { @@ -1133,7 +1173,7 @@ class FaceAnalyzer { } if (!isRemoteSegment(service)) this.fail(serviceArgument, 'Gateway service key must be nonempty and must not contain "/"') if (!isRemoteSegment(namespace)) this.fail(options ?? call, 'Gateway namespace must be nonempty and must not contain "/"') - return { service, namespace, site: property } + return { service, namespace, site } } private remoteMarker( diff --git a/packages/typert/generator/tests/fixtures/remote-model/packages/remote/src/index.ts b/packages/typert/generator/tests/fixtures/remote-model/packages/remote/src/index.ts index 115b3b87a6..4aa51ec433 100644 --- a/packages/typert/generator/tests/fixtures/remote-model/packages/remote/src/index.ts +++ b/packages/typert/generator/tests/fixtures/remote-model/packages/remote/src/index.ts @@ -1,4 +1,4 @@ -import { Remote, RemoteContext, bindTypeRTGateway } from '@deepseek-ai/dsh-type-meta' +import { GatewayService, Remote, RemoteContext } from '@deepseek-ai/dsh-type-meta' import type { Agent } from '@fixture/domain' import type { CreateGoalRequest, @@ -8,8 +8,10 @@ import type { } from './types.ts' /** Remote-only business Service with no Cordis declaration merge. */ -export class GoalService { - readonly typertGateway = bindTypeRTGateway(this, 'goals') +export class GoalService extends GatewayService { + constructor() { + super(undefined, 'goals') + } @Remote async create(agent: Agent, request: CreateGoalRequest, signal: AbortSignal): Promise { diff --git a/packages/typert/generator/tests/fixtures/remote-model/type-meta.d.ts b/packages/typert/generator/tests/fixtures/remote-model/type-meta.d.ts index f8e84bbe90..91daea98c2 100644 --- a/packages/typert/generator/tests/fixtures/remote-model/type-meta.d.ts +++ b/packages/typert/generator/tests/fixtures/remote-model/type-meta.d.ts @@ -26,6 +26,19 @@ declare module '@deepseek-ai/dsh-type-meta' { readonly descriptors: readonly unknown[] } + export abstract class GatewayService { + readonly typertGateway: { + readonly service: GatewayService + readonly serviceKey: string + readonly namespace: string + } + protected constructor( + ctx: unknown, + serviceKey: string, + options?: { readonly namespace?: string }, + ) + } + export function bindTypeRTGateway( service: Service, serviceKey: string, diff --git a/packages/typert/generator/tests/remote-model.spec.ts b/packages/typert/generator/tests/remote-model.spec.ts index d5838f39ce..268645ca73 100644 --- a/packages/typert/generator/tests/remote-model.spec.ts +++ b/packages/typert/generator/tests/remote-model.spec.ts @@ -219,8 +219,56 @@ export type GenericResult = { it.each([ { name: 'missing binding', - edit: (source: string) => source.replace(" readonly typertGateway = bindTypeRTGateway(this, 'goals')\n\n", ''), - message: 'Remote methods require readonly typertGateway', + edit: (source: string) => source.replace( + "export class GoalService extends GatewayService {\n constructor() {\n super(undefined, 'goals')\n }", + 'export class GoalService {', + ), + message: 'Remote methods require GatewayService', + }, + { + name: 'dynamic GatewayService key', + edit: (source: string) => source.replace( + " constructor() {\n super(undefined, 'goals')\n }", + ' constructor(serviceKey: string) {\n super(undefined, serviceKey)\n }', + ), + message: 'Gateway service key must be a string literal', + }, + { + name: 'GatewayService without a constructor', + edit: (source: string) => source.replace( + " constructor() {\n super(undefined, 'goals')\n }\n\n", + '', + ), + message: 'GatewayService subclasses must declare a constructor', + }, + { + name: 'GatewayService without a direct super call', + edit: (source: string) => source.replace( + " super(undefined, 'goals')", + ' void undefined', + ), + message: 'GatewayService constructor must call super', + }, + { + name: 'GatewayService super call without a service key', + edit: (source: string) => source.replace( + " super(undefined, 'goals')", + ' super(undefined)', + ), + message: 'GatewayService super\\(\\) requires context, service key', + }, + { + name: 'duplicate GatewayService field binding', + edit: (source: string) => source + .replace( + 'import { GatewayService, Remote, RemoteContext }', + 'import { GatewayService, Remote, RemoteContext, bindTypeRTGateway }', + ) + .replace( + 'export class GoalService extends GatewayService {', + "export class GoalService extends GatewayService {\n readonly typertGateway = bindTypeRTGateway(this, 'goals')", + ), + message: 'GatewayService subclasses must not declare a second typertGateway binding', }, { name: 'private method', @@ -351,8 +399,10 @@ export type GenericResult = { it('rejects duplicate endpoints across Remote services', () => { const root = copyFixture() editFile(root, 'packages/remote/src/index.ts', source => `${source} -export class DuplicateGoalService { - readonly typertGateway = bindTypeRTGateway(this, 'duplicate', { namespace: 'goals' }) +export class DuplicateGoalService extends GatewayService { + constructor() { + super(undefined, 'duplicate', { namespace: 'goals' }) + } @Remote create(request: CreateGoalRequest): CreateGoalResult { @@ -521,7 +571,7 @@ ctx.api.goals.create('agent-1', { title: 'must not compile' }) if (config.error !== undefined) throw new Error(formatDiagnostics([config.error])) const parsed = ts.parseJsonConfigFileContent(config.config, ts.sys, consumerRoot, undefined, configPath) const diagnostics = ts.getPreEmitDiagnostics(ts.createProgram(parsed.fileNames, parsed.options)) - expect(diagnostics).toHaveLength(1) + expect(diagnostics, formatDiagnostics(diagnostics)).toHaveLength(1) expect(diagnostics[0]?.code).toBe(2339) expect(ts.flattenDiagnosticMessageText(diagnostics[0]?.messageText ?? '', '\n')).toContain("Property 'goals' does not exist") } diff --git a/packages/typert/type-meta/README.i18n.yaml b/packages/typert/type-meta/README.i18n.yaml index 9751c4c088..a3e0643ace 100644 --- a/packages/typert/type-meta/README.i18n.yaml +++ b/packages/typert/type-meta/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/typert/type-meta/README.md -README.md: 95716446c01c7fd510cdf55a82509b5b8af6f3ae -README.zh.md: 0d30b3122265d9bb3caa289345f843fe67377be3 +README.md: 245df305efcf711486b2d3f32e40a8b415f2682e +README.zh.md: 592aa5d027a52a7a277a90ba5d51f19101f055f6 diff --git a/packages/typert/type-meta/README.md b/packages/typert/type-meta/README.md index 95716446c0..245df305ef 100644 --- a/packages/typert/type-meta/README.md +++ b/packages/typert/type-meta/README.md @@ -2,18 +2,19 @@ English | [中文](README.zh.md) -Compiler-independent declarations shared by business packages, generated TypeRT artifacts, the Host Gateway, and Client API. This package owns Remote decorators, the explicit Service binding, merge-extensible protocol maps, invocation descriptors, codecs, and provider contracts; it does not run TypeScript analysis or provide a Cordis service. +Compiler-independent declarations shared by business packages, generated TypeRT artifacts, the Host Gateway, and Client API. This package owns the Remote Service base, decorators, explicit binding fallback, merge-extensible protocol maps, invocation descriptors, codecs, and provider contracts; it does not run TypeScript analysis or register a concrete Cordis service. ## Remote declarations - `@Remote` marks a public instance method for direct invocation on its registered Cordis Service. - `@RemoteContext(key)` marks a method whose receiver is selected from a merge-declared scoped Context kind. -- `bindTypeRTGateway(this, serviceKey, options?)` creates the visible, frozen binding between a Service instance, its exact Cordis key, and its wire namespace. +- `GatewayService` binds the Cordis key passed to `super(ctx, serviceKey, options?)` to the same default wire namespace. +- `bindTypeRTGateway(this, serviceKey, options?)` provides the same visible, frozen binding for a Service that cannot inherit from `GatewayService`. - `remoteMethods(service)` returns a detached declaration-order snapshot used by the Gateway's SRC fallback. A Host method opts into cooperative cancellation by declaring `signal: AbortSignal` as its final parameter. `InvocationDescriptor.cancellation` records that reserved injection point; the signal never becomes a JSON parameter or lookup field. SRC recognizes the final parameter name, while strict generation also verifies the global `AbortSignal` type. -Decorator initializers retain markers in a module-private `WeakMap` keyed by the Service prototype. They do not add constructor symbols, prototype properties, parameter metadata, or runtime reflection fields. The Service opts in explicitly through its `typertGateway` binding field. +Decorator initializers retain markers in a module-private `WeakMap` keyed by the Service prototype. They do not add constructor symbols, prototype properties, parameter metadata, or runtime reflection fields. A `GatewayService` exposes the same public readonly `typertGateway` binding that the explicit helper returns. ## TypeRT protocol diff --git a/packages/typert/type-meta/README.zh.md b/packages/typert/type-meta/README.zh.md index 0d30b31222..592aa5d027 100644 --- a/packages/typert/type-meta/README.zh.md +++ b/packages/typert/type-meta/README.zh.md @@ -2,18 +2,19 @@ [English](README.md) | 中文 -该包提供不依赖编译器的声明,由业务包、生成的 TypeRT 产物、Host Gateway 和 Client API 共享。它负责 Remote 装饰器、显式服务绑定、可通过声明合并扩展的协议映射、调用描述符、编解码器和提供方契约;它不执行 TypeScript 分析,也不提供 Cordis 服务。 +该包提供不依赖编译器的声明,由业务包、生成的 TypeRT 产物、Host Gateway 和 Client API 共享。它负责 Remote Service 基类、装饰器、显式 binding 回退、可通过声明合并扩展的协议映射、调用描述符、编解码器和提供方契约;它不执行 TypeScript 分析,也不注册具体 Cordis 服务。 ## Remote 声明 - `@Remote` 将公开实例方法标记为可在其注册的 Cordis 服务上直接调用。 - `@RemoteContext(key)` 标记接收者选自合并声明的作用域 Context 类型的方法。 -- `bindTypeRTGateway(this, serviceKey, options?)` 在服务实例、其准确的 Cordis key 与协议命名空间之间创建可见且冻结的绑定。 +- `GatewayService` 将 `super(ctx, serviceKey, options?)` 接收的 Cordis key 同时绑定为默认 wire namespace。 +- `bindTypeRTGateway(this, serviceKey, options?)` 为无法继承 `GatewayService` 的 Service 提供同样可见且冻结的绑定。 - `remoteMethods(service)` 返回按声明顺序排列、与内部状态分离的快照,供 Gateway 的 SRC 回退路径使用。 Host 方法通过将 `signal: AbortSignal` 声明为最后一个参数来启用协作式取消。`InvocationDescriptor.cancellation` 记录这个保留的注入点;signal 绝不会成为 JSON 参数或 lookup 字段。SRC 识别末位参数名,严格生成还会校验它是否具有全局 `AbortSignal` 类型。 -装饰器初始化器将标记保存在以服务 prototype 为键的模块私有 `WeakMap` 中。它们不会在构造函数上添加 symbol,也不会添加 prototype 属性、参数元数据或运行时反射字段。服务通过自身的 `typertGateway` 绑定字段显式接入。 +装饰器初始化器将标记保存在以服务 prototype 为键的模块私有 `WeakMap` 中。它们不会在构造函数上添加 symbol,也不会添加 prototype 属性、参数元数据或运行时反射字段。`GatewayService` 会暴露与显式 helper 相同的 public readonly `typertGateway` 绑定。 ## TypeRT 协议 diff --git a/packages/typert/type-meta/src/index.ts b/packages/typert/type-meta/src/index.ts index 92438ee0fa..4d4457b5be 100644 --- a/packages/typert/type-meta/src/index.ts +++ b/packages/typert/type-meta/src/index.ts @@ -4,6 +4,7 @@ * @module @deepseek-ai/dsh-type-meta */ +import { Service, type Context } from 'cordis' import type { TypeRTContextMap } from './types.ts' export type { @@ -104,6 +105,23 @@ export function bindTypeRTGateway( return Object.freeze({ service, serviceKey, namespace }) } +/** Cordis Service base that exposes its registered name through TypeRT Gateway. */ +export abstract class GatewayService extends Service { + /** Visible binding consumed by the Gateway's source-mode discovery. */ + readonly typertGateway: TypeRTGatewayBinding + + /** + * Register the Service and bind the same key to TypeRT Gateway. + * @param ctx - owning Cordis Context. + * @param serviceKey - exact Cordis service key and default wire namespace. + * @param options - optional distinct wire namespace. + */ + protected constructor(ctx: Context, serviceKey: string, options: TypeRTGatewayBindingOptions = {}) { + super(ctx, serviceKey) + this.typertGateway = bindTypeRTGateway(this, this.name, options) + } +} + /** * Mark one public instance method as a direct Remote invocation. * @param _method - decorated method; retained only by the class itself. diff --git a/packages/typert/type-meta/tests/fixtures/source-launch.ts b/packages/typert/type-meta/tests/fixtures/source-launch.ts index 68f886dff1..b13a80796d 100644 --- a/packages/typert/type-meta/tests/fixtures/source-launch.ts +++ b/packages/typert/type-meta/tests/fixtures/source-launch.ts @@ -1,12 +1,15 @@ +import { Context } from 'cordis' import { - bindTypeRTGateway, + GatewayService, Remote, RemoteContext, remoteMethods, } from '@deepseek-ai/dsh-type-meta' -class Goals { - readonly typertGateway = bindTypeRTGateway(this, 'goals') +class Goals extends GatewayService { + constructor(ctx: Context) { + super(ctx, 'goals') + } @Remote create(value: string): string { @@ -19,7 +22,7 @@ class Goals { } } -const methods = remoteMethods(new Goals()) +const methods = remoteMethods(new Goals(new Context())) const actual = JSON.stringify(methods) const expected = JSON.stringify([ { method: 'create', invocation: { kind: 'direct' } }, diff --git a/packages/typert/type-meta/tests/type-meta.spec.ts b/packages/typert/type-meta/tests/type-meta.spec.ts index f25c367914..8a2a4372ce 100644 --- a/packages/typert/type-meta/tests/type-meta.spec.ts +++ b/packages/typert/type-meta/tests/type-meta.spec.ts @@ -1,8 +1,10 @@ import { execFileSync } from 'node:child_process' import { fileURLToPath } from 'node:url' +import { Context } from 'cordis' import { describe, expect, it } from 'vitest' import { bindTypeRTGateway, + GatewayService, Remote, RemoteContext, remoteMethods, @@ -16,9 +18,11 @@ declare module '@deepseek-ai/dsh-type-meta' { } describe('type-meta Remote declarations', () => { - it('executes standard decorator syntax through the Vitest source transform', () => { - class Goals { - readonly typertGateway = bindTypeRTGateway(this, 'goals') + it('binds a GatewayService name and executes decorators through the Vitest source transform', async () => { + class Goals extends GatewayService { + constructor(ctx: Context) { + super(ctx, 'goals') + } @Remote create(value: string): string { @@ -31,11 +35,26 @@ describe('type-meta Remote declarations', () => { } } - const goals = new Goals() + class NamespacedGoals extends GatewayService { + constructor(ctx: Context) { + super(ctx, 'internalGoals', { namespace: 'goals' }) + } + } + + const ctx = new Context() + const goals = new Goals(ctx) + const namespaced = new NamespacedGoals(ctx) + expect(goals.typertGateway).toEqual({ service: goals, serviceKey: 'goals', namespace: 'goals' }) + expect(namespaced.typertGateway).toEqual({ + service: namespaced, + serviceKey: 'internalGoals', + namespace: 'goals', + }) expect(remoteMethods(goals)).toEqual([ { method: 'create', invocation: { kind: 'direct' } }, { method: 'scoped', invocation: { kind: 'context', context: 'metaFixture' } }, ]) + await ctx.fiber.dispose() }) it('executes standard decorator syntax through the TSX source launcher', () => { From ede278d0c79ff07a53df018d782ed4753356e308 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 7 Aug 2026 01:16:07 +0800 Subject: [PATCH 28/63] fix(connection): mint RPC ids on insecure origins --- packages/client/connection/src/client/fixture.ts | 3 ++- .../client/connection/src/client/random-uuid.ts | 14 ++++++++++++++ packages/client/connection/src/client/rpc.ts | 3 ++- .../client/connection/tests/client-apply.spec.ts | 9 ++++++++- 4 files changed, 26 insertions(+), 3 deletions(-) create mode 100644 packages/client/connection/src/client/random-uuid.ts diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 31747bb311..e13c0a19f6 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -35,10 +35,11 @@ import type { } from './api.ts' import type { RequestPayload, ResponseValue, RpcMethodMap } from '@deepseek-ai/dsh-host-apiproxy/api' import { AbstractApiClient, RpcId, SESSION_SEARCH_RESULT_LIMIT } from './api.ts' +import { randomUuid } from './random-uuid.ts' /** The fake carrier mints like a real one (business code never mints). */ function rpcRequest

(payload: P): RpcRequest

{ - return { rpcId: RpcId(crypto.randomUUID()), payload } + return { rpcId: RpcId(randomUuid()), payload } } function text(t: string): ContentBlock[] { diff --git a/packages/client/connection/src/client/random-uuid.ts b/packages/client/connection/src/client/random-uuid.ts new file mode 100644 index 0000000000..dc3106bd86 --- /dev/null +++ b/packages/client/connection/src/client/random-uuid.ts @@ -0,0 +1,14 @@ +/** Browser-safe UUID generation for client-side wire correlation. */ + +/** + * Generate an RFC 4122 version 4 UUID without requiring a secure context. + * @returns a UUID backed by `crypto.getRandomValues()`, which browsers expose on insecure origins. + */ +export function randomUuid(): string { + const bytes = globalThis.crypto.getRandomValues(new Uint8Array(16)) + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength) + view.setUint8(6, (view.getUint8(6) & 0x0f) | 0x40) + view.setUint8(8, (view.getUint8(8) & 0x3f) | 0x80) + const hex = Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('') + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}` +} diff --git a/packages/client/connection/src/client/rpc.ts b/packages/client/connection/src/client/rpc.ts index 0c12149d7b..7883f2a9d3 100644 --- a/packages/client/connection/src/client/rpc.ts +++ b/packages/client/connection/src/client/rpc.ts @@ -6,6 +6,7 @@ import { type ClientRequest, } from '@deepseek-ai/dsh-host-apiproxy/api' import type { ClientConnectionRpc } from '../rpc.ts' +import { randomUuid } from './random-uuid.ts' const INTERNAL_BASE = 'http://dsh.internal' const CHANNEL_PATTERN = /^\/[A-Za-z0-9._~-]+$/ @@ -19,7 +20,7 @@ export function createWebConnectionRpc(): ClientConnectionRpc { return { async call(channel, endpoint, payload, signal) { assertTarget(channel, endpoint) - const rpcId = RpcId(crypto.randomUUID()) + const rpcId = RpcId(randomUuid()) const message: ClientRequest = { type: 'client-request', rpcId, diff --git a/packages/client/connection/tests/client-apply.spec.ts b/packages/client/connection/tests/client-apply.spec.ts index 6bf9c26b46..41e8e9b0e2 100644 --- a/packages/client/connection/tests/client-apply.spec.ts +++ b/packages/client/connection/tests/client-apply.spec.ts @@ -204,8 +204,13 @@ describe('connection client apply', () => { expect(sockets[0]?.readyState).toBe(FakeWebSocket.CLOSED) }) - it('carries RPC calls over the shared API channel with rpcId echo validation', async () => { + it('carries RPC calls without requiring secure-context randomUUID', async () => { ;(globalThis as Win).location = { hostname: 'localhost', search: '' } + vi.stubGlobal('crypto', { + getRandomValues(bytes: Uint8Array) { + return bytes.fill(0) + }, + }) const handle = await mount() const original = globalThis.fetch const seen: { url: string; body: unknown }[] = [] @@ -225,11 +230,13 @@ describe('connection client apply', () => { .resolves.toEqual({ ok: true, value: { ref: 'goal-1' } }) } finally { globalThis.fetch = original + vi.unstubAllGlobals() } expect(seen).toHaveLength(1) expect(seen[0]?.url).toBe('http://dsh.internal/api/goals/create') expect(seen[0]?.body).toMatchObject({ type: 'client-request', + rpcId: '00000000-0000-4000-8000-000000000000', method: 'goals/create', payload: { args: { agentId: 'agent-1' } }, }) From 2f619b1b88ebd0ffc054ff24852c8775d98946a1 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 7 Aug 2026 01:59:34 +0800 Subject: [PATCH 29/63] docs: document TypeRT API-Gateway --- docs/api-gateway.i18n.yaml | 6 ++ docs/api-gateway.md | 157 ++++++++++++++++++++++++++++++++++++ docs/api-gateway.zh.md | 157 ++++++++++++++++++++++++++++++++++++ docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 1 + docs/architecture.zh.md | 1 + docs/development.i18n.yaml | 4 +- docs/development.md | 2 + docs/development.zh.md | 2 + 9 files changed, 330 insertions(+), 4 deletions(-) create mode 100644 docs/api-gateway.i18n.yaml create mode 100644 docs/api-gateway.md create mode 100644 docs/api-gateway.zh.md diff --git a/docs/api-gateway.i18n.yaml b/docs/api-gateway.i18n.yaml new file mode 100644 index 0000000000..87abb10c88 --- /dev/null +++ b/docs/api-gateway.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/api-gateway.md +api-gateway.md: 76af93880d278a17dc46370fd5065fdcdadb9fb6 +api-gateway.zh.md: d447cea6b64bf88084f86a210a5f654bd9445d6c diff --git a/docs/api-gateway.md b/docs/api-gateway.md new file mode 100644 index 0000000000..76af93880d --- /dev/null +++ b/docs/api-gateway.md @@ -0,0 +1,157 @@ +# API Gateway + +English | [中文](api-gateway.zh.md) + +This is the current-state reference for the TypeRT API Gateway. It describes how business services declare unary Remote methods, how the build generates Host and Client contracts, and how calls reuse the Connection RPC and `/api` route. Session events, incremental data, and other streaming protocols are outside this document's scope; they may use the same Connection but do not use Remote method descriptors. + +## Programming model + +Business services use `@Remote` or `@RemoteContext` to select the methods exposed to the Client. Unmarked methods do not enter the generated Client types or runtime contributions and cannot be called through `ctx.api`. + +`@Remote` denotes calling a Cordis service registered on the root Host Context. Complex Host objects cannot cross the wire directly; the business package must declare their association with a wire identity through `TypeRTLookupMap` and register a resolution provider with `ctx.typert.lookups` at runtime. For example, an `Agent` parameter named `agent` in the Host signature produces an `agentId` wire field, and the Gateway resolves that id to the current live object before invoking the business method. + +`@RemoteContext(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. + +Services normally extend `GatewayService` 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 typertGateway = bindTypeRTGateway(this, serviceKey)`; both forms leave an inspectable public binding and do not depend on the compiler injecting a symbol into the constructor. + +```ts +import type { Agent } from '@deepseek-ai/dsh-agent' +import { GatewayService, Remote, RemoteContext } from '@deepseek-ai/dsh-type-meta' +import type { Context } from 'cordis' + +export interface CreateGoalRequest { + objective: string +} + +export interface CreateGoalResult { + accepted: boolean +} + +export class GoalService extends GatewayService { + constructor(ctx: Context) { + super(ctx, 'goals') + } + + @Remote('create') + createForClient( + agent: Agent, + request: CreateGoalRequest, + signal: AbortSignal, + ): CreateGoalResult { + signal.throwIfAborted() + return this.create(agent, request) + } + + @RemoteContext('agent', 'current') + currentForClient(): CreateGoalResult { + return { accepted: true } + } + + private create(_agent: Agent, request: CreateGoalRequest): CreateGoalResult { + return { accepted: request.objective.length > 0 } + } +} +``` + +Remote methods may return a value synchronously or return a Promise. For cooperative cancellation, the final parameter in the Host signature must be `signal: AbortSignal` using the global type; it is recorded in the descriptor instead of entering `args`, while the generated Client method accepts an optional final `AbortSignal`. + +The Client uses concrete functions on ordinary objects, not a JavaScript Proxy. Direct Remotes appear under `ctx.api.`; when an `@Remote` method has exactly one lookup parameter and a same-named `TypeRTContextMap` uses the same wire identity, the generator also projects the method without that identity parameter onto the corresponding scoped Context. `@RemoteContext` generates only the scoped invocation interface. + +```ts +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { AgentContext } from '@deepseek-ai/dsh-client-runtime/client' +import type { Context } from 'cordis' +import type {} from '@deepseek-ai/dsh-client-remotes/client' + +declare const ctx: Context +declare const agentCtx: AgentContext +declare const agentId: SessionId + +await ctx.api.goals.create(agentId, { objective: 'ship it' }) +await agentCtx.goals.create({ objective: 'ship it' }) +``` + +Client applications assemble only `@deepseek-ai/dsh-client-remotes`. That package imports the `/remote` subpaths of selected business packages as runtime values, mounts their contributions on `ctx.api`, and re-exports the declaration merges from the same files. Adding a Host Remote package is an explicit choice by the Client composition owner; business components do not need to load the Host API Gateway or the business package's Remote JS separately. + +A future TUI can assemble the same React-independent `client-remotes` and `ctx.api` contract, so the Host methods visible to it are likewise limited to the Remote methods selected at generation time. This document does not define or implement the TUI composition. + +## Component responsibilities + +| Location | Package or entry | Responsibility | +|---|---|---| +| Shared | `@deepseek-ai/dsh-type-meta` | Declares decorators, Gateway bindings, merge-extensible protocol maps, invocation descriptors, and provider types; starts no TypeScript analysis and registers no Cordis services | +| Build | `@deepseek-ai/dsh-typert-generator` | Strictly analyzes Remote signatures, the type graph, lookups, Contexts, and source locations from the Host `ts.Program`, then generates Host and Host-for-Client artifacts | +| 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-host-api-gateway` | Provides `ctx.typertGateway`, claims Remote endpoints, resolves objects or Contexts, invokes live Cordis services, and validates boundaries | +| Client | `@deepseek-ai/dsh-host-api-gateway/client` | Provides `ctx.api`, mounts generated descriptors as concrete methods, and initiates, validates, and cancels calls through the Connection | +| Client | `@deepseek-ai/dsh-client-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 current `/api` HTTP bridge | + +The Host API Gateway package owns the Host dispatcher and Client API as peer entries, but the two builds never enter the same `ts.Program`. The Host entry does not import the Client Cordis `Context` merge, and the Client entry does not import the Host Gateway service. + +## Strict generation pipeline + +The root build orders `build:lib:host`, `build:lib:client`, and `build:web`. The Host lib build first runs `build:lib:contracts`: it compiles the TypeRT generator, then starts a Host `ts.Program` through `tsdown.typert-host.config.ts` with `tsconfig.host.json` as its seed. The generator does not put the Host and Client aggregates in the same program, so it does not trigger conflicts between the two Cordis `Context` declaration merges. + +Each contributing business package writes generated files to its own `lib/` directory, not to its source directory: + +| File | Consumer | Contents | +|---|---|---| +| `typert.host.js` | Host Loader | Runtime reflection for the Host face, strict invocation descriptors, and schema registration values | +| `typert.host.d.ts` | Host type system | Generated declarations for the Host face | +| `typert.remote-client.js` | `client-remotes` | A mountable `TypeRTRemoteContribution` containing strict descriptors and runtime codecs | +| `typert.remote-client.d.ts` | Client type system | Declaration merges for `TypeRTRemoteNamespaceMap` and `TypeRTRemoteContextMap`, plus Client-safe type references | +| `typert.remote-client.d.ts.map` | Editor | Maps generated method properties back to Remote method declarations in the Host package | + +Business packages expose the Host Loader entry through `./typert` and the Host-for-Client entry through `./remote`. The generator also validates these package exports and published-file lists; it generates artifacts only for explicit contribution packages that provide the corresponding entry. + +Parameter names in Remote Client declarations come from wire fields, while parameter and return types reference Client-safe types exported by the original business package. The declaration map resolves the generated property behind `ctx.api.goals.create` back to the Host source method marked with `@Remote`, so editors that support declaration maps can navigate from a Client call to the real implementation instead of stopping at the generated `.d.ts`. + +Strict analysis requires a Remote to be a public, non-static instance method with a concrete implementation. The method cannot be generic; parameters must be required, named simple identifiers and cannot use destructuring, default values, rest parameters, or optional parameters. TypeRT generates strict schemas for ordinary JSON-representable types; complex objects such as workspace classes must have a unique `TypeRTLookupMap` declaration. Lookup and Context packages are responsible for both static declaration merges and runtime provider registration; if either side is missing, the build or earliest resolvable runtime boundary fails. + +## Runtime invocation + +Remote and API Proxy currently share the Connection's `/api` route; there is no separate `/api2` server or second Connection. The Client API calls `connection.rpc.call('/api', '/', { args }, signal)`; the current HTTP carrier maps this to `POST /api//`, with a payload containing only a named `args` object. + +The Connection performs the unified trust check for `/api` before the HTTP bridge, then dispatches inside the shared FetchHandler in interceptor order. The TypeRT Gateway claims only two-segment endpoints that have a strict descriptor or active SRC marker; unclaimed requests fall back to the existing API Proxy. The Connection owns transport, RPC ids, response envelopes, and request cancellation, while the Gateway owns only the Remote data protocol and business dispatch. Replacing the Connection carrier in the future 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, invokes the service method targeted by the binding, and validates the return value. A missing provider, unknown identity, binding mismatch, missing or extra argument, schema failure, or missing method fails at the boundary before entering or after leaving business code. + +Unloading a Client contribution removes its descriptors and concrete methods together, aborts its in-flight calls, and makes stale method handles retained by external code reject further calls. A strict endpoint withdrawn on the Host also does not degrade to SRC inference, preventing a hot unload from silently weakening validation. + +## SRC development fallback + +When the Host starts from source through `node --import tsx/esm`, it does not execute the TypeRT compiler plugin. Standard decorator initializers still record the method name and invocation mode in a module-private `WeakMap`, while `GatewayService` or `bindTypeRTGateway()` supplies the explicit service binding; the Gateway can therefore construct a weaker temporary descriptor without starting a `ts.Program`. + +The SRC fallback parses simple parameter names from the live function. When a parameter name matches the `parameter` of a registered lookup, such as `agent` or `session`, it uses the lookup's `agentId` or `sessionId` wire field and resolves the object on the Host; other parameters are checked only for cycle-free, JSON-safe data with no special prototype. `@RemoteContext` directly uses the wire field of a registered Host Context provider. SRC does not read TypeScript types, generate Zod schemas, infer optional parameters, or support destructuring, default values, rest parameters, or duplicate parameter names. + +SRC solves only dispatch for a Host process running from source. The Client does not discover decorators from the running Host, and the Client API refuses to mount SRC descriptors that lack strict codecs; its types, codecs, and Remote registration values always come from the most recently generated `lib/typert.remote-client.*` artifacts. + +## Development mode + +A complete build generates Host contracts before compiling the Host, Client, and Web, so it is the deterministic entry for creating or refreshing all artifacts: + +```sh +pnpm run build +``` + +Web development normally starts the source Host after one complete build and runs the Client plugin watcher in another terminal: + +```sh +pnpm run dsh -- web --dev +pnpm run dev:web +``` + +`dsh` starts the Host source through tsx, so the Host can use the SRC fallback; `dev:web` watches only Client plugins with a `dshClient` declaration and rewrites their `lib/client.js`. It does not analyze Host decorators or generate Remote Client DTS. + +Changing only a Remote method's implementation body without changing its contract does not require regenerating the TypeRT files. After adding or removing a decorator or changing an export name, namespace, parameter, return value, lookup, Context, or cancellation signature, regenerate the strict contracts before the Client bundle consumes the new artifacts: + +```sh +pnpm run build:lib:contracts +``` + +The running Client watcher consumes these generated files when it rebundles; without a watcher, run `pnpm run build:lib:client`. Recompiling only the frontend source cannot infer new types from Host decorators. `pnpm run typecheck` includes `build:lib:contracts` as a prerequisite, and CI and release builds also use the strict generation pipeline. + +## Boundaries + +Remote handles only unary method calls with one request and one result. Session event streams, pagination, incremental reduce, projection, and entity substreams require a separate data protocol and registration model; even when they reuse the Connection, they must not masquerade as Remote methods or enter invocation descriptors. diff --git a/docs/api-gateway.zh.md b/docs/api-gateway.zh.md new file mode 100644 index 0000000000..d447cea6b6 --- /dev/null +++ b/docs/api-gateway.zh.md @@ -0,0 +1,157 @@ +# API Gateway + +[English](api-gateway.md) | 中文 + +本文是 TypeRT API Gateway 的当前状态参考。它描述业务 Service 如何声明一元 Remote 方法、构建如何生成 Host 与 Client 契约,以及调用如何复用 Connection 的 RPC 与 `/api` 路由。会话事件、增量数据和其他流协议不属于本文范围;它们可以使用同一个 Connection,但不使用 Remote 方法描述符。 + +## 编程模型 + +业务 Service 通过 `@Remote` 或 `@RemoteContext` 选择对 Client 开放的方法。未标记的方法不会进入生成的 Client 类型或运行时贡献,也不能通过 `ctx.api` 调用。 + +`@Remote` 表示调用根 Host Context 中注册的 Cordis Service。复杂的 Host 对象不能直接跨 wire 传输;业务包必须通过 `TypeRTLookupMap` 声明它与 wire identity 的关联,并在运行时向 `ctx.typert.lookups` 注册解析提供方。例如 `Agent` 参数在 Host 签名中名为 `agent`,生成的 wire 字段为 `agentId`,Gateway 在调用业务方法前将 id 解析为当前的实时对象。 + +`@RemoteContext(key)` 表示先通过 `ctx.typert.contexts` 把 identity 解析为一个作用域 Context,再从该 Context 取得 Service 并调用方法。它适用于方法本身依赖作用域组合、而不需要显式接收 `Agent` 等对象的情形。 + +Service 通常继承 `GatewayService`,让 Cordis service key 与默认 Remote namespace 在构造器中显式绑定。已有其他基类的 Service 可以改为声明 `readonly typertGateway = bindTypeRTGateway(this, serviceKey)`;两种方式都会留下可检查的公开 binding,不依赖编译器向构造函数注入 symbol。 + +```ts +import type { Agent } from '@deepseek-ai/dsh-agent' +import { GatewayService, Remote, RemoteContext } from '@deepseek-ai/dsh-type-meta' +import type { Context } from 'cordis' + +export interface CreateGoalRequest { + objective: string +} + +export interface CreateGoalResult { + accepted: boolean +} + +export class GoalService extends GatewayService { + constructor(ctx: Context) { + super(ctx, 'goals') + } + + @Remote('create') + createForClient( + agent: Agent, + request: CreateGoalRequest, + signal: AbortSignal, + ): CreateGoalResult { + signal.throwIfAborted() + return this.create(agent, request) + } + + @RemoteContext('agent', 'current') + currentForClient(): CreateGoalResult { + return { accepted: true } + } + + private create(_agent: Agent, request: CreateGoalRequest): CreateGoalResult { + return { accepted: request.objective.length > 0 } + } +} +``` + +Remote 方法可以同步返回或返回 Promise。若需要协作式取消,Host 签名的最后一个参数必须是全局类型的 `signal: AbortSignal`;它记录在描述符中而不是进入 `args`,Client 生成的方法则接受最后一个可选的 `AbortSignal`。 + +Client 使用普通对象上的具体函数,不使用 JavaScript Proxy。直接 Remote 出现在 `ctx.api.`;当一个 `@Remote` 方法恰好有一个 lookup 参数、且同名 `TypeRTContextMap` 使用相同 wire identity 时,生成器还会把去掉该 identity 参数后的方法投影到对应作用域 Context。`@RemoteContext` 只生成作用域调用界面。 + +```ts +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { AgentContext } from '@deepseek-ai/dsh-client-runtime/client' +import type { Context } from 'cordis' +import type {} from '@deepseek-ai/dsh-client-remotes/client' + +declare const ctx: Context +declare const agentCtx: AgentContext +declare const agentId: SessionId + +await ctx.api.goals.create(agentId, { objective: 'ship it' }) +await agentCtx.goals.create({ objective: 'ship it' }) +``` + +Client 应用只装配 `@deepseek-ai/dsh-client-remotes`。该包以运行时值导入被选业务包的 `/remote` 子路径,并向 `ctx.api` 挂载贡献,同时重新导出相同文件中的声明合并。增加一个 Host Remote 包是 Client 组合所有者的显式选择;业务组件不需要分别加载 Host API Gateway 或业务包的 Remote JS。 + +未来的 TUI 可以装配同一个不依赖 React 的 `client-remotes` 与 `ctx.api` 契约,因此它能看到的 Host 方法同样只限于生成时选择的 Remote 方法。本文不定义或实现 TUI 组合。 + +## 组件职责 + +| 位置 | 包或入口 | 职责 | +|---|---|---| +| 共享 | `@deepseek-ai/dsh-type-meta` | 声明 decorator、Gateway binding、可合并协议映射、调用描述符及提供方类型;不启动 TypeScript 分析,也不注册 Cordis 服务 | +| 构建 | `@deepseek-ai/dsh-typert-generator` | 从 Host `ts.Program` 严格分析 Remote 签名、类型图、lookup、Context 与源码位置,并生成 Host 和 Host-for-Client 产物 | +| Host | `@deepseek-ai/dsh-typert-registry` 与 Loader | 把生成的 Host 描述符、schema 及业务包注册项放入 `ctx.typert`,并持有 lookup 与 Context 提供方 | +| Host | `@deepseek-ai/dsh-host-api-gateway` | 提供 `ctx.typertGateway`,认领 Remote endpoint,解析对象或 Context,调用实时 Cordis Service 并校验边界 | +| Client | `@deepseek-ai/dsh-host-api-gateway/client` | 提供 `ctx.api`,把生成的描述符挂成具体方法,并通过 Connection 发起、校验和取消调用 | +| Client | `@deepseek-ai/dsh-client-remotes/client` | 显式选择并挂载本应用允许使用的 `/remote` 贡献,向业务代码带入对应的声明合并 | +| 双侧 | `@deepseek-ai/dsh-client-connection` | 提供 RPC carrier、请求关联、信任边界、取消、响应 envelope 与当前 `/api` HTTP bridge | + +Host API Gateway 包同时拥有 Host dispatcher 与 Client API 两个对等入口,但两侧构建不会进入同一个 `ts.Program`。Host 入口不导入 Client 的 Cordis `Context` 合并,Client 入口也不导入 Host Gateway 服务。 + +## 严格生成链路 + +根构建按 `build:lib:host`、`build:lib:client`、`build:web` 排序。Host lib 构建首先运行 `build:lib:contracts`:它先编译 TypeRT generator,再通过 `tsdown.typert-host.config.ts` 以 `tsconfig.host.json` 为种子启动 Host `ts.Program`。生成器不会把 Host 与 Client 聚合放入同一个 program,因而不会触发两侧 Cordis `Context` 声明合并冲突。 + +每个贡献业务包把生成文件写入自己的 `lib/`,而不是源码目录: + +| 文件 | 消费方 | 内容 | +|---|---|---| +| `typert.host.js` | Host Loader | Host face 的运行时反射、严格调用描述符和 schema 注册值 | +| `typert.host.d.ts` | Host 类型系统 | Host face 的生成声明 | +| `typert.remote-client.js` | `client-remotes` | 可挂载的 `TypeRTRemoteContribution`,包含严格描述符与运行时 codec | +| `typert.remote-client.d.ts` | Client 类型系统 | `TypeRTRemoteNamespaceMap` 与 `TypeRTRemoteContextMap` 的声明合并及 Client-safe 类型引用 | +| `typert.remote-client.d.ts.map` | 编辑器 | 将生成的方法属性映射回 Host 包中的 Remote 方法声明 | + +业务包通过 `./typert` 暴露 Host Loader 入口,通过 `./remote` 暴露 Host-for-Client 入口。生成器同时校验这些 package export 及发布文件清单;只有具备相应入口的显式贡献包才会生成产物。 + +Remote Client 声明中的参数名来自 wire 字段,参数和返回类型则引用原业务包导出的 Client-safe 类型。声明 map 把 `ctx.api.goals.create` 最终解析到的生成属性映射到带 `@Remote` 的 Host 源方法,因此支持 declaration-map 的编辑器可以从 Client 调用跳到真实实现,而不是停在生成的 `.d.ts`。 + +严格分析要求 Remote 是公开、非静态、有具体实现的实例方法。方法不能是泛型;参数必须是具名且必填的简单标识符,不能使用解构、默认值、rest 或可选参数。可 JSON 表示的普通类型由 TypeRT 生成严格 schema;工作区 class 等复杂对象必须具有唯一的 `TypeRTLookupMap` 声明。lookup 与 Context 包同时负责静态声明合并和运行时提供方注册,缺少任一侧都会在构建或最早可解析的运行时边界报错。 + +## 运行时调用 + +当前 Remote 与 API Proxy 共用 Connection 的 `/api` 路由,不存在独立 `/api2` server 或第二套 Connection。Client API 调用 `connection.rpc.call('/api', '/', { args }, signal)`;当前 HTTP carrier 对应 `POST /api//`,payload 只包含一个具名 `args` 对象。 + +Connection 在 HTTP bridge 之前执行 `/api` 的统一信任检查,再在共享 FetchHandler 内按 interceptor 顺序分发。TypeRT Gateway 只认领存在严格描述符或活跃 SRC marker 的两段式 endpoint;未认领的请求回退到既有 API Proxy。Connection 拥有传输、RPC id、响应 envelope 和 request cancellation,Gateway 只拥有 Remote 数据协议和业务分发。未来替换 Connection carrier 不要求改变 Remote 描述符或 Client 编程界面。 + +Gateway 每次调用都从当前注册表解析描述符和实时 Service,不缓存业务对象。它要求 `args` 的字段集合与描述符完全一致,先用 codec 校验 wire 值,再通过注册的 lookup 或 Context provider 解析对象或接收者,最后调用 binding 指向的 Service 方法并校验返回值。缺少 provider、identity 未命中、binding 不一致、参数多缺、schema 失败和方法不存在都在进入或离开业务边界时失败。 + +Client 卸载一个贡献时会一起移除描述符和具体方法,中止其进行中的调用,并使外部仍持有的旧方法句柄拒绝继续调用。Host 上已经注册过的严格 endpoint 被撤回后也不会降级到 SRC 推断,以免热卸载悄然降低校验强度。 + +## SRC 开发回退 + +Host 通过 `node --import tsx/esm` 从源码启动时不会执行 TypeRT 编译插件。标准 decorator 初始化器仍会把方法名和调用模式记录到模块私有 `WeakMap`,`GatewayService` 或 `bindTypeRTGateway()` 则提供显式 service binding;Gateway 因而可以在不启动 `ts.Program` 的情况下构造一个较弱的临时描述符。 + +SRC 回退从运行中函数解析简单参数名。参数名与某个已注册 lookup 的 `parameter` 相同,例如 `agent` 或 `session`,就使用其 `agentId` 或 `sessionId` wire 字段并在 Host 解析对象;其他参数只检查值是否为无循环、无特殊 prototype 的 JSON-safe 数据。`@RemoteContext` 直接使用已注册 Host Context provider 的 wire 字段。SRC 不读取 TypeScript 类型,不生成 Zod schema,不推断可选参数,也不支持解构、默认值、rest 或重复参数名。 + +SRC 只解决 Host 源码进程的分发问题。Client 不会从运行中的 Host 发现 decorator,Client API 也拒绝挂载缺少严格 codec 的 SRC 描述符;其类型、codec 和 Remote 注册值始终来自最近一次生成的 `lib/typert.remote-client.*`。 + +## 开发模式 + +完整构建会先生成 Host 契约,再编译 Host、Client 与 Web,因此是建立或刷新所有产物的确定性入口: + +```sh +pnpm run build +``` + +Web 开发通常在完成一次构建后启动源码 Host,并在另一个终端运行 Client plugin watcher: + +```sh +pnpm run dsh -- web --dev +pnpm run dev:web +``` + +`dsh` 通过 tsx 启动 Host 源码,所以 Host 可以使用 SRC 回退;`dev:web` 只监听带 `dshClient` 声明的 Client plugin 并重写其 `lib/client.js`,它不会分析 Host decorator,也不会生成 Remote Client DTS。 + +只修改 Remote 方法实现体而不改变契约时,无需重新生成 TypeRT 文件。新增或删除 decorator、修改导出名、namespace、参数、返回值、lookup、Context 或取消签名时,先重新生成严格契约,再让 Client bundle 使用新的产物: + +```sh +pnpm run build:lib:contracts +``` + +运行中的 Client watcher 会在重新打包时消费这些生成文件;没有 watcher 时运行 `pnpm run build:lib:client`。仅重新编译前端源码不能从 Host decorator 推导新类型。`pnpm run typecheck` 自带 `build:lib:contracts` 前置步骤,CI 与发布构建也使用严格生成链路。 + +## 边界 + +Remote 只处理有单个请求与单个结果的一元方法调用。Session event stream、分页、增量 reduce、projection 和实体子流需要独立的数据协议与注册模型;即使它们复用 Connection,也不应伪装成 Remote 方法或放入调用描述符。 diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 8daacae254..774bc296b1 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/architecture.md -architecture.md: 81464d9c8800556565c84d33239882dc750180a8 -architecture.zh.md: c02bca4f12c3758723b0fc818c89080dccf435d9 +architecture.md: db5991d98dfbc6b04992d62d5a465c375c9a78b8 +architecture.zh.md: 2eb8c3834a6ffc3283c8aa669be481b534bb5914 diff --git a/docs/architecture.md b/docs/architecture.md index 81464d9c88..db5991d98d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -48,6 +48,7 @@ Harnesses are [Cordis](cordis-primer.md) contexts; packages contribute services, | `ctx.credentials` | [`credentials/`](../packages/credentials/README.md) | named secret references resolved per operation, never inlined in configuration | | `ctx.directoryPicker` | [`host/directory-picker`](../packages/host/directory-picker/README.md) | GUI-host directory picking (`native`/`browse` interactions) | | `ctx.typert` | [`typert/registry`](../packages/typert/registry/README.md) | runtime registry for generated package reflection and live Zod schemas | +| `ctx.typertGateway` | [`host/api-gateway`](../packages/host/api-gateway/README.md) | dispatches TypeRT Remote unary calls through the [API Gateway](api-gateway.md) | | `ctx.invariants` | [`support/invariants`](../packages/support/invariants/README.md) | package-name-selected registry of package-owned runtime checks | ## Event diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index c02bca4f12..2eb8c3834a 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -48,6 +48,7 @@ | `ctx.credentials` | [`credentials/`](../packages/credentials/README.md) | 具名密钥引用,按操作解析,绝不内联进配置 | | `ctx.directoryPicker` | [`host/directory-picker`](../packages/host/directory-picker/README.md) | GUI 宿主目录选取(`native`/`browse` 交互) | | `ctx.typert` | [`typert/registry`](../packages/typert/registry/README.md) | 生成的包反射和实时 Zod schema 的运行时注册表 | +| `ctx.typertGateway` | [`host/api-gateway`](../packages/host/api-gateway/README.md) | 通过 [API Gateway](api-gateway.md) 分发 TypeRT Remote 一元调用 | | `ctx.invariants` | [`support/invariants`](../packages/support/invariants/README.md) | 按包名筛选包自有运行时检查的注册表 | ## 事件 diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index 95ed34cce0..2ea336b1f0 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/development.md -development.md: 30a2bd0a2c97df8d3d75ec50f47b861b3a65590e -development.zh.md: 5582a85429c97c3e31517a495c69392b80885f7d +development.md: d480f548dd24ea81d132e4b4c0cc364ce1b0cd53 +development.zh.md: 08ef7fd2d3da7db83eb3ca4dff9f9c85f6d7cb5e diff --git a/docs/development.md b/docs/development.md index 30a2bd0a2c..d480f548dd 100644 --- a/docs/development.md +++ b/docs/development.md @@ -62,6 +62,8 @@ Host and client stay two aggregate programs because both sides declaration-merge Static analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. Decision record: [solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md); the tsc-first emit pipeline is the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md). +Business services declare callable methods on the Host with `@Remote` or `@RemoteContext`; the Host build generates Host-for-Client types and runtime contributions, and the Client's `client-remotes` composition loads those contributions and mounts concrete methods on `ctx.api` or the corresponding scoped Context. See [API Gateway](api-gateway.md) for the generated artifacts on both sides, their assembly relationships, the SRC development fallback, and the Web build order. + If a relevant local check consumes built package output, build once first: ```sh diff --git a/docs/development.zh.md b/docs/development.zh.md index 5582a85429..08ef7fd2d3 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -62,6 +62,8 @@ host 与 client 保持两个聚合 program,是因为两侧在相同键下以 静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。决策记录:[solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md);tsc-first 发射管线见 [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md)。 +业务 Service 在 Host 使用 `@Remote` 或 `@RemoteContext` 声明可调用方法;Host 构建生成 Host-for-Client 类型与运行时贡献,Client 的 `client-remotes` 组合加载这些贡献并将具体方法挂载到 `ctx.api` 或对应的作用域 Context。两侧的生成产物、装配关系、SRC 开发回退和 Web 构建顺序见 [API Gateway](api-gateway.md)。 + 如果相关的本地检查需要使用构建后的包产物,请先构建一次: ```sh From da1ebd2b68a14af5d8688942fcedcfef59ff32a5 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 7 Aug 2026 10:47:17 +0800 Subject: [PATCH 30/63] refactor(goal): call entity methods through remote API --- ...08-02-typert-remote-method-calls.i18n.yaml | 4 +- .../2026-08-02-typert-remote-method-calls.md | 13 ++-- ...026-08-02-typert-remote-method-calls.zh.md | 13 ++-- docs/cordis-catalog/services.md | 51 ++------------ .../client/remotes/tests/built-lib.e2e.ts | 12 +++- packages/client/ui-goal/README.i18n.yaml | 4 +- packages/client/ui-goal/README.md | 4 +- packages/client/ui-goal/README.zh.md | 4 +- packages/client/ui-goal/package.json | 5 +- packages/client/ui-goal/src/client/index.ts | 51 +++++++++----- .../ui-goal/tests/browser-plugin.spec.tsx | 70 ++++++++++++------- packages/client/ui-goal/tsconfig.json | 4 +- .../cordis/tool-cordis/src/api-catalog.ts | 30 ++------ packages/goal/goal/src/index.ts | 61 ++-------------- packages/goal/goal/tests/goal.spec.ts | 12 ++-- pnpm-lock.yaml | 6 +- 16 files changed, 140 insertions(+), 204 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml index 3808a8d363..1a59abbb43 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md -2026-08-02-typert-remote-method-calls.md: ade8eb827ae765677be8dcdb0ffec965c67bc4ab -2026-08-02-typert-remote-method-calls.zh.md: 2de887a2a0e46148fbb2b5ac52cfd7e3b2305b8d +2026-08-02-typert-remote-method-calls.md: c810a221a23549f3e17e25bd40fcc1fc0f9ec868 +2026-08-02-typert-remote-method-calls.zh.md: 38e3ca286dad98665269697f49fba731346e665e diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md index ade8eb827a..c810a221a2 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md @@ -40,7 +40,7 @@ The Host Gateway does not depend on concrete implementations of `ctx.agents`, `c ## Business declarations -Ordinary direct calls use `@Remote`. When migrating to an existing Service or Registry, do not rename or alter existing methods. Add `remoteExport*` entry points at the end of the class and use decorator arguments to declare their short API names. A method explicitly declares every required business object in a top-level parameter position: +Ordinary direct calls use `@Remote`. When an existing method's parameters and result are already the intended Remote contract, decorate that method directly without renaming it. Add a `remoteExport*` adapter only when the wire contract needs a distinct request or result shape, and use the decorator argument to declare its short API name. A method explicitly declares every required business object in a top-level parameter position: ```text export class GoalService extends GatewayService { @@ -48,13 +48,14 @@ export class GoalService extends GatewayService { super(ctx, 'goals') } - create(agent: Agent, request: CreateGoalRequest): CreateGoalResult { + create(agent: Agent, request: CreateGoalRequest): GoalView { // Existing business method remains unchanged. } @Remote('create') remoteExportCreate(agent: Agent, request: CreateGoalRequest): CreateGoalResult { - return this.create(agent, request) + const view = this.create(agent, request) + return { ref: { id: view.id, revision: view.revision } } } } ``` @@ -84,7 +85,7 @@ A method that cooperatively supports cancellation declares `signal: AbortSignal` ## Decorators and the explicit Gateway facet -A decorator only states that a method participates in the Remote contract. It performs no runtime type reflection and injects no hidden symbol into a Service constructor. The arguments to `@Remote('create')` and `@RemoteContext('agent', 'create')` are external method names, while the actual member remains named `remoteExportCreate`. The member name becomes the external method name only when no alias is provided. Inheriting `GatewayService` is the normal explicit declaration that a Service has joined the Gateway; its public readonly `typertGateway` field keeps the binding visible on the runtime instance. +A decorator only states that a method participates in the Remote contract. It performs no runtime type reflection and injects no hidden symbol into a Service constructor. The arguments to `@Remote('create')` and `@RemoteContext('agent', 'create')` are external method names; the decorated member may be the business method itself or an adapter such as `remoteExportCreate`. The member name becomes the external method name only when no alias is provided. Inheriting `GatewayService` is the normal explicit declaration that a Service has joined the Gateway; its public readonly `typertGateway` field keeps the binding visible on the runtime instance. In SRC mode, the decorator may record the prototype, method name, and invocation mode in a `WeakMap` internal to `dsh-type-meta`. It writes no custom properties to a Service instance, prototype, constructor, or method function. @@ -174,7 +175,7 @@ import type { CreateGoalRequest, CreateGoalResult } from '@deepseek-ai/dsh-goal/ Consequently, `SessionId`, the Agent wire ID, the request, and the result all refer to the same TypeScript declaration in the Host and Browser Client. A future TUI can reuse them without a second set of types. Go to Definition, renames, and Find References for a DTO return to the one source location for the business type instead of stopping at a copy in a generated file. -Remote API methods themselves use declaration-map navigation. TypeRT anchors `InvocationModel.location` to the method-name token of the Host `remoteExport*` method and emits a source-map segment on the corresponding property of the namespace interface. After the TypeScript editor resolves `ctx.api.models.list` to its generated declaration, `typert.remote-client.d.ts.map` takes it to the Host Service's `remoteExportList` entry point. That entry point explicitly calls the existing, unrenamed `list()` method; the map does not misidentify the decorator, class, or full signature as the method definition. +Remote API methods themselves use declaration-map navigation. TypeRT anchors `InvocationModel.location` to the decorated Host method-name token and emits a source-map segment on the corresponding property of the namespace interface. For an adapter-backed endpoint, after the TypeScript editor resolves `ctx.api.models.list` to its generated declaration, `typert.remote-client.d.ts.map` takes it to the Host Service's `remoteExportList` entry point. That entry point explicitly calls the existing, unrenamed `list()` method; the map does not misidentify the decorator, class, or full signature as the method definition. TypeRT generates a wire Zod codec for the same symbol key. The Host Gateway uses it to validate input and encode results, while the Client API may use it to encode arguments and validate responses. If a complex type cannot produce a strict codec, the LIB build fails instead of degrading to `unknown` or unchecked JSON. @@ -480,7 +481,7 @@ Connection supplies the shared-channel interceptor and current HTTP carrier mapp ## Verification -- Goal Service keeps its existing business method and adds an explicit `typertGateway` plus `@Remote('create') remoteExportCreate(...)`, without a second route, codec, or Client method list. +- Goal Service directly decorates mutation methods whose business signatures already match the Remote contract and keeps `remoteExportCreate(...)` only to adapt `GoalView` into `CreateGoalResult`, without a second route, codec, or Client method list. - A clean `build:lib` emits Host and consumer Remote artifacts before Client compilation, including the business package's JS, DTS, and declaration map under `/remote`. - Importing `@deepseek-ai/dsh-goal/remote` adds the strict `api.goals.create(...)` type and declaration navigation to `remoteExportCreate`; omitting that import omits the namespace. - Mounting the same import's JS contribution supplies endpoint, parameter, result, lookup, Context, and Zod reflection and materializes the call without a handwritten stub. diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md index 2de887a2a0..38e3ca286d 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md @@ -40,7 +40,7 @@ Host Gateway 不依赖 `ctx.agents`、`ctx.sessions`、`ctx.goals` 或 `ctx.http ## 业务声明 -普通直接调用使用 `@Remote`。迁移到现存 Service 或 Registry 时不重命名、不改变存量方法;类末尾新增 `remoteExport*` 出口,并由 decorator 参数声明短 API 名。方法需要哪个业务对象,就在顶层参数位置显式声明该对象: +普通直接调用使用 `@Remote`。现有方法的参数和结果已经是预期的 Remote 契约时,直接装饰该方法,不为此重命名。只有 wire 契约需要不同的请求或结果形态时,才新增 `remoteExport*` 适配器,并由 decorator 参数声明短 API 名。方法需要哪个业务对象,就在顶层参数位置显式声明该对象: ```text export class GoalService extends GatewayService { @@ -48,13 +48,14 @@ export class GoalService extends GatewayService { super(ctx, 'goals') } - create(agent: Agent, request: CreateGoalRequest): CreateGoalResult { + create(agent: Agent, request: CreateGoalRequest): GoalView { // Existing business method remains unchanged. } @Remote('create') remoteExportCreate(agent: Agent, request: CreateGoalRequest): CreateGoalResult { - return this.create(agent, request) + const view = this.create(agent, request) + return { ref: { id: view.id, revision: view.revision } } } } ``` @@ -84,7 +85,7 @@ export class ScopedGoalService extends GatewayService { ## Decorator 与显式 Gateway facet -Decorator 只表达“该方法参与 Remote 契约”,不负责运行时类型反射,也不向 Service constructor 注入隐藏 symbol。`@Remote('create')` 和 `@RemoteContext('agent', 'create')` 的参数是外部方法名,实际成员名保持 `remoteExportCreate`;未给别名时才使用成员名作为外部方法名。继承 `GatewayService` 是 Service 加入 Gateway 的常规显式声明;其 public readonly `typertGateway` 字段使运行时实例上的绑定保持可见。 +Decorator 只表达“该方法参与 Remote 契约”,不负责运行时类型反射,也不向 Service constructor 注入隐藏 symbol。`@Remote('create')` 和 `@RemoteContext('agent', 'create')` 的参数是外部方法名;被装饰成员既可以是业务方法本身,也可以是 `remoteExportCreate` 这样的适配器。未给别名时才使用成员名作为外部方法名。继承 `GatewayService` 是 Service 加入 Gateway 的常规显式声明;其 public readonly `typertGateway` 字段使运行时实例上的绑定保持可见。 SRC 运行时允许 decorator 在 `dsh-type-meta` 内部的 `WeakMap` 记录 prototype、方法名和调用模式。它不向 Service 实例、prototype、constructor 或方法函数写入自定义属性。 @@ -174,7 +175,7 @@ import type { CreateGoalRequest, CreateGoalResult } from '@deepseek-ai/dsh-goal/ 因此 `SessionId`、Agent wire ID、request 和 result 在 Host 与 Browser Client 中都指向同一 TypeScript declaration,未来 TUI 复用时也不需要第二份类型。DTO 的跳转定义、重命名和引用查找回到业务类型的唯一源码位置,而不是停在生成文件中的副本。 -Remote API 方法本身使用 declaration map 导航。TypeRT 把 `InvocationModel.location` 固定在 Host 的 `remoteExport*` 方法名 token,并在 namespace interface 的对应属性上写入 source-map segment;TypeScript editor 从 `ctx.api.models.list` 取得生成 declaration 后,再沿 `typert.remote-client.d.ts.map` 跳到 Host Service 的 `remoteExportList` 远程出口。该出口继续显式调用不改名的存量 `list()`,map 不把 decorator、class 或整个签名误当成方法定义位置。 +Remote API 方法本身使用 declaration map 导航。TypeRT 把 `InvocationModel.location` 固定在 Host 被装饰方法的方法名 token,并在 namespace interface 的对应属性上写入 source-map segment。对于由适配器支撑的 endpoint,TypeScript editor 从 `ctx.api.models.list` 取得生成 declaration 后,再沿 `typert.remote-client.d.ts.map` 跳到 Host Service 的 `remoteExportList` 远程出口。该出口继续显式调用不改名的存量 `list()`,map 不把 decorator、class 或整个签名误当成方法定义位置。 TypeRT 为同一 symbol key 生成 wire Zod codec。Host Gateway 用它校验输入和编码结果,Client API 可以用它编码参数并校验响应;复杂类型无法生成严格 codec 时,LIB 构建失败,不降级为 `unknown` 或无校验 JSON。 @@ -480,7 +481,7 @@ Connection 提供共享 channel interceptor 与当前 HTTP carrier 映射。WebS ## 验证 -- Goal Service 保留既有业务方法,并新增显式 `typertGateway` 与 `@Remote('create') remoteExportCreate(...)`,无需第二条路由、第二份 codec 或 Client 方法清单。 +- Goal Service 直接装饰业务签名已经符合 Remote 契约的变更类方法,仅保留 `remoteExportCreate(...)` 把 `GoalView` 适配为 `CreateGoalResult`,无需第二条路由、第二份 codec 或 Client 方法清单。 - 一次干净的 `build:lib` 会在 Client 编译前生成 Host 与消费方 Remote 产物,包括业务包 `/remote` 下的 JS、DTS 和 declaration map。 - 导入 `@deepseek-ai/dsh-goal/remote` 会加入严格的 `api.goals.create(...)` 类型,并可通过 declaration 导航到 `remoteExportCreate`;不导入时不会出现该 namespace。 - 挂载同一次 import 得到的 JS contribution 会提供 endpoint、参数、结果、lookup、Context 和 Zod 反射,并在无需手写 stub 的情况下实体化调用。 diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 99ffaca7c7..8a646fc177 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -718,7 +718,7 @@ create(agent: Agent, request: CreateGoalRequest): GoalView * @param request - at least one replacement field. * @returns the edited view. */ -edit(agent: Agent, ref: GoalRef, request: EditGoalRequest): GoalView +@Remote('edit') edit(agent: Agent, ref: GoalRef, request: EditGoalRequest): GoalView /** * Pause an active goal and disarm automatic continuation. @@ -726,7 +726,7 @@ edit(agent: Agent, ref: GoalRef, request: EditGoalRequest): GoalView * @param ref - expected current revision. * @returns the paused view. */ -pause(agent: Agent, ref: GoalRef): GoalView +@Remote('pause') pause(agent: Agent, ref: GoalRef): GoalView /** * Resume and arm a stopped goal, or rearm an active goal after a @@ -735,7 +735,7 @@ pause(agent: Agent, ref: GoalRef): GoalView * @param ref - expected current revision. * @returns the active view. */ -resume(agent: Agent, ref: GoalRef): GoalView +@Remote('resume') resume(agent: Agent, ref: GoalRef): GoalView /** * Mark a current non-complete goal complete and disarm it. @@ -743,7 +743,7 @@ resume(agent: Agent, ref: GoalRef): GoalView * @param ref - expected current revision. * @returns the completed view. */ -complete(agent: Agent, ref: GoalRef): GoalView +@Remote('complete') complete(agent: Agent, ref: GoalRef): GoalView /** * Mark an active goal blocked and disarm it. @@ -760,7 +760,7 @@ block(agent: Agent, ref: GoalRef, reason: GoalBlockReason): GoalView * @param ref - expected current revision. * @returns the tombstone ref whose revision is one past the cleared snapshot. */ -clear(agent: Agent, ref: GoalRef): GoalRef +@Remote('clear') clear(agent: Agent, ref: GoalRef): GoalRef /** * Create one Goal through the remote boundary. @@ -769,47 +769,6 @@ clear(agent: Agent, ref: GoalRef): GoalRef * @returns the created Goal identity. */ @Remote('create') remoteExportCreate(agent: Agent, request: CreateGoalRequest): CreateGoalResult - -/** - * Edit one Goal through the remote boundary. - * @param agent - exact live Agent resolved from the wire identity. - * @param ref - expected current revision. - * @param request - replacement fields. - * @returns the edited Goal view. - */ -@Remote('edit') remoteExportEdit(agent: Agent, ref: GoalRef, request: EditGoalRequest): GoalView - -/** - * Pause one Goal through the remote boundary. - * @param agent - exact live Agent resolved from the wire identity. - * @param ref - expected current revision. - * @returns the paused Goal view. - */ -@Remote('pause') remoteExportPause(agent: Agent, ref: GoalRef): GoalView - -/** - * Resume one Goal through the remote boundary. - * @param agent - exact live Agent resolved from the wire identity. - * @param ref - expected current revision. - * @returns the resumed Goal view. - */ -@Remote('resume') remoteExportResume(agent: Agent, ref: GoalRef): GoalView - -/** - * Complete one Goal through the remote boundary. - * @param agent - exact live Agent resolved from the wire identity. - * @param ref - expected current revision. - * @returns the completed Goal view. - */ -@Remote('complete') remoteExportComplete(agent: Agent, ref: GoalRef): GoalView - -/** - * Clear one terminal Goal through the remote boundary. - * @param agent - exact live Agent resolved from the wire identity. - * @param ref - expected current revision. - * @returns the committed clear revision. - */ -@Remote('clear') remoteExportClear(agent: Agent, ref: GoalRef): GoalRef ``` Types: [Agent](../core-data-structures/core.md) · [CreateGoalRequest](../core-data-structures/goal.md) · [CreateGoalResult](../core-data-structures/goal.md) · [EditGoalRequest](../core-data-structures/goal.md) · [GoalBlockReason](../core-data-structures/goal.md) · [GoalRef](../core-data-structures/goal.md) · [GoalView](../core-data-structures/goal.md) diff --git a/packages/client/remotes/tests/built-lib.e2e.ts b/packages/client/remotes/tests/built-lib.e2e.ts index bef3f4ad65..0cee3eb245 100644 --- a/packages/client/remotes/tests/built-lib.e2e.ts +++ b/packages/client/remotes/tests/built-lib.e2e.ts @@ -148,11 +148,17 @@ describe.skipIf(!requiredArtifacts)('Goal Remote built LIB chain', () => { invalidRejected = true } const rootResult = await client.api.goals.create(rootAgent.id, { objective: 'root goal' }) + const rootEdit = await client.api.goals.edit( + rootAgent.id, + rootResult.ref, + { objective: 'edited root goal' }, + ) const agentContext = client.extend({ builtAgentId: scopedAgent.id }) const scopedResult = await agentContext.goals.create({ objective: 'scoped goal', maxGoalRounds: 3 }) const result = { invalidRejected, rootResult, + rootEdit, scopedResult, rootGoal: host.goals.get(rootAgent)?.objective, scopedGoal: host.goals.get(scopedAgent)?.objective, @@ -174,6 +180,7 @@ describe.skipIf(!requiredArtifacts)('Goal Remote built LIB chain', () => { const output = JSON.parse(result.stdout.trim().split('\n').at(-1) ?? '{}') as { invalidRejected: boolean rootResult: { ref: { id: string; revision: number } } + rootEdit: { objective: string; revision: number } scopedResult: { ref: { id: string; revision: number } } rootGoal: string scopedGoal: string @@ -183,10 +190,11 @@ describe.skipIf(!requiredArtifacts)('Goal Remote built LIB chain', () => { expect(output).toMatchObject({ invalidRejected: true, rootResult: { ref: { revision: 1 } }, + rootEdit: { objective: 'edited root goal', revision: 2 }, scopedResult: { ref: { revision: 1 } }, - rootGoal: 'root goal', + rootGoal: 'edited root goal', scopedGoal: 'scoped goal', - rootEvents: 1, + rootEvents: 2, scopedEvents: 1, }) expect(output.rootResult.ref.id).toMatch(/^goal-/) diff --git a/packages/client/ui-goal/README.i18n.yaml b/packages/client/ui-goal/README.i18n.yaml index 5120d720cd..f30f14ed48 100644 --- a/packages/client/ui-goal/README.i18n.yaml +++ b/packages/client/ui-goal/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-goal/README.md -README.md: 0ea00b8bf9b07f02b5df0f7b3e7d3d9c6f109fde -README.zh.md: 70bf443118e5d2b1ce46e7bc1479bf932507b3f9 +README.md: b99aaf624a7d669879ba668938ee455e3cdc68ad +README.zh.md: 3d823d013066bc912398f61c85553887e05ca3b4 diff --git a/packages/client/ui-goal/README.md b/packages/client/ui-goal/README.md index 0ea00b8bf9..b99aaf624a 100644 --- a/packages/client/ui-goal/README.md +++ b/packages/client/ui-goal/README.md @@ -2,13 +2,13 @@ English | [中文](README.zh.md) -Goal surface plugin, browser half: the `GoalBar` strip is the second standalone card in the `conversation.input.dock` composer-context stack (order 10, after Todo and before Queue). The live goal arrives through `useProjection('goal')` — the host-computed whole value seeded by the history tail page and updated by `session/projection` frames — so the plugin owns no domain store, refresh chain, or event listener. The slot inject face carries only the four mutation verbs (edit / pause / resume / clear over the `goal.*` wire domain — an active goal offers the pause action, a paused one resume); each reads the CAS ref from the session's current projected value at call time and surfaces the settled RPC error inline. The strip single-flights mutations synchronously because React's pending render cannot fence same-frame clicks; after a successful clear it immediately suppresses that exact goal id while the authoritative null projection catches up. Goal creation stays on the `/goal` host command; loading, absent, completed, and successfully cleared goals render nothing. +Goal surface plugin, browser half: the `GoalBar` strip is the second standalone card in the `conversation.input.dock` composer-context stack (order 10, after Todo and before Queue). The live goal arrives through `useProjection('goal')` — the host-computed whole value seeded by the history tail page and updated by `session/projection` frames — so the plugin owns no domain store, refresh chain, or event listener. The slot inject face carries only the four mutation verbs (edit / pause / resume / clear through `ctx.api.goals` — an active goal offers the pause action, a paused one resume); each reads the CAS ref from the session's current projected value at call time and surfaces the rejected Remote error inline. The strip single-flights mutations synchronously because React's pending render cannot fence same-frame clicks; after a successful clear it immediately suppresses that exact goal id while the authoritative null projection catches up. Goal creation stays on the `/goal` host command; loading, absent, completed, and successfully cleared goals render nothing. The `/client` export surface is the plugin body (`apply`/`inject`), the `GoalBar`/`GoalDock` components, and the injected verb face types. ## Model Experience -Indirectly, through the `goal.edit`/`goal.pause`/`goal.resume`/`goal.clear` RPCs the strip's verbs submit: each accepted mutation commits in a durable `agent/inbox/spliced` insertion, which the goal projection folds immediately, and queues a `goal/change` context message. The model sees that context only if a later pre-step admits it; discarding the queued message does not roll back the projected state. The strip itself adds no prompt content. +Indirectly, through the `goals/edit`, `goals/pause`, `goals/resume`, and `goals/clear` Remote methods the strip invokes: each accepted mutation commits in a durable `agent/inbox/spliced` insertion, which the goal projection folds immediately, and queues a `goal/change` context message. The model sees that context only if a later pre-step admits it; discarding the queued message does not roll back the projected state. The strip itself adds no prompt content. #### KV Cache effect diff --git a/packages/client/ui-goal/README.zh.md b/packages/client/ui-goal/README.zh.md index 70bf443118..3d823d0130 100644 --- a/packages/client/ui-goal/README.zh.md +++ b/packages/client/ui-goal/README.zh.md @@ -2,13 +2,13 @@ [English](README.md) | 中文 -Goal 界面插件(浏览器端部分):`GoalBar` 条带是 `conversation.input.dock` composer 上下文堆栈中的第二张独立卡片(order 10,位于 Todo 之后、Queue 之前)。活值经 `useProjection('goal')` 到达——host 计算的全量值由历史尾页播种、由 `session/projection` 帧更新——因此本插件不持有领域 store、不设刷新链、不挂事件监听。slot 注入面只携带四个变更动词(edit / pause / resume / clear,走 `goal.*` 协议域——active 的 goal 提供暂停动作,paused 的提供恢复);每个动词在调用时从会话当前投影值读取 CAS ref,并把结算后的 RPC 错误内联呈现。由于 React 的 pending 渲染无法拦住同一帧内的点击,横条会同步为变更建立 single-flight 防护;清除成功后,会立即抑制该 goal id 对应的目标显示,直到权威的 null 投影追上。goal 的创建仍归 `/goal` host 命令;加载中、无 goal、已完成和已成功清除的 goal 一律不渲染。 +Goal 界面插件(浏览器端部分):`GoalBar` 条带是 `conversation.input.dock` composer 上下文堆栈中的第二张独立卡片(order 10,位于 Todo 之后、Queue 之前)。活值经 `useProjection('goal')` 到达——host 计算的全量值由历史尾页播种、由 `session/projection` 帧更新——因此本插件不持有领域 store、不设刷新链、不挂事件监听。slot 注入面只携带四个变更动词(edit / pause / resume / clear,经 `ctx.api.goals` 调用——active 的 goal 提供暂停动作,paused 的提供恢复);每个动词在调用时从会话当前投影值读取 CAS ref,并将 Remote 调用的拒绝错误内联呈现。由于 React 的 pending 渲染无法拦住同一帧内的点击,横条会同步为变更建立 single-flight 防护;清除成功后,会立即抑制该 goal id 对应的目标显示,直到权威的 null 投影追上。goal 的创建仍归 `/goal` host 命令;加载中、无 goal、已完成和已成功清除的 goal 一律不渲染。 `/client` 的导出接口包括插件本体(`apply`/`inject`)、`GoalBar`/`GoalDock` 组件与注入动词面类型。 ## 模型体验 -间接影响:条带动词提交的 `goal.edit`/`goal.pause`/`goal.resume`/`goal.clear` RPC 每次被接受后,变更都会在持久 `agent/inbox/spliced` 插入项中提交,goal 投影会立即折叠该插入项,同时将一条 `goal/change` 上下文消息排队。只有后续 pre-step 准入该上下文时,模型才会看到它;丢弃已排队的消息不会回滚投影状态。条带自身不添加任何提示词内容。 +间接影响:条带通过调用 `goals/edit`、`goals/pause`、`goals/resume` 和 `goals/clear` Remote 方法提交变更;每次被接受的变更都会在持久 `agent/inbox/spliced` 插入项中提交,goal 投影会立即折叠该插入项,同时将一条 `goal/change` 上下文消息排队。只有后续 pre-step 准入该上下文时,模型才会看到它;丢弃已排队的消息不会回滚投影状态。条带自身不添加任何提示词内容。 #### KV Cache 影响 diff --git a/packages/client/ui-goal/package.json b/packages/client/ui-goal/package.json index 9430da812f..4c26405bd8 100644 --- a/packages/client/ui-goal/package.json +++ b/packages/client/ui-goal/package.json @@ -25,6 +25,7 @@ "dshClient": { "inject": [ "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-client-remotes", "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-ui-conversation" ], @@ -36,8 +37,8 @@ }, "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-client-connection": "^0.0.1", "@deepseek-ai/dsh-client-locale": "^0.0.1", + "@deepseek-ai/dsh-client-remotes": "^0.0.1", "@deepseek-ai/dsh-client-runtime": "^0.0.1", "@deepseek-ai/dsh-client-ui-conversation": "^0.0.1", "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", @@ -48,8 +49,8 @@ "react": "^18.2.0" }, "devDependencies": { - "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-remotes": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", diff --git a/packages/client/ui-goal/src/client/index.ts b/packages/client/ui-goal/src/client/index.ts index 6ee340715c..19b88139b5 100644 --- a/packages/client/ui-goal/src/client/index.ts +++ b/packages/client/ui-goal/src/client/index.ts @@ -4,19 +4,19 @@ * arrives through `useProjection('goal')` (seeded by the history tail page, * updated by session/projection frames), so this plugin owns no store, no * refresh chain, and no event listener. The inject face carries only the - * three mutation verbs (edit/resume/clear over the goal.* wire domain); + * four mutation verbs through the generated Goal Remote API; * their CAS ref reads the session's current projected value at call time. * Goal creation stays on the /goal host command. */ -import type { ConnectionHandle, GoalRef, SessionId } from '@deepseek-ai/dsh-client-connection/client' -import type { RpcResult } from '@deepseek-ai/dsh-client-connection/client' -import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client' +// Type-only: pulls the generated Remote API and ctx.api merge through the Client assembly boundary. +import type {} from '@deepseek-ai/dsh-client-remotes/client' // Type-only: pulls the ui-conversation SlotMap merge (the input.dock entry). import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' // Type-only: pulls the locale plugin's Context merge (ctx.locale). import type {} from '@deepseek-ai/dsh-client-locale/client' // Type-only: the `goal` SessionProjectionMap key merge (single source, the domain's pure outlet). -import type { GoalProjection } from '@deepseek-ai/dsh-goal/client' +import type { GoalProjection, GoalRef } from '@deepseek-ai/dsh-goal/client' import type { GoalActionResult, GoalBarActions } from './slots.ts' import { GoalDock } from './GoalBar.tsx' import { en, zh, type GoalKey } from './locales.ts' @@ -35,13 +35,32 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { /** Dictionary namespace owned by this plugin. */ const NS = 'goal' -/** Required services: slots for the dock entry, sessions for the projected ref, connection for the wire verbs, locale for the copy. */ -export const inject = ['slots', 'sessions', 'connection', 'locale'] +/** Required services: slots for the dock entry, sessions for the projected ref, API for Remote mutations, locale for the copy. */ +export const inject = ['slots', 'sessions', 'api', 'locale'] -/** Map one settled RPC result onto the strip's inline-render shape. */ -function settle(result: RpcResult): GoalActionResult { - if (result.ok) return { ok: true } - return { ok: false, error: { code: result.error.code, message: result.error.message } } +/** Map one generated Remote call onto the strip's inline-render shape. */ +async function settle(result: Promise): Promise { + try { + await result + return { ok: true } + } catch (error) { + const cause = error instanceof Error ? error.cause : undefined + if (isRemoteError(cause)) return { ok: false, error: { code: cause.code, message: cause.message } } + return { + ok: false, + error: { + code: 'internal', + message: error instanceof Error ? error.message : 'goal mutation failed', + }, + } + } +} + +function isRemoteError(value: unknown): value is { readonly code: string; readonly message: string } { + return value !== null + && typeof value === 'object' + && typeof (value as { code?: unknown }).code === 'string' + && typeof (value as { message?: unknown }).message === 'string' } /** @@ -51,7 +70,7 @@ function settle(result: RpcResult): GoalActionResult { export function apply(ctx: ClientContext): void { ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-goal: dictionaries') - const { goals } = (ctx.get('connection') as ConnectionHandle).api + const { goals } = ctx.api const sessions = ctx.sessions @@ -77,22 +96,22 @@ export function apply(ctx: ClientContext): void { onEdit: async (objective) => { const ref = refOf(sessionId) if (ref === undefined) return noCurrentGoal - return settle((await goals.edit({ sessionId, ref, objective })).result) + return settle(goals.edit(sessionId, ref, { objective })) }, onPause: async () => { const ref = refOf(sessionId) if (ref === undefined) return noCurrentGoal - return settle((await goals.pause({ sessionId, ref })).result) + return settle(goals.pause(sessionId, ref)) }, onResume: async () => { const ref = refOf(sessionId) if (ref === undefined) return noCurrentGoal - return settle((await goals.resume({ sessionId, ref })).result) + return settle(goals.resume(sessionId, ref)) }, onClear: async () => { const ref = refOf(sessionId) if (ref === undefined) return noCurrentGoal - return settle((await goals.clear({ sessionId, ref })).result) + return settle(goals.clear(sessionId, ref)) }, }), }, GoalDock)) diff --git a/packages/client/ui-goal/tests/browser-plugin.spec.tsx b/packages/client/ui-goal/tests/browser-plugin.spec.tsx index 98d5a0291a..eddb272be4 100644 --- a/packages/client/ui-goal/tests/browser-plugin.spec.tsx +++ b/packages/client/ui-goal/tests/browser-plugin.spec.tsx @@ -1,11 +1,11 @@ // @vitest-environment jsdom /** - * ui-goal browser half on a real cordis Context with fake slots/connection/ + * ui-goal browser half on a real cordis Context with fake slots/api/ * sessions faces: the plugin registers the GoalBar dock entry at - * conversation.input.dock, the inject face's three verbs read the CAS ref + * conversation.input.dock, the inject face's four verbs read the CAS ref * from the session's CURRENT projected value at call time (no fence — the - * RPC's compare-and-set is the guard), a missing projection short-circuits - * to the no-current-goal error without touching the wire, and RPC errors + * Remote method's compare-and-set is the guard), a missing projection short-circuits + * to the no-current-goal error without touching the wire, and Remote errors * map onto the inline-render result shape. Registration disposal rides the * plugin fiber (HMR safety). The node half and the invariant companion are * exercised over the same Context. @@ -44,27 +44,32 @@ function makeProjection(revision = 3): GoalProjection { } } -/** Boot the plugin over fake faces; goals verbs record payloads and answer per the script. */ -async function bench(options: { projection?: GoalProjection | null | undefined; failWith?: { code: string; message: string } } = {}) { +/** Boot the plugin over fake faces; Goal Remote methods record arguments and answer per the script. */ +async function bench(options: { + projection?: GoalProjection | null | undefined + failWith?: { code: string; message: string } + rejectWith?: unknown +} = {}) { const ctx = new Context() - const calls: { method: string; payload: unknown }[] = [] + const calls: { method: string; args: unknown[] }[] = [] function answer(method: string, value: T) { - return (payload: unknown) => { - calls.push({ method, payload }) - return Promise.resolve({ - result: options.failWith === undefined - ? { ok: true as const, value } - : { ok: false as const, error: { ...options.failWith, details: {} } }, - }) + return (...args: unknown[]) => { + calls.push({ method, args }) + // oxlint-disable-next-line typescript/prefer-promise-reject-errors -- the non-Error rejection is the defensive scenario under test. + if ('rejectWith' in options) return Promise.reject(options.rejectWith) + if (options.failWith !== undefined) { + return Promise.reject(new Error(`Remote ${method} failed`, { cause: options.failWith })) + } + return Promise.resolve(value) } } const ref = { id: 'g-1', revision: 3 } - ctx.provide('connection', { api: { goals: { - edit: answer('goal.edit', { ref }), - pause: answer('goal.pause', { ref }), - resume: answer('goal.resume', { ref }), - clear: answer('goal.clear', { cleared: true as const }), - } } }) + ctx.provide('api', { goals: { + edit: answer('goals/edit', { ref }), + pause: answer('goals/pause', { ref }), + resume: answer('goals/resume', { ref }), + clear: answer('goals/clear', ref), + } }) await ctx.plugin(SlotsService).await() ctx.slots.register({ name: 'root', children: { 'conversation.input.dock': { kind: 'list', scope: 'session' } }, @@ -113,12 +118,12 @@ describe('ui-goal browser plugin', () => { expect(await verbs.onPause()).toEqual({ ok: true }) expect(await verbs.onResume()).toEqual({ ok: true }) expect(await verbs.onClear()).toEqual({ ok: true }) - expect(b.calls.map(c => c.method)).toEqual(['goal.edit', 'goal.pause', 'goal.resume', 'goal.clear']) + expect(b.calls.map(c => c.method)).toEqual(['goals/edit', 'goals/pause', 'goals/resume', 'goals/clear']) const ref = { id: 'g-1', revision: 5 } - expect(b.calls[0]?.payload).toEqual({ sessionId: 's1', ref, objective: 'New objective' }) - expect(b.calls[1]?.payload).toEqual({ sessionId: 's1', ref }) - expect(b.calls[2]?.payload).toEqual({ sessionId: 's1', ref }) - expect(b.calls[3]?.payload).toEqual({ sessionId: 's1', ref }) + expect(b.calls[0]?.args).toEqual(['s1', ref, { objective: 'New objective' }]) + expect(b.calls[1]?.args).toEqual(['s1', ref]) + expect(b.calls[2]?.args).toEqual(['s1', ref]) + expect(b.calls[3]?.args).toEqual(['s1', ref]) }) it('a null or absent projection short-circuits every verb without touching the wire', async () => { @@ -133,13 +138,26 @@ describe('ui-goal browser plugin', () => { } }) - it('maps a settled RPC error onto the inline-render shape', async () => { + it('maps a Remote error onto the inline-render shape', async () => { const b = await bench({ projection: makeProjection(), failWith: { code: 'internal', message: 'stale revision' } }) await b.fiber.await() const verbs = b.entry()!.inject!(sid('s1')) expect(await verbs.onEdit('x')).toEqual({ ok: false, error: { code: 'internal', message: 'stale revision' } }) }) + it.each([ + [new Error('connection closed'), 'connection closed'], + ['connection closed', 'goal mutation failed'], + [new Error('invalid Remote failure', { cause: null }), 'invalid Remote failure'], + [new Error('invalid Remote failure', { cause: { code: 1, message: 'stale revision' } }), 'invalid Remote failure'], + [new Error('invalid Remote failure', { cause: { code: 'internal', message: 1 } }), 'invalid Remote failure'], + ])('maps an unstructured rejection onto an internal error', async (rejection, message) => { + const b = await bench({ projection: makeProjection(), rejectWith: rejection }) + await b.fiber.await() + const verbs = b.entry()!.inject!(sid('s1')) + expect(await verbs.onEdit('x')).toEqual({ ok: false, error: { code: 'internal', message } }) + }) + it('drops the dock entry when the plugin fiber unloads (HMR safety)', async () => { const b = await bench() await b.fiber.await() diff --git a/packages/client/ui-goal/tsconfig.json b/packages/client/ui-goal/tsconfig.json index ad863bdc32..2bb4070b18 100644 --- a/packages/client/ui-goal/tsconfig.json +++ b/packages/client/ui-goal/tsconfig.json @@ -12,10 +12,10 @@ "path": "../../../vendor/cordis" }, { - "path": "../connection" + "path": "../locale" }, { - "path": "../locale" + "path": "../remotes" }, { "path": "../runtime" diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index d8d067ce3e..2627d43b69 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -359,19 +359,19 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ jsDoc: '/**\n * Create and arm a goal. A completed goal may be replaced; every other\n * current phase must be cleared or resumed instead.\n * @param agent - owning live agent.\n * @param request - objective and optional round cap.\n * @returns the created live view.\n */', }, { - signature: 'edit(agent: Agent, ref: GoalRef, request: EditGoalRequest): GoalView', + signature: '@Remote(\'edit\') edit(agent: Agent, ref: GoalRef, request: EditGoalRequest): GoalView', jsDoc: '/**\n * Edit objective and/or round cap without changing phase.\n * @param agent - owning live agent.\n * @param ref - expected current revision.\n * @param request - at least one replacement field.\n * @returns the edited view.\n */', }, { - signature: 'pause(agent: Agent, ref: GoalRef): GoalView', + signature: '@Remote(\'pause\') pause(agent: Agent, ref: GoalRef): GoalView', jsDoc: '/**\n * Pause an active goal and disarm automatic continuation.\n * @param agent - owning live agent.\n * @param ref - expected current revision.\n * @returns the paused view.\n */', }, { - signature: 'resume(agent: Agent, ref: GoalRef): GoalView', + signature: '@Remote(\'resume\') resume(agent: Agent, ref: GoalRef): GoalView', jsDoc: '/**\n * Resume and arm a stopped goal, or rearm an active goal after a\n * session-start edge, while its round budget still has capacity.\n * @param agent - owning live agent.\n * @param ref - expected current revision.\n * @returns the active view.\n */', }, { - signature: 'complete(agent: Agent, ref: GoalRef): GoalView', + signature: '@Remote(\'complete\') complete(agent: Agent, ref: GoalRef): GoalView', jsDoc: '/**\n * Mark a current non-complete goal complete and disarm it.\n * @param agent - owning live agent.\n * @param ref - expected current revision.\n * @returns the completed view.\n */', }, { @@ -379,33 +379,13 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ jsDoc: '/**\n * Mark an active goal blocked and disarm it.\n * @param agent - owning live agent.\n * @param ref - expected current revision.\n * @param reason - policy-owned stable code and human-readable explanation.\n * @returns the blocked view with its durable reason.\n */', }, { - signature: 'clear(agent: Agent, ref: GoalRef): GoalRef', + signature: '@Remote(\'clear\') clear(agent: Agent, ref: GoalRef): GoalRef', jsDoc: '/**\n * Clear the current goal while retaining a durable tombstone and history.\n * @param agent - owning live agent.\n * @param ref - expected current revision.\n * @returns the tombstone ref whose revision is one past the cleared snapshot.\n */', }, { signature: '@Remote(\'create\') remoteExportCreate(agent: Agent, request: CreateGoalRequest): CreateGoalResult', jsDoc: '/**\n * Create one Goal through the remote boundary.\n * @param agent - exact live Agent resolved from the wire identity.\n * @param request - objective and optional round cap.\n * @returns the created Goal identity.\n */', }, - { - signature: '@Remote(\'edit\') remoteExportEdit(agent: Agent, ref: GoalRef, request: EditGoalRequest): GoalView', - jsDoc: '/**\n * Edit one Goal through the remote boundary.\n * @param agent - exact live Agent resolved from the wire identity.\n * @param ref - expected current revision.\n * @param request - replacement fields.\n * @returns the edited Goal view.\n */', - }, - { - signature: '@Remote(\'pause\') remoteExportPause(agent: Agent, ref: GoalRef): GoalView', - jsDoc: '/**\n * Pause one Goal through the remote boundary.\n * @param agent - exact live Agent resolved from the wire identity.\n * @param ref - expected current revision.\n * @returns the paused Goal view.\n */', - }, - { - signature: '@Remote(\'resume\') remoteExportResume(agent: Agent, ref: GoalRef): GoalView', - jsDoc: '/**\n * Resume one Goal through the remote boundary.\n * @param agent - exact live Agent resolved from the wire identity.\n * @param ref - expected current revision.\n * @returns the resumed Goal view.\n */', - }, - { - signature: '@Remote(\'complete\') remoteExportComplete(agent: Agent, ref: GoalRef): GoalView', - jsDoc: '/**\n * Complete one Goal through the remote boundary.\n * @param agent - exact live Agent resolved from the wire identity.\n * @param ref - expected current revision.\n * @returns the completed Goal view.\n */', - }, - { - signature: '@Remote(\'clear\') remoteExportClear(agent: Agent, ref: GoalRef): GoalRef', - jsDoc: '/**\n * Clear one terminal Goal through the remote boundary.\n * @param agent - exact live Agent resolved from the wire identity.\n * @param ref - expected current revision.\n * @returns the committed clear revision.\n */', - }, ], }, { diff --git a/packages/goal/goal/src/index.ts b/packages/goal/goal/src/index.ts index 312e3a70d9..6667463d86 100644 --- a/packages/goal/goal/src/index.ts +++ b/packages/goal/goal/src/index.ts @@ -273,6 +273,7 @@ export class GoalService extends GatewayService { * @param request - at least one replacement field. * @returns the edited view. */ + @Remote('edit') edit(agent: Agent, ref: GoalRef, request: EditGoalRequest): GoalView { const cache = this.prepareMutation(agent) const current = this.expectCurrent(cache, ref) @@ -294,6 +295,7 @@ export class GoalService extends GatewayService { * @param ref - expected current revision. * @returns the paused view. */ + @Remote('pause') pause(agent: Agent, ref: GoalRef): GoalView { return this.transition(agent, ref, 'pause', ['active'], 'paused', 'disarmed') } @@ -305,6 +307,7 @@ export class GoalService extends GatewayService { * @param ref - expected current revision. * @returns the active view. */ + @Remote('resume') resume(agent: Agent, ref: GoalRef): GoalView { const cache = this.prepareMutation(agent) const current = this.expectCurrent(cache, ref) @@ -330,6 +333,7 @@ export class GoalService extends GatewayService { * @param ref - expected current revision. * @returns the completed view. */ + @Remote('complete') complete(agent: Agent, ref: GoalRef): GoalView { return this.transition( agent, @@ -369,6 +373,7 @@ export class GoalService extends GatewayService { * @param ref - expected current revision. * @returns the tombstone ref whose revision is one past the cleared snapshot. */ + @Remote('clear') clear(agent: Agent, ref: GoalRef): GoalRef { const cache = this.prepareMutation(agent) const current = this.expectCurrent(cache, ref) @@ -582,62 +587,6 @@ export class GoalService extends GatewayService { const view = this.create(agent, request) return { ref: { id: view.id, revision: view.revision } } } - - /** - * Edit one Goal through the remote boundary. - * @param agent - exact live Agent resolved from the wire identity. - * @param ref - expected current revision. - * @param request - replacement fields. - * @returns the edited Goal view. - */ - @Remote('edit') - remoteExportEdit(agent: Agent, ref: GoalRef, request: EditGoalRequest): GoalView { - return this.edit(agent, ref, request) - } - - /** - * Pause one Goal through the remote boundary. - * @param agent - exact live Agent resolved from the wire identity. - * @param ref - expected current revision. - * @returns the paused Goal view. - */ - @Remote('pause') - remoteExportPause(agent: Agent, ref: GoalRef): GoalView { - return this.pause(agent, ref) - } - - /** - * Resume one Goal through the remote boundary. - * @param agent - exact live Agent resolved from the wire identity. - * @param ref - expected current revision. - * @returns the resumed Goal view. - */ - @Remote('resume') - remoteExportResume(agent: Agent, ref: GoalRef): GoalView { - return this.resume(agent, ref) - } - - /** - * Complete one Goal through the remote boundary. - * @param agent - exact live Agent resolved from the wire identity. - * @param ref - expected current revision. - * @returns the completed Goal view. - */ - @Remote('complete') - remoteExportComplete(agent: Agent, ref: GoalRef): GoalView { - return this.complete(agent, ref) - } - - /** - * Clear one terminal Goal through the remote boundary. - * @param agent - exact live Agent resolved from the wire identity. - * @param ref - expected current revision. - * @returns the committed clear revision. - */ - @Remote('clear') - remoteExportClear(agent: Agent, ref: GoalRef): GoalRef { - return this.clear(agent, ref) - } } export default GoalService diff --git a/packages/goal/goal/tests/goal.spec.ts b/packages/goal/goal/tests/goal.spec.ts index 2dd5885cc7..3c642d2a8c 100644 --- a/packages/goal/goal/tests/goal.spec.ts +++ b/packages/goal/goal/tests/goal.spec.ts @@ -245,14 +245,14 @@ describe('GoalService creation and replay', () => { }) describe('GoalService mutations', () => { - it('exposes the supported mutation sequence through Remote wrappers', async () => { + it('adapts Remote creation and reuses business methods for later mutations', async () => { const { ctx, agent } = await harness() const created = ctx.goals.remoteExportCreate(agent, { objective: 'remote lifecycle' }) - const edited = ctx.goals.remoteExportEdit(agent, created.ref, { objective: 'edited remotely' }) - const paused = ctx.goals.remoteExportPause(agent, edited) - const resumed = ctx.goals.remoteExportResume(agent, paused) - const completed = ctx.goals.remoteExportComplete(agent, resumed) - const cleared = ctx.goals.remoteExportClear(agent, completed) + const edited = ctx.goals.edit(agent, created.ref, { objective: 'edited remotely' }) + const paused = ctx.goals.pause(agent, edited) + const resumed = ctx.goals.resume(agent, paused) + const completed = ctx.goals.complete(agent, resumed) + const cleared = ctx.goals.clear(agent, completed) expect(edited).toMatchObject({ objective: 'edited remotely', revision: 2 }) expect(paused).toMatchObject({ phase: 'paused', revision: 3 }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d9a4453a61..79d38a43cd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1617,12 +1617,12 @@ importers: packages/client/ui-goal: devDependencies: - '@deepseek-ai/dsh-client-connection': - specifier: workspace:^ - version: link:../connection '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale + '@deepseek-ai/dsh-client-remotes': + specifier: workspace:^ + version: link:../remotes '@deepseek-ai/dsh-client-runtime': specifier: workspace:^ version: link:../runtime From 2e1f9a5ceaf1c801f018791c326ea29fe42caf3d Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 7 Aug 2026 11:05:55 +0800 Subject: [PATCH 31/63] fix(typert): address remote gateway review --- ...08-02-typert-remote-method-calls.i18n.yaml | 4 +- .../2026-08-02-typert-remote-method-calls.md | 2 +- ...026-08-02-typert-remote-method-calls.zh.md | 2 +- packages/host/api-gateway/src/client/index.ts | 51 +++++++--- .../host/api-gateway/tests/client.spec.ts | 18 ++++ packages/typert/generator/package.json | 1 + packages/typert/generator/src/analyzer.ts | 39 ++++---- packages/typert/generator/src/emitter.ts | 9 +- .../typert/generator/src/tsdown-plugin.ts | 11 ++- packages/typert/generator/src/workspace.ts | 20 ++-- .../fixtures/remote-model/type-meta.d.ts | 8 +- .../generator/tests/remote-model.spec.ts | 97 ++++++++++++++++++- .../generator/tests/tsdown-plugin.spec.ts | 33 ++++++- packages/typert/generator/tsconfig.json | 3 + packages/typert/registry/src/service.ts | 6 +- packages/typert/registry/tests/typert.spec.ts | 8 ++ packages/typert/type-meta/src/index.ts | 15 ++- .../typert/type-meta/tests/type-meta.spec.ts | 3 + pnpm-lock.yaml | 3 + 19 files changed, 275 insertions(+), 58 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml index 1a59abbb43..2400e39519 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md -2026-08-02-typert-remote-method-calls.md: c810a221a23549f3e17e25bd40fcc1fc0f9ec868 -2026-08-02-typert-remote-method-calls.zh.md: 38e3ca286dad98665269697f49fba731346e665e +2026-08-02-typert-remote-method-calls.md: 13b407d580c6042a71234e55cdb61225910f0e48 +2026-08-02-typert-remote-method-calls.zh.md: 434cf4765d2206f3c6f99b67c156b9508d70f313 diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md index c810a221a2..13b407d580 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md @@ -345,7 +345,7 @@ SRC supports local source startup. The `WeakMap` records created by `@Remote` an For example, `@Remote('create') remoteExportCreate(agent, request, signal)` resolves to the external method `create`, implementation member `remoteExportCreate`, two top-level business parameters, and one cancellation injection point. Lookup registration rewrites `agent` to the wire field `agentId`, `request` is passed as a same-named JSON parameter, and the final `signal` stays outside the payload. SRC does not start a `ts.Program`, use a preload or loader hook, generate or rewrite source, or inspect the internal structure of an ordinary JSON object. -A signature that SRC cannot resolve unambiguously fails when the Service mounts. It does not guess at object destructuring, ambiguity caused by default parameters, rest parameters, nested lookups, or complex types. +A signature that SRC cannot resolve unambiguously fails on the first invocation that resolves its descriptor; Service mounting records only the decorator marker and does not inspect the JavaScript signature. SRC does not guess at object destructuring, ambiguity caused by default parameters, rest parameters, nested lookups, or complex types. LIB supports CI, releases, and the prerequisite Web build. TypeRT scans the complete Host project and checks Remote decorators, explicit bindings, service keys, endpoint conflicts, lookup/Context declarations, public-symbol reachability, JSON codecs, result codecs, and that a reserved final `signal` parameter has the global `AbortSignal` type, then generates strict descriptors. diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md index 38e3ca286d..434cf4765d 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md @@ -345,7 +345,7 @@ SRC 面向本地源码启动。`@Remote` 和 `@RemoteContext()` 的 WeakMap 记 例如 `@Remote('create') remoteExportCreate(agent, request, signal)` 解析为外部方法 `create`、实现成员 `remoteExportCreate`、两个顶层业务参数和一个取消注入点;lookup 注册把 `agent` 改写为 wire 字段 `agentId`,`request` 按同名 JSON 参数传递,最后一个 `signal` 则留在 payload 之外。SRC 不启动 `ts.Program`,不使用 preload、loader hook、源码生成或模块改写,也不检查普通 JSON 对象的内部结构。 -SRC 无法明确解析的签名在 Service 挂载时失败。对象解构、默认参数造成的歧义、rest 参数、嵌套 lookup 和复杂类型不做猜测。 +SRC 无法明确解析的签名会在首次调用解析其 descriptor 时失败;Service 挂载只记录 decorator 标记,不检查 JavaScript 签名。SRC 不会猜测对象解构、默认参数造成的歧义、rest 参数、嵌套 lookup 或复杂类型。 LIB 面向 CI、发布和 Web 前置构建。TypeRT 扫描完整 Host project,检查 Remote decorator、显式 binding、service key、endpoint 冲突、lookup/Context 声明、公共符号可达性、JSON codec、结果 codec,以及保留的最后一个 `signal` 参数是否具有全局 `AbortSignal` 类型,并生成严格 descriptor。 diff --git a/packages/host/api-gateway/src/client/index.ts b/packages/host/api-gateway/src/client/index.ts index 292df54152..3fc8389079 100644 --- a/packages/host/api-gateway/src/client/index.ts +++ b/packages/host/api-gateway/src/client/index.ts @@ -134,8 +134,11 @@ class ClientApiService extends Service implements ClientApi { const record = this.scoped.get(namespace) if (record !== undefined) { for (const method of methods) record.service.assertMethodAvailable(method) - } else if (this.ownerCtx.reflect.props[namespace] !== undefined) { - throw new Error(`client api: scoped namespace ${JSON.stringify(namespace)} conflicts with an existing Context property`) + } else { + for (const method of methods) ScopedRemoteNamespace.assertMethodAvailable(namespace, method) + if (this.ownerCtx.reflect.props[namespace] !== undefined) { + throw new Error(`client api: scoped namespace ${JSON.stringify(namespace)} conflicts with an existing Context property`) + } } } } @@ -143,11 +146,18 @@ class ClientApiService extends Service implements ClientApi { private install(descriptor: InvocationDescriptor): () => void { const token: MountToken = { active: true, abort: new AbortController() } const installed: (() => void)[] = [] - if (descriptor.invocation.kind === 'direct') { - installed.push(this.installDirect(descriptor, token)) + try { + if (descriptor.invocation.kind === 'direct') { + installed.push(this.installDirect(descriptor, token)) + } + const projection = scopedProjection(descriptor) + if (projection !== undefined) installed.push(this.installScoped(descriptor, projection, token)) + } catch (error) { + token.active = false + for (const dispose of installed.reverse()) dispose() + token.abort.abort() + throw error } - const projection = scopedProjection(descriptor) - if (projection !== undefined) installed.push(this.installScoped(descriptor, projection, token)) return () => { /* v8 ignore next -- Cordis effect disposers are idempotent and invoke this cleanup at most once. */ if (!token.active) return @@ -192,19 +202,19 @@ class ClientApiService extends Service implements ClientApi { ): () => void { let namespace = this.scoped.get(descriptor.namespace) if (namespace === undefined) { - namespace = { - service: new ScopedRemoteNamespace( - this.ownerCtx, - descriptor.namespace, - (current, currentProjection, currentToken, caller, args) => - this.invoke(current, currentProjection, currentToken, caller, args), - ), - tokens: new Map(), - } + const service = new ScopedRemoteNamespace( + this.ownerCtx, + descriptor.namespace, + (current, currentProjection, currentToken, caller, args) => + this.invoke(current, currentProjection, currentToken, caller, args), + ) + service.install(descriptor, projection, token) + namespace = { service, tokens: new Map() } this.scoped.set(descriptor.namespace, namespace) + } else { + namespace.service.install(descriptor, projection, token) } namespace.tokens.set(descriptor.method, token) - namespace.service.install(descriptor, projection, token) return () => { /* v8 ignore next -- duplicate live methods are rejected before installation, so no newer token can replace this one. */ if (namespace.tokens.get(descriptor.method) !== token) return @@ -275,6 +285,12 @@ class ScopedRemoteNamespace extends Service { private readonly ownerCtx: Context private readonly methods = new Set() + static assertMethodAvailable(namespace: string, method: string): void { + if (SCOPED_NAMESPACE_FIELDS.has(method) || method in ScopedRemoteNamespace.prototype) { + throw new Error(`client api: scoped method ${JSON.stringify(`${namespace}/${method}`)} conflicts with its namespace service`) + } + } + constructor( ctx: Context, name: string, @@ -285,6 +301,7 @@ class ScopedRemoteNamespace extends Service { } assertMethodAvailable(method: string): void { + ScopedRemoteNamespace.assertMethodAvailable(this.name, method) if (method in this) { throw new Error(`client api: scoped method ${JSON.stringify(`${this.name}/${method}`)} conflicts with its namespace service`) } @@ -311,6 +328,8 @@ class ScopedRemoteNamespace extends Service { } } +const SCOPED_NAMESPACE_FIELDS = new Set(['ctx', 'invokeRemote', 'methods', 'name', 'ownerCtx']) + function endpointOf(descriptor: Pick): string { return `${descriptor.namespace}/${descriptor.method}` } diff --git a/packages/host/api-gateway/tests/client.spec.ts b/packages/host/api-gateway/tests/client.spec.ts index 3ad00ff0fc..28aa848fcd 100644 --- a/packages/host/api-gateway/tests/client.spec.ts +++ b/packages/host/api-gateway/tests/client.spec.ts @@ -279,6 +279,24 @@ describe('Client TypeRT API', () => { await disposeMultipleScoped() }) + it('rolls back direct projection when scoped installation fails', async () => { + const ctx = await bench(vi.fn()) + const descriptor: InvocationDescriptor = { + ...directDescriptor(), + id: '@fixture/goals#fresh/remove', + namespace: 'fresh', + method: 'remove', + } + + for (const packageName of ['@fixture/first-attempt', '@fixture/second-attempt']) { + expect(() => ctx.api.mount({ package: packageName, descriptors: [descriptor] })) + .toThrow('conflicts with its namespace service') + expect((ctx.api as unknown as Record).fresh).toBeUndefined() + expect(ctx.get('fresh')).toBeUndefined() + expect(ctx.typert.remotes.list()).toEqual([]) + } + }) + it('rejects weak parameter and Context codecs plus malformed scope projections', async () => { const ctx = await bench(vi.fn()) const direct = directDescriptor() diff --git a/packages/typert/generator/package.json b/packages/typert/generator/package.json index 5ffb933214..eed553ebed 100644 --- a/packages/typert/generator/package.json +++ b/packages/typert/generator/package.json @@ -30,6 +30,7 @@ ], "license": "BSD-3-Clause", "dependencies": { + "@deepseek-ai/dsh-type-meta": "workspace:^", "@jridgewell/gen-mapping": "^0.3.13", "typescript": "^6.0.3" }, diff --git a/packages/typert/generator/src/analyzer.ts b/packages/typert/generator/src/analyzer.ts index ecc7d8aa6b..5e26245171 100644 --- a/packages/typert/generator/src/analyzer.ts +++ b/packages/typert/generator/src/analyzer.ts @@ -8,6 +8,7 @@ import { existsSync, readFileSync, realpathSync, writeFileSync } from 'node:fs' import { dirname, extname, join, relative, resolve, sep } from 'node:path' import ts from 'typescript' +import { isTypeRTRemoteSegment } from '@deepseek-ai/dsh-type-meta' import type { CrossFaceLink, DocumentationModel, @@ -1171,8 +1172,8 @@ class FaceAnalyzer { namespace = value } } - if (!isRemoteSegment(service)) this.fail(serviceArgument, 'Gateway service key must be nonempty and must not contain "/"') - if (!isRemoteSegment(namespace)) this.fail(options ?? call, 'Gateway namespace must be nonempty and must not contain "/"') + if (!isRemoteSegment(service)) this.fail(serviceArgument, 'Gateway service key must contain only RPC endpoint segment characters') + if (!isRemoteSegment(namespace)) this.fail(options ?? call, 'Gateway namespace must contain only RPC endpoint segment characters') return { service, namespace, site } } @@ -1196,7 +1197,7 @@ class FaceAnalyzer { if (expression.arguments.length !== 1) this.fail(expression, 'Remote() requires one exported method name') const exportName = stringLiteralValue(expression.arguments[0]) if (exportName === undefined || !isRemoteSegment(exportName)) { - this.fail(expression.arguments[0] ?? expression, 'Remote() name must be a nonempty string literal without "/"') + this.fail(expression.arguments[0] ?? expression, 'Remote() name must be a string literal containing only RPC endpoint segment characters') } marker = { kind: 'direct', exportName } } else if (ts.isCallExpression(expression) @@ -1206,12 +1207,12 @@ class FaceAnalyzer { } const context = stringLiteralValue(expression.arguments[0]) if (context === undefined || !isRemoteSegment(context)) { - this.fail(expression.arguments[0] ?? expression, 'RemoteContext() key must be a nonempty string literal without "/"') + this.fail(expression.arguments[0] ?? expression, 'RemoteContext() 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, 'RemoteContext() name must be a nonempty string literal without "/"') + this.fail(exportArgument, 'RemoteContext() name must be a string literal containing only RPC endpoint segment characters') } marker = { kind: 'context', context, ...exportName === undefined ? {} : { exportName } } } else { @@ -1251,7 +1252,7 @@ class FaceAnalyzer { this.fail(declaration, 'TypeRTLookupMap entries must be required properties') } const key = memberName(declaration.name) - if (!isRemoteSegment(key)) this.fail(declaration.name, 'TypeRTLookupMap key must be nonempty and must not contain "/"') + if (!isRemoteSegment(key)) this.fail(declaration.name, 'TypeRTLookupMap key must contain only RPC endpoint segment characters') if (!ts.isTypeReferenceNode(declaration.type) || !this.isTypeMetaSymbol(declaration.type.typeName, 'TypeRTLookup') || declaration.type.typeArguments?.length !== 2) { @@ -1287,7 +1288,7 @@ class FaceAnalyzer { this.fail(declaration, 'TypeRTContextMap entries must be required properties') } const key = memberName(declaration.name) - if (!isRemoteSegment(key)) this.fail(declaration.name, 'TypeRTContextMap key must be nonempty and must not contain "/"') + if (!isRemoteSegment(key)) this.fail(declaration.name, 'TypeRTContextMap key must contain only RPC endpoint segment characters') if (!ts.isTypeReferenceNode(declaration.type) || !this.isTypeMetaSymbol(declaration.type.typeName, 'TypeRTContext') || declaration.type.typeArguments?.length !== 1) { @@ -1331,16 +1332,6 @@ class FaceAnalyzer { const type = this.convertType(authoredType) const codecType = this.resolvedRemoteCodecType(authoredType) const rootSymbol = this.namedWorkspaceType(authoredType) - if (rootSymbol !== undefined) { - const imported = this.publicRemoteType(rootSymbol, authoredType) - return { - type, - codecType, - typeSymbol: `${imported.specifier}#${imported.name}`, - imports: [imported], - } - } - if (requireNamed) this.fail(authoredType, 'lookup and Context wire types must be named public types') const imports = new Map() const visit = (node: ts.Node): void => { if ((ts.isTypeReferenceNode(node) || ts.isImportTypeNode(node))) { @@ -1355,13 +1346,23 @@ class FaceAnalyzer { && this.registrationForFile(declaration.getSourceFile().fileName) !== undefined) { const imported = this.publicRemoteType(resolved, node) imports.set(imported.symbol, imported) - return } } } ts.forEachChild(node, visit) } visit(authoredType) + if (rootSymbol !== undefined) { + const imported = this.publicRemoteType(rootSymbol, authoredType) + return { + type, + codecType, + typeSymbol: `${imported.specifier}#${imported.name}`, + imports: [...imports.values()].sort((left, right) => + left.specifier.localeCompare(right.specifier) || left.name.localeCompare(right.name)), + } + } + if (requireNamed) this.fail(authoredType, 'lookup and Context wire types must be named public types') return { type, codecType, @@ -2810,7 +2811,7 @@ function stringLiteralValue(node: ts.Node | undefined): string | undefined { } function isRemoteSegment(value: string): boolean { - return value.length > 0 && !value.includes('/') + return isTypeRTRemoteSegment(value) } function expressionName(node: ts.Expression): string | undefined { diff --git a/packages/typert/generator/src/emitter.ts b/packages/typert/generator/src/emitter.ts index c8b9ab4195..bb39959606 100644 --- a/packages/typert/generator/src/emitter.ts +++ b/packages/typert/generator/src/emitter.ts @@ -414,8 +414,9 @@ export class FaceModelEmitter { invocation: InvocationModel, referenceNames: ReadonlyMap, ): void { - const signature = `${invocation.method}: ${this.remoteFunctionType(invocation, referenceNames, false)}` - this.pushMappedRemoteSignature(lines, sourceMap, packageModel, invocation, signature, invocation.method.length) + const key = renderRemotePropertyName(invocation.method) + const signature = `${key}: ${this.remoteFunctionType(invocation, referenceNames, false)}` + this.pushMappedRemoteSignature(lines, sourceMap, packageModel, invocation, signature, key.length) } private pushMappedRemoteSignature( @@ -914,6 +915,10 @@ function safeIdentifier(name: string): string { return `_${normalized}` } +function renderRemotePropertyName(name: string): string { + return /^[$A-Z_a-z][$\w]*$/u.test(name) ? name : quote(name) +} + function quote(value: string): string { return `'${value.replaceAll('\\', '\\\\').replaceAll("'", "\\'").replaceAll('\n', '\\n').replaceAll('\r', '\\r')}'` } diff --git a/packages/typert/generator/src/tsdown-plugin.ts b/packages/typert/generator/src/tsdown-plugin.ts index 10cba60974..eca5ad47d2 100644 --- a/packages/typert/generator/src/tsdown-plugin.ts +++ b/packages/typert/generator/src/tsdown-plugin.ts @@ -6,7 +6,7 @@ * @module @deepseek-ai/dsh-typert-generator/tsdown */ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { dirname, join, resolve } from 'node:path' import ts from 'typescript' import { WorkspaceTypertGenerator } from './workspace.ts' @@ -103,15 +103,24 @@ export function typertPlugin(pluginOptions: TypertPluginOptions = {}): TypertPlu function emitArtifacts(packageDir: string, artifacts: readonly WorkspaceEmitResult[]): void { const output = join(packageDir, 'lib') mkdirSync(output, { recursive: true }) + let emittedRemote = false for (const artifact of artifacts) { writeFileSync(join(output, `typert.${artifact.face}.js`), artifact.js) writeFileSync(join(output, `typert.${artifact.face}.d.ts`), artifact.dts) if (artifact.remote !== undefined) { + emittedRemote = true writeFileSync(join(output, 'typert.remote-client.js'), artifact.remote.js) writeFileSync(join(output, 'typert.remote-client.d.ts'), artifact.remote.dts) writeFileSync(join(output, 'typert.remote-client.d.ts.map'), artifact.remote.dtsMap) } } + if (!emittedRemote && artifacts.some(artifact => artifact.face === 'host')) { + for (const file of [ + 'typert.remote-client.js', + 'typert.remote-client.d.ts', + 'typert.remote-client.d.ts.map', + ]) rmSync(join(output, file), { force: true }) + } } function readManifest(packageDir: string): { name?: string; exports?: unknown } { diff --git a/packages/typert/generator/src/workspace.ts b/packages/typert/generator/src/workspace.ts index c79861a796..6327872166 100644 --- a/packages/typert/generator/src/workspace.ts +++ b/packages/typert/generator/src/workspace.ts @@ -90,7 +90,6 @@ export class WorkspaceTypertGenerator { throw new TypertAnalysisError(`typert(${artifact.face}): ${artifact.package} package files must include ${file}`) } } - if (artifact.remote === undefined) return const remoteExpected = { types: './lib/typert.remote-client.d.ts', default: './lib/typert.remote-client.js', @@ -98,16 +97,25 @@ export class WorkspaceTypertGenerator { const remoteActual = manifest.exports !== null && typeof manifest.exports === 'object' ? (manifest.exports as Record)['./remote'] : undefined + const remoteFiles = [ + 'lib/typert.remote-client.js', + 'lib/typert.remote-client.d.ts', + 'lib/typert.remote-client.d.ts.map', + ] + if (artifact.remote === undefined) { + if (remoteActual !== undefined || remoteFiles.some(file => files.includes(file))) { + throw new TypertAnalysisError( + `typert(host): ${artifact.package} publishes Remote artifacts but has no Remote methods`, + ) + } + return + } if (!sameExport(remoteActual, remoteExpected)) { throw new TypertAnalysisError( `typert(host): ${artifact.package} must export ./remote as ${JSON.stringify(remoteExpected)}`, ) } - for (const file of [ - 'lib/typert.remote-client.js', - 'lib/typert.remote-client.d.ts', - 'lib/typert.remote-client.d.ts.map', - ]) { + for (const file of remoteFiles) { if (!files.includes(file)) { throw new TypertAnalysisError(`typert(host): ${artifact.package} package files must include ${file}`) } diff --git a/packages/typert/generator/tests/fixtures/remote-model/type-meta.d.ts b/packages/typert/generator/tests/fixtures/remote-model/type-meta.d.ts index 91daea98c2..5347a6b77e 100644 --- a/packages/typert/generator/tests/fixtures/remote-model/type-meta.d.ts +++ b/packages/typert/generator/tests/fixtures/remote-model/type-meta.d.ts @@ -50,7 +50,13 @@ declare module '@deepseek-ai/dsh-type-meta' { context: ClassMethodDecoratorContext Result>, ): void - export function RemoteContext(key: Extract): + export function Remote(exportName: string): + ( + method: (this: This, ...args: Args) => Result, + context: ClassMethodDecoratorContext Result>, + ) => void + + export function RemoteContext(key: Extract, exportName?: string): ( 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 268645ca73..4f4f3ea7cb 100644 --- a/packages/typert/generator/tests/remote-model.spec.ts +++ b/packages/typert/generator/tests/remote-model.spec.ts @@ -216,6 +216,91 @@ export type GenericResult = { expect(dispatch?.result.schema.safeParse({ kind: 'ship', value: { cancelled: true } }).success).toBe(false) }) + it('imports public type arguments nested under a named generic boundary', () => { + const root = copyFixture() + editFile(root, 'packages/remote/src/types.ts', source => `${source} + +/** Generic Remote envelope. */ +export interface Box { + readonly value: Value +} + +/** Payload reachable only as a generic argument. */ +export interface BoxPayload { + readonly count: number +} +`) + editFile(root, 'packages/remote/src/index.ts', source => source + .replace( + ' RenameGoalResult,\n', + ' RenameGoalResult,\n Box,\n BoxPayload,\n', + ) + .replace( + ' rename(request: RenameGoalRequest): RenameGoalResult {\n return { renamed: request.title.length > 0 }\n }\n}', + ` rename(request: RenameGoalRequest): RenameGoalResult { + return { renamed: request.title.length > 0 } + } + + @Remote + box(request: Box): Box { + return request + } +}`, + )) + + const [artifact] = new WorkspaceTypertGenerator(root).generate() + expect(artifact?.remote?.dts).toMatch(/import type \{ [^}]*Box[^}]*BoxPayload[^}]* \} from '@fixture\/remote\/types'/) + expect(artifact?.remote?.dts).toContain('box: (request: Box) => Promise>') + assertRemoteConsumerTypechecks(artifact?.remote?.dts, artifact?.remote?.dtsMap, root) + }) + + it('quotes aliased methods in generated namespace interfaces', () => { + const root = copyFixture() + editFile(root, 'packages/remote/src/index.ts', source => source.replace( + ' rename(request: RenameGoalRequest): RenameGoalResult {\n return { renamed: request.title.length > 0 }\n }\n}', + ` rename(request: RenameGoalRequest): RenameGoalResult { + return { renamed: request.title.length > 0 } + } + + @Remote('create-goal') + createAlias(request: CreateGoalRequest): CreateGoalResult { + return { ref: request.title } + } +}`, + )) + + const [artifact] = new WorkspaceTypertGenerator(root).generate() + expect(artifact?.remote?.dts).toContain("'create-goal': (request: CreateGoalRequest) => Promise") + assertRemoteConsumerTypechecks(artifact?.remote?.dts, artifact?.remote?.dtsMap, root) + }) + + 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( + ' @Remote\n async create(', + ` @Remote('${alias}')\n async create(`, + )) + + expect(() => analyzeRemote(root, false)).toThrow(/RPC endpoint segment characters/) + }) + + it('rejects a Remote export after its last Remote method is removed', () => { + const root = copyFixture() + editFile(root, 'packages/remote/src/index.ts', source => source + .replace(' @Remote\n', '') + .replace(" @RemoteContext('agent')\n", '')) + editFile(root, 'packages/remote/src/types.ts', source => `${source} + +/** @typert schema */ +export interface RemainingSchema { + readonly value: string +} +`) + + expect(() => new WorkspaceTypertGenerator(root).generate()) + .toThrow('publishes Remote artifacts but has no Remote methods') + }) + it.each([ { name: 'missing binding', @@ -429,9 +514,9 @@ function remotePackage(root: string): { return packageModel } -function copyFixture(): string { +function copyFixture(sourceRoot = fixtureRoot): string { const root = mkdtempSync(join(tmpdir(), 'dsh-typert-remote-model-')) - cpSync(fixtureRoot, root, { recursive: true }) + cpSync(sourceRoot, root, { recursive: true }) temporaryRoots.push(root) return root } @@ -444,10 +529,14 @@ function editFile(root: string, relativePath: string, edit: (source: string) => writeFileSync(path, result) } -function assertRemoteConsumerTypechecks(dts: string | undefined, dtsMap: string | undefined): void { +function assertRemoteConsumerTypechecks( + dts: string | undefined, + dtsMap: string | undefined, + sourceRoot = fixtureRoot, +): void { if (dts === undefined) throw new Error('Remote fixture emitted no Host-for-Client declaration') if (dtsMap === undefined) throw new Error('Remote fixture emitted no Host-for-Client declaration map') - const consumerRoot = copyFixture() + const consumerRoot = copyFixture(sourceRoot) const declarationPath = join(consumerRoot, 'packages/remote/lib/typert.remote-client.d.ts') const declarationMapPath = `${declarationPath}.map` const consumerPath = join(consumerRoot, 'consumer.ts') diff --git a/packages/typert/generator/tests/tsdown-plugin.spec.ts b/packages/typert/generator/tests/tsdown-plugin.spec.ts index 106b8950ff..556e96045d 100644 --- a/packages/typert/generator/tests/tsdown-plugin.spec.ts +++ b/packages/typert/generator/tests/tsdown-plugin.spec.ts @@ -3,8 +3,9 @@ import { mkdir } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' +import type { WorkspaceEmitResult } from '../src/workspace.ts' -const generated = vi.hoisted(() => vi.fn(() => [ +const generated = vi.hoisted(() => vi.fn<() => WorkspaceEmitResult[]>(() => [ { package: '@deepseek-ai/dsh-tools', packageRoot: 'packages/core/tools', @@ -141,6 +142,36 @@ describe('typertPlugin', () => { .toBe('{"version":3}\n') }) + it('removes stale Remote artifacts from a Host package without Remote output', async () => { + const root = await workspace() + const output = await packageOutput(root, 'tools', { + name: '@deepseek-ai/dsh-tools', + exports: { './typert': './lib/typert.host.js' }, + }) + const packageLib = join(root, 'packages', 'tools', 'lib') + for (const file of [ + 'typert.remote-client.js', + 'typert.remote-client.d.ts', + 'typert.remote-client.d.ts.map', + ]) writeFileSync(join(packageLib, file), 'stale\n') + generated.mockReturnValueOnce([{ + package: '@deepseek-ai/dsh-tools', + packageRoot: 'packages/core/tools', + face: 'host', + exports: [], + js: 'export const host = true\n', + dts: 'export declare const host: true\n', + }]) + + typertPlugin().writeBundle({ dir: output }) + + for (const file of [ + 'typert.remote-client.js', + 'typert.remote-client.d.ts', + 'typert.remote-client.d.ts.map', + ]) expect(existsSync(join(packageLib, file))).toBe(false) + }) + it('emits every explicit workspace contributor once from a host-only prepass', async () => { const root = await workspace() const trigger = await packageOutput(root, 'generator', { name: '@deepseek-ai/dsh-typert-generator' }) diff --git a/packages/typert/generator/tsconfig.json b/packages/typert/generator/tsconfig.json index 9966c8ca8a..311dfa4b6d 100644 --- a/packages/typert/generator/tsconfig.json +++ b/packages/typert/generator/tsconfig.json @@ -16,6 +16,9 @@ }, { "path": "../../support/invariants" + }, + { + "path": "../type-meta" } ] } diff --git a/packages/typert/registry/src/service.ts b/packages/typert/registry/src/service.ts index d04f38cde5..7a097b8635 100644 --- a/packages/typert/registry/src/service.ts +++ b/packages/typert/registry/src/service.ts @@ -7,6 +7,7 @@ import { Context, Service } from 'cordis' import { z } from 'zod' +import { isTypeRTRemoteSegment } from '@deepseek-ai/dsh-type-meta' import type { InvocationDescriptor, TypeRTClientContextBinder, @@ -600,8 +601,9 @@ function validateCodec(codec: InvocationDescriptor['result'], subject: string): } function validateWireName(subject: string, value: string): void { - validateSegment(subject, value) - if (value.includes('/')) throw new Error(`typert: invalid ${subject} "${value}" — must not contain "/"`) + if (!isTypeRTRemoteSegment(value)) { + throw new Error(`typert: invalid ${subject} "${value}" — must contain only RPC endpoint segment characters`) + } } function validateSegment(subject: string, value: string): void { diff --git a/packages/typert/registry/tests/typert.spec.ts b/packages/typert/registry/tests/typert.spec.ts index 5603ce8954..6661cbeeb4 100644 --- a/packages/typert/registry/tests/typert.spec.ts +++ b/packages/typert/registry/tests/typert.spec.ts @@ -247,6 +247,14 @@ describe('TypertRegistry', () => { })).toThrow('endpoint "goals/create" is already registered') }) + it.each(['create#v2', 'create goal'])('rejects untransportable invocation method %s', async (method) => { + const ctx = await makeCtx() + expect(() => ctx.typert.remotes.register({ + package: '@fixture/invalid-endpoint', + descriptors: [{ ...invocation(), method }], + })).toThrow('RPC endpoint segment characters') + }) + it('mounts Remote contributions in the calling fiber and withdraws them exactly', async () => { const ctx = await makeCtx() const descriptor = invocation() diff --git a/packages/typert/type-meta/src/index.ts b/packages/typert/type-meta/src/index.ts index 4d4457b5be..67a4169f96 100644 --- a/packages/typert/type-meta/src/index.ts +++ b/packages/typert/type-meta/src/index.ts @@ -7,6 +7,17 @@ import { Service, type Context } from 'cordis' import type { TypeRTContextMap } from './types.ts' +const TYPERT_REMOTE_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/ + +/** + * Test one generated Remote name against the Connection endpoint grammar. + * @param value - namespace, method, lookup, or Context segment. + * @returns whether the value can cross the shared RPC carrier unchanged. + */ +export function isTypeRTRemoteSegment(value: string): boolean { + return TYPERT_REMOTE_SEGMENT_PATTERN.test(value) +} + export type { InvocationDescriptor, InvocationParameterDescriptor, @@ -236,7 +247,7 @@ function sameInvocation(left: RemoteInvocationMarker, right: RemoteInvocationMar } function validateName(subject: string, value: string): void { - if (value.length === 0 || value.includes('/')) { - throw new TypeError(`type-meta: ${subject} must be nonempty and must not contain "/"`) + if (!isTypeRTRemoteSegment(value)) { + throw new TypeError(`type-meta: ${subject} must contain only RPC endpoint segment characters`) } } diff --git a/packages/typert/type-meta/tests/type-meta.spec.ts b/packages/typert/type-meta/tests/type-meta.spec.ts index 8a2a4372ce..757488024d 100644 --- a/packages/typert/type-meta/tests/type-meta.spec.ts +++ b/packages/typert/type-meta/tests/type-meta.spec.ts @@ -162,6 +162,8 @@ describe('type-meta Remote declarations', () => { const method: (this: object) => void = function (this: object): void {} expect(() => { (Remote as unknown as (value: typeof method) => void)(method) }).toThrow('context is missing') expect(() => Remote('bad/name')).toThrow('export name') + expect(() => Remote('bad#name')).toThrow('export name') + expect(() => Remote('bad name')).toThrow('export name') expect(() => RemoteContext('' as 'metaFixture')).toThrow('Context key') expect(() => RemoteContext('metaFixture', 'bad/name')).toThrow('export name') @@ -203,6 +205,7 @@ describe('type-meta Remote declarations', () => { it('rejects ambiguous binding names', () => { expect(() => bindTypeRTGateway({}, '')).toThrow('service key') expect(() => bindTypeRTGateway({}, 'goals', { namespace: 'api/goals' })).toThrow('namespace') + expect(() => bindTypeRTGateway({}, 'goals', { namespace: 'api goals' })).toThrow('namespace') }) }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 79d38a43cd..d6889328eb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6172,6 +6172,9 @@ importers: packages/typert/generator: dependencies: + '@deepseek-ai/dsh-type-meta': + specifier: workspace:^ + version: link:../type-meta '@jridgewell/gen-mapping': specifier: ^0.3.13 version: 0.3.13 From 0ad58850155b9eab5fd1f6c000b09f28f1bb0f03 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 7 Aug 2026 11:13:19 +0800 Subject: [PATCH 32/63] fix(typert): keep generator bootstrap self-contained --- packages/typert/generator/package.json | 1 - packages/typert/generator/src/analyzer.ts | 3 +-- packages/typert/generator/tsconfig.json | 3 --- pnpm-lock.yaml | 3 --- 4 files changed, 1 insertion(+), 9 deletions(-) diff --git a/packages/typert/generator/package.json b/packages/typert/generator/package.json index eed553ebed..5ffb933214 100644 --- a/packages/typert/generator/package.json +++ b/packages/typert/generator/package.json @@ -30,7 +30,6 @@ ], "license": "BSD-3-Clause", "dependencies": { - "@deepseek-ai/dsh-type-meta": "workspace:^", "@jridgewell/gen-mapping": "^0.3.13", "typescript": "^6.0.3" }, diff --git a/packages/typert/generator/src/analyzer.ts b/packages/typert/generator/src/analyzer.ts index 5e26245171..16c30e8bc5 100644 --- a/packages/typert/generator/src/analyzer.ts +++ b/packages/typert/generator/src/analyzer.ts @@ -8,7 +8,6 @@ import { existsSync, readFileSync, realpathSync, writeFileSync } from 'node:fs' import { dirname, extname, join, relative, resolve, sep } from 'node:path' import ts from 'typescript' -import { isTypeRTRemoteSegment } from '@deepseek-ai/dsh-type-meta' import type { CrossFaceLink, DocumentationModel, @@ -2811,7 +2810,7 @@ function stringLiteralValue(node: ts.Node | undefined): string | undefined { } function isRemoteSegment(value: string): boolean { - return isTypeRTRemoteSegment(value) + return /^[A-Za-z0-9_$.-]+$/.test(value) } function expressionName(node: ts.Expression): string | undefined { diff --git a/packages/typert/generator/tsconfig.json b/packages/typert/generator/tsconfig.json index 311dfa4b6d..9966c8ca8a 100644 --- a/packages/typert/generator/tsconfig.json +++ b/packages/typert/generator/tsconfig.json @@ -16,9 +16,6 @@ }, { "path": "../../support/invariants" - }, - { - "path": "../type-meta" } ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d6889328eb..79d38a43cd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6172,9 +6172,6 @@ importers: packages/typert/generator: dependencies: - '@deepseek-ai/dsh-type-meta': - specifier: workspace:^ - version: link:../type-meta '@jridgewell/gen-mapping': specifier: ^0.3.13 version: 0.3.13 From 5ea631194910153c5f89f3eb7a100c012ff72288 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 7 Aug 2026 11:22:52 +0800 Subject: [PATCH 33/63] test(api-gateway): cover client mount rollback --- .../host/api-gateway/tests/client.spec.ts | 48 ++++++++++++++----- 1 file changed, 36 insertions(+), 12 deletions(-) diff --git a/packages/host/api-gateway/tests/client.spec.ts b/packages/host/api-gateway/tests/client.spec.ts index 28aa848fcd..5c2c427605 100644 --- a/packages/host/api-gateway/tests/client.spec.ts +++ b/packages/host/api-gateway/tests/client.spec.ts @@ -258,6 +258,13 @@ describe('Client TypeRT API', () => { package: '@fixture/service-method-conflict', descriptors: [{ ...context, id: '@fixture/goals#goals/remove', method: 'remove' }], })).toThrow('conflicts with its namespace service') + const scopedService = ctx.get('goals') as unknown as object + Object.defineProperty(scopedService, 'custom', { configurable: true, value: () => undefined }) + expect(() => ctx.api.mount({ + package: '@fixture/service-own-property-conflict', + descriptors: [{ ...direct, id: '@fixture/goals#goals/custom', method: 'custom' }], + })).toThrow('conflicts with its namespace service') + Reflect.deleteProperty(scopedService, 'custom') await disposeScoped() expect(() => ctx.api.mount({ @@ -281,20 +288,37 @@ describe('Client TypeRT API', () => { it('rolls back direct projection when scoped installation fails', async () => { const ctx = await bench(vi.fn()) - const descriptor: InvocationDescriptor = { - ...directDescriptor(), - id: '@fixture/goals#fresh/remove', - namespace: 'fresh', - method: 'remove', + const disposeScoped = ctx.api.mount({ + package: '@fixture/scoped-base', + descriptors: [contextDescriptor()], + }) + const defineProperty = Object.defineProperty + let createDefinitions = 0 + const definePropertySpy = vi.spyOn(Object, 'defineProperty').mockImplementation((target, key, attributes) => { + // The direct projection defines `create` first; fail the following scoped projection. + if (key === 'create' && ++createDefinitions === 2) throw new Error('simulated scoped installation failure') + return defineProperty(target, key, attributes) + }) + + try { + expect(() => ctx.api.mount({ + package: '@fixture/failing-install', + descriptors: [directDescriptor()], + })).toThrow('simulated scoped installation failure') + } finally { + definePropertySpy.mockRestore() } - for (const packageName of ['@fixture/first-attempt', '@fixture/second-attempt']) { - expect(() => ctx.api.mount({ package: packageName, descriptors: [descriptor] })) - .toThrow('conflicts with its namespace service') - expect((ctx.api as unknown as Record).fresh).toBeUndefined() - expect(ctx.get('fresh')).toBeUndefined() - expect(ctx.typert.remotes.list()).toEqual([]) - } + expect((ctx.api as unknown as Record).goals).toBeUndefined() + expect(ctx.get('goals') !== undefined).toBe(true) + expect(ctx.typert.remotes.list()).toHaveLength(1) + + const disposeRetry = ctx.api.mount({ + package: '@fixture/retry', + descriptors: [directDescriptor()], + }) + await disposeRetry() + await disposeScoped() }) it('rejects weak parameter and Context codecs plus malformed scope projections', async () => { From 56af59b41d8a8459d36a8d3e18aa6b455cbf03be Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 7 Aug 2026 11:52:24 +0800 Subject: [PATCH 34/63] fix(typert): keep client registry bundle pure --- packages/typert/registry/src/service.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/typert/registry/src/service.ts b/packages/typert/registry/src/service.ts index 7a097b8635..2f85138edd 100644 --- a/packages/typert/registry/src/service.ts +++ b/packages/typert/registry/src/service.ts @@ -7,7 +7,6 @@ import { Context, Service } from 'cordis' import { z } from 'zod' -import { isTypeRTRemoteSegment } from '@deepseek-ai/dsh-type-meta' import type { InvocationDescriptor, TypeRTClientContextBinder, @@ -601,7 +600,7 @@ function validateCodec(codec: InvocationDescriptor['result'], subject: string): } function validateWireName(subject: string, value: string): void { - if (!isTypeRTRemoteSegment(value)) { + if (!/^[A-Za-z0-9_$.-]+$/.test(value)) { throw new Error(`typert: invalid ${subject} "${value}" — must contain only RPC endpoint segment characters`) } } From eca3090dfaecfd67ff79e4679f1cf90fa295eccb Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 7 Aug 2026 12:11:32 +0800 Subject: [PATCH 35/63] fix: docs --- docs/config-catalog.md | 2 -- docs/module-graph.md | 4 ++-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 41c9c98515..77baad512d 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2565,8 +2565,6 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-timeout-policy` — requires `tools` ([`packages/timeout/timeout-policy/src/index.ts`](../packages/timeout/timeout-policy/src/index.ts)) - `@deepseek-ai/dsh-tool-ask-user` — requires `tools` · `userInteraction` ([`packages/ui/tool-ask-user/src/index.ts`](../packages/ui/tool-ask-user/src/index.ts)) - `@deepseek-ai/dsh-tool-subagent-control` — requires `tools` · `subagents` ([`packages/subagent/tool-subagent-control/src/index.ts`](../packages/subagent/tool-subagent-control/src/index.ts)) -- `@deepseek-ai/dsh-tool-todo` — requires `tools` ([`packages/todo/tool-todo/src/index.ts`](../packages/todo/tool-todo/src/index.ts)) -- `@deepseek-ai/dsh-typert-registry` ([`packages/typert/registry/src/index.ts`](../packages/typert/registry/src/index.ts)) - `@deepseek-ai/dsh-user-interaction` ([`packages/ui/user-interaction/src/index.ts`](../packages/ui/user-interaction/src/index.ts)) - `@deepseek-ai/dsh-workspace` — requires `storageDomain` · `sessionPersistence` ([`packages/workspace/workspace/src/index.ts`](../packages/workspace/workspace/src/index.ts)) diff --git a/docs/module-graph.md b/docs/module-graph.md index f659e61206..ac46e968b3 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -1132,8 +1132,8 @@ flowchart TD pkg_client_ui_command --> pkg_client_ui_slash pkg_client_ui_command --> pkg_client_ui_slots pkg_client_ui_command --> pkg_invariants - pkg_client_ui_goal --> pkg_client_connection pkg_client_ui_goal --> pkg_client_locale + pkg_client_ui_goal --> pkg_client_remotes pkg_client_ui_goal --> pkg_client_runtime pkg_client_ui_goal --> pkg_client_ui_conversation pkg_client_ui_goal --> pkg_client_ui_primitives @@ -1383,7 +1383,7 @@ flowchart TD | [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | | [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | | [`client-ui-command`](../packages/client/ui-command) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | +| [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`client-locale`](../packages/client/locale), [`client-remotes`](../packages/client/remotes), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | | [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`plan-mode`](../packages/plan/plan-mode) | | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) | | [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | From e28ac506edf9ea5c5c72bdba20fea1e16f708728 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 7 Aug 2026 12:21:41 +0800 Subject: [PATCH 36/63] perf(api-gateway): cache SRC endpoint claims --- ...08-02-typert-remote-method-calls.i18n.yaml | 4 +- .../2026-08-02-typert-remote-method-calls.md | 4 +- ...026-08-02-typert-remote-method-calls.zh.md | 4 +- docs/event-producer-consumer.md | 1 + packages/host/api-gateway/src/index.ts | 21 ++++++-- .../host/api-gateway/tests/gateway.spec.ts | 49 +++++++++++++++++++ 6 files changed, 73 insertions(+), 10 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml index 2400e39519..02e6c428ff 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md -2026-08-02-typert-remote-method-calls.md: 13b407d580c6042a71234e55cdb61225910f0e48 -2026-08-02-typert-remote-method-calls.zh.md: 434cf4765d2206f3c6f99b67c156b9508d70f313 +2026-08-02-typert-remote-method-calls.md: ddc93b4fc672f320b4e3dc3e11586d92604e6aa4 +2026-08-02-typert-remote-method-calls.zh.md: 808c7d54bff19d9a4e9cf924769df1d405d997b5 diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md index 13b407d580..ddc93b4fc6 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md @@ -158,7 +158,7 @@ ctx.typert.lookups wire ID 到 Host 活对象的 provider ctx.typert.contexts Host Context resolver 与 Client Context binder ``` -Every registration returns a disposer owned by the caller's Cordis fiber. Client contribution mounting registers the descriptor set and concrete methods as one owned operation. The Host Gateway resolves descriptors, Services, and providers from current state for every claim and invocation instead of retaining endpoint registrations. Removing a strict definition, Service, or provider therefore makes the corresponding call unavailable without leaving a stale live object. +Every registration returns a disposer owned by the caller's Cordis fiber. Client contribution mounting registers the descriptor set and concrete methods as one owned operation. The Host Gateway caches only the set of SRC-owned endpoint names and discards it whenever the Cordis Service set changes; it retains no descriptor, Service, or provider. Invocation resolves all live objects from current state, so removing a strict definition, Service, or provider makes the corresponding call unavailable without leaving a stale live object. The lookup registry retains the stable wire declaration after its live resolver unloads. SRC parsing continues to classify the parameter as a lookup, while invocation fails with `lookup-unavailable`; it never reclassifies the incoming ID as an ordinary JSON business object. Re-registering the same key with different parameter, wire, or canonical type symbols fails for the lifetime of that TypeRT Service. @@ -355,7 +355,7 @@ CI and releases use LIB. Moving all repository coverage to LIB is separate follo ## Host Gateway resolution -The Host Gateway registers one `/api` interceptor with Connection and does not maintain a second endpoint registry. Its ownership matcher resolves each endpoint from the current TypeRT local registry or scans current Cordis Services for a matching `typertGateway` binding and SRC Remote marker. TypeRT definitions and business Services may therefore arrive in either order. +The Host Gateway registers one `/api` interceptor with Connection and does not maintain a second endpoint registry. Its ownership matcher checks the current TypeRT local registry first, then consults an invalidation-aware set populated by scanning current Cordis Services for `typertGateway` bindings and SRC Remote markers. A Cordis Service change discards the set, so TypeRT definitions and business Services may arrive in either order without making legacy `/api` traffic rescan every Service on each request or letting arbitrary request paths grow the cache. Invocation resolves the descriptor, receiver, lookup providers, and Context provider again from current state. A current strict descriptor takes precedence over SRC. After a strict endpoint has appeared, `TypeRTLocalRegistry.hasSeen()` keeps it owned when that descriptor is withdrawn and forbids SRC fallback for the remainder of the registry lifetime; re-registering the strict descriptor restores calls. Removing a Service or provider makes invocation fail explicitly, and the Gateway neither retains invalid objects nor invokes a method with a raw lookup ID. diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md index 434cf4765d..808c7d54bf 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md @@ -158,7 +158,7 @@ ctx.typert.lookups wire ID 到 Host 活对象的 provider ctx.typert.contexts Host Context resolver 与 Client Context binder ``` -每次注册都返回由调用方 Cordis fiber 持有的 disposer。挂载 Client contribution 时,descriptor 集与具体方法会作为一项有明确所有者的操作统一注册。Host Gateway 每次认领和调用时都从当前状态解析 descriptor、Service 与提供方,不保留 endpoint 注册。因此移除 strict definition、Service 或提供方会使相应调用不可用,且不会留下陈旧的活对象。 +每次注册都返回由调用方 Cordis fiber 持有的 disposer。挂载 Client contribution 时,descriptor 集与具体方法会作为一项有明确所有者的操作统一注册。Host Gateway 只缓存 SRC 所认领的 endpoint 名称集合,并在 Cordis Service 集合发生变化时整体丢弃该集合;它不保留 descriptor、Service 或提供方。调用时会从当前状态解析所有活对象,因此移除 strict definition、Service 或提供方会使相应调用不可用,且不会留下陈旧的活对象。 lookup 注册表会在活 resolver 卸载后保留稳定的 wire 声明。SRC 解析仍会把该参数归类为 lookup,而调用会以 `lookup-unavailable` 失败;系统绝不会把传入的 ID 重新归类为普通 JSON 业务对象。在同一个 TypeRT Service 的生命周期内,以不同参数、wire 或规范类型 symbol 重新注册同一 key 会直接失败。 @@ -355,7 +355,7 @@ CI 和发布运行 LIB。全仓 coverage 全部切换到 LIB 是独立后续工 ## Host Gateway 解析 -Host Gateway 向 Connection 注册一个 `/api` interceptor,不维护第二份 endpoint 注册表。ownership matcher 会从当前 TypeRT local 注册表解析各 endpoint,或扫描当前 Cordis Service,查找匹配的 `typertGateway` binding 与 SRC Remote 标记。因此 TypeRT definition 与业务 Service 可以按任意顺序到达。 +Host Gateway 向 Connection 注册一个 `/api` interceptor,不维护第二份 endpoint 注册表。ownership matcher 会先检查当前 TypeRT local 注册表,再查询一份可失效的集合;该集合通过扫描当前 Cordis Service 中的 `typertGateway` binding 与 SRC Remote 标记生成。Cordis Service 发生变化时会整体丢弃该集合,因此 TypeRT definition 与业务 Service 可以按任意顺序到达,同时既不会让旧 API Proxy 的 `/api` 流量在每次请求时重新扫描所有 Service,也不会因任意请求路径而扩大缓存。 每次调用都会重新从当前状态解析 descriptor、receiver、lookup 提供方与 Context 提供方。当前 strict descriptor 优先于 SRC。strict endpoint 一旦出现,即使随后撤回对应 descriptor,`TypeRTLocalRegistry.hasSeen()` 仍会在注册表剩余生命周期内保持对它的认领并禁止回退 SRC;重新注册 strict descriptor 即可恢复调用。移除 Service 或提供方会让调用明确失败;Gateway 既不保留失效对象,也不会以原始 lookup ID 调用方法。 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index f5b3a0b99a..34f4d37ffd 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -66,6 +66,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `credentials/changed` | `runtime` (`emit`) | `ui-models` | | `internal/dispatch` | - | [`commands`](../packages/ui/commands), [`compact`](../packages/compact/compact), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`plan-mode`](../packages/plan/plan-mode), [`pty-local`](../packages/pty/pty-local), `runtime`, [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`subagent`](../packages/subagent/subagent), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`workflow`](../packages/workflow/workflow) | | `internal/plugin` | - | `hmr`, `loader`, `modules`, `webserver` | +| `internal/service` | - | `api-gateway` | | `internal/status` | - | [`agent`](../packages/core/agent) | | `locale/change` | `locale` (`emit`) | `locale` | | `models/changed` | `runtime` (`emit`) | `ui-models` | diff --git a/packages/host/api-gateway/src/index.ts b/packages/host/api-gateway/src/index.ts index c4a61cef8d..7dd2410873 100644 --- a/packages/host/api-gateway/src/index.ts +++ b/packages/host/api-gateway/src/index.ts @@ -76,12 +76,17 @@ export class TypertGatewayError extends Error { export class TypertGatewayService extends Service implements TypertGateway { static inject = ['typert'] + private srcClaims: ReadonlySet | undefined + /** * Register the Gateway against the active TypeRT registry. * @param ctx - owning Host Context with TypeRT registry access. */ constructor(ctx: Context) { super(ctx, 'typertGateway') + ctx.on('internal/service', () => { + this.srcClaims = undefined + }) ctx.inject(['connection'], (connectionCtx) => { connectionCtx.connection.rpc.intercept( '/api', @@ -95,18 +100,26 @@ export class TypertGatewayService extends Service implements TypertGateway { private claimsEndpoint(endpoint: string): boolean { const segments = endpoint.split('/') if (segments.length !== 2 || segments[0] === '' || segments[1] === '') return false - const [namespace, method] = segments as [string, string] if (this.ctx.typert.local.get(endpoint) !== undefined || this.ctx.typert.local.hasSeen(endpoint)) return true + this.srcClaims ??= this.collectSrcClaims() + return this.srcClaims.has(endpoint) + } + + private collectSrcClaims(): ReadonlySet { + const claims = new Set() for (const [serviceKey, definition] of Object.entries(this.ctx.reflect.props)) { if (definition.type !== 'service') continue const receiver = this.ctx.get(serviceKey) as unknown if (!isObject(receiver)) continue const original = originalOf(receiver) const binding = Reflect.get(original, 'typertGateway') as unknown - if (!isObject(binding) || Reflect.get(binding, 'namespace') !== namespace) continue - if (remoteMethods(original).some(candidate => (candidate.exportName ?? candidate.method) === method)) return true + if (!isObject(binding) || typeof Reflect.get(binding, 'namespace') !== 'string') continue + const namespace = Reflect.get(binding, 'namespace') as string + for (const candidate of remoteMethods(original)) { + claims.add(endpointOf(namespace, candidate.exportName ?? candidate.method)) + } } - return false + return claims } /** diff --git a/packages/host/api-gateway/tests/gateway.spec.ts b/packages/host/api-gateway/tests/gateway.spec.ts index 6558a7ca47..aebe23da57 100644 --- a/packages/host/api-gateway/tests/gateway.spec.ts +++ b/packages/host/api-gateway/tests/gateway.spec.ts @@ -314,6 +314,25 @@ class NoBindingService extends Service { } } +class ObservedClaimService extends Service { + private readonly binding = bindTypeRTGateway(this, 'observedClaim', { namespace: 'observed-claim' }) + bindingReads = 0 + + constructor(ctx: Context) { + super(ctx, 'observedClaim') + } + + get typertGateway() { + this.bindingReads += 1 + return this.binding + } + + @Remote + run(value: string): string { + return value + } +} + class MissingMethodService extends Service { readonly typertGateway = bindTypeRTGateway(this, 'missingMethod', { namespace: 'missing-method' }) @@ -949,6 +968,36 @@ describe('TypertGatewayService', () => { expect(connection.handler).toBeUndefined() }) + it('caches SRC ownership until the Cordis Service set changes', async () => { + const ctx = new Context() + await ctx.plugin(TypertRegistry) + await ctx.plugin(FakeConnectionService) + await ctx.plugin(TypertGatewayService) + const observedFiber = ctx.plugin(ObservedClaimService) + await observedFiber + const connection = rawConnection(ctx) + const observed = ctx.get('observedClaim') as unknown as ObservedClaimService & { + [symbols.original]?: ObservedClaimService + } + const service = observed[symbols.original] ?? observed + + expect(connection.matches?.('legacy/list')).toBe(false) + expect(connection.matches?.('legacy/list')).toBe(false) + expect(service.bindingReads).toBe(1) + expect(connection.matches?.('observed-claim/run')).toBe(true) + expect(connection.matches?.('observed-claim/run')).toBe(true) + expect(service.bindingReads).toBe(1) + + const unrelatedFiber = ctx.plugin(NoBindingService) + await unrelatedFiber + expect(connection.matches?.('legacy/list')).toBe(false) + expect(service.bindingReads).toBe(2) + + await observedFiber.dispose() + expect(connection.matches?.('observed-claim/run')).toBe(false) + await unrelatedFiber.dispose() + }) + it('dispatches claimed invocations through /api and leaves unclaimed endpoints to its fallback', async () => { const ctx = new Context().extend({ fixtureScope: 'http-caller' }) const routes: WebRoute[] = [] From 286a8b168e1e4e536c75800d0b9d2523213f7991 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 7 Aug 2026 12:53:16 +0800 Subject: [PATCH 37/63] fix(connection): route fixture calls through remote semantics --- .../client/connection/src/client/fixture.ts | 266 +++++++++++++----- .../client/connection/src/client/index.ts | 7 +- packages/client/connection/src/client/rpc.ts | 12 - .../connection/tests/client-apply.spec.ts | 32 ++- 4 files changed, 234 insertions(+), 83 deletions(-) diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index e13c0a19f6..1a9841aece 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -36,6 +36,7 @@ import type { import type { RequestPayload, ResponseValue, RpcMethodMap } from '@deepseek-ai/dsh-host-apiproxy/api' import { AbstractApiClient, RpcId, SESSION_SEARCH_RESULT_LIMIT } from './api.ts' import { randomUuid } from './random-uuid.ts' +import type { ClientConnectionRpc } from '../rpc.ts' /** The fake carrier mints like a real one (business code never mints). */ function rpcRequest

(payload: P): RpcRequest

{ @@ -1329,6 +1330,16 @@ class FxInbox implements StreamConn { * @returns an ApiProxy backed entirely by in-memory state — no host process, no network. */ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { + return createFixtureWorld(options).api +} + +interface FixtureWorld { + readonly api: ApiProxy + readonly rpc: ClientConnectionRpc +} + +/** Build the fixture's legacy API and Remote RPC faces over one state graph. */ +function createFixtureWorld(options: FixtureOptions): FixtureWorld { // The resident fixture sessions all carry history, so none of them is blank. const sessions: SessionSummary[] = options.empty ? [] : [ { sessionId: sid('fx-alpha'), updatedAt: Date.now(), running: true, blank: false, cwd: '/tmp/fixture' }, @@ -1507,31 +1518,136 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { return backscanGoal(log) as FxGoalProjection } - /** Shared CAS mutation path of the goal verbs (undefined next = invalid transition). */ - const fxMutateGoal = ( - request: RpcRequest<{ sessionId: SessionId; ref: { id: string; revision: number } }>, - ref: { id: string; revision: number }, + type FxGoalRef = { id: string; revision: number } + type FxGoalView = FxGoalProjection['goal'] & { + roundsStarted: number + createdAt: number + updatedAt: number + activation: 'armed' | 'disarmed' + } + + const goalFailure = (message: string): RpcResult => ({ + ok: false, + error: { code: 'internal', message, details: {} }, + }) + + const requireGoalSession = (id: SessionId): RpcResult | undefined => ( + summaryOf(id) === undefined + ? { ok: false, error: { code: 'session-not-found', message: `no session ${id}`, details: { sessionId: id } } } + : undefined + ) + + const goalView = (projection: FxGoalProjection): FxGoalView => ({ + ...projection.goal, + roundsStarted: projection.roundsStarted, + createdAt: projection.createdAt, + updatedAt: projection.updatedAt, + activation: projection.goal.phase === 'active' ? 'armed' : 'disarmed', + }) + + /** Canonical fixture implementation of the generated Goal Remote contract. */ + const goalRemotes = { + create(id: SessionId, request: { objective: string; maxGoalRounds?: number }): RpcResult<{ ref: FxGoalRef }> { + const missing = requireGoalSession(id) + if (missing !== undefined) return missing + const current = backscanGoal(logOf(id)) + if (current !== null && current.goal.phase !== 'complete') { + return goalFailure(`goal "${current.goal.id}" already exists`) + } + const now = Date.now() + const projection = appendGoalChange(id, { + kind: 'goal/change', version: 1, operation: 'create', + goal: { + id: `fx-goal-${logOf(id).length}`, + revision: 1, + objective: request.objective, + phase: 'active', + maxGoalRounds: request.maxGoalRounds ?? 256, + }, + roundsStarted: 0, createdAt: now, updatedAt: now, + }) + return { ok: true, value: { ref: { id: projection.goal.id, revision: projection.goal.revision } } } + }, + edit(id: SessionId, ref: FxGoalRef, request: { objective?: string; maxGoalRounds?: number }): RpcResult { + return mutateGoal(id, ref, current => ({ + ...current.goal, + revision: current.goal.revision + 1, + ...request.objective === undefined ? {} : { objective: request.objective }, + ...request.maxGoalRounds === undefined ? {} : { maxGoalRounds: request.maxGoalRounds }, + })) + }, + pause(id: SessionId, ref: FxGoalRef): RpcResult { + return mutateGoal(id, ref, current => ( + current.goal.phase === 'active' + ? { ...current.goal, revision: current.goal.revision + 1, phase: 'paused' } + : undefined + )) + }, + resume(id: SessionId, ref: FxGoalRef): RpcResult { + return mutateGoal(id, ref, current => ( + current.goal.phase === 'paused' || current.goal.phase === 'blocked' || current.goal.phase === 'active' + ? { ...current.goal, revision: current.goal.revision + 1, phase: 'active' } + : undefined + )) + }, + complete(id: SessionId, ref: FxGoalRef): RpcResult { + return mutateGoal(id, ref, current => ( + current.goal.phase === 'complete' + ? undefined + : { ...current.goal, revision: current.goal.revision + 1, phase: 'complete' } + )) + }, + clear(id: SessionId, ref: FxGoalRef): RpcResult { + const missing = requireGoalSession(id) + if (missing !== undefined) return missing + const current = backscanGoal(logOf(id)) + if (current === null || current.goal.id !== ref.id || current.goal.revision !== ref.revision) { + return goalFailure('stale or missing goal revision') + } + const tombstone = { id: current.goal.id, revision: current.goal.revision + 1 } + appendGoalChange(id, { + kind: 'goal/change', version: 1, operation: 'clear', cleared: tombstone, clearedAt: Date.now(), + }) + return { ok: true, value: tombstone } + }, + } + + /** Shared CAS mutation path behind the canonical Remote verbs. */ + function mutateGoal( + id: SessionId, + ref: FxGoalRef, next: (current: FxGoalProjection) => FxGoalProjection['goal'] | undefined, - ): Promise> => { - const missing = requireSession(request) + ): RpcResult { + const missing = requireGoalSession(id) if (missing !== undefined) return missing - const id = request.payload.sessionId const current = backscanGoal(logOf(id)) if (current === null || current.goal.id !== ref.id || current.goal.revision !== ref.revision) { - return err(request, { code: 'internal', message: 'stale or missing goal revision', details: { goalCode: 'GOAL_STALE_REVISION' } }) + return goalFailure('stale or missing goal revision') } const goal = next(current) if (goal === undefined) { - return err(request, { code: 'internal', message: `invalid goal transition from "${current.goal.phase}"`, details: { goalCode: 'GOAL_INVALID_TRANSITION' } }) + return goalFailure(`invalid goal transition from "${current.goal.phase}"`) } const projection = appendGoalChange(id, { kind: 'goal/change', version: 1, operation: goal.phase === current.goal.phase ? 'edit' : goal.phase === 'paused' ? 'pause' : goal.phase === 'active' ? 'resume' : 'complete', goal, roundsStarted: current.roundsStarted, createdAt: current.createdAt, updatedAt: Date.now(), }) - return ok(request, { ref: { id: projection.goal.id as never, revision: projection.goal.revision } }) + return { ok: true, value: goalView(projection) } } + const mapGoalResult = (result: RpcResult, map: (value: T) => U): RpcResult => ( + result.ok ? { ok: true, value: map(result.value) } : result + ) + + const goalRefResult = (result: RpcResult): RpcResult<{ ref: { id: never; revision: number } }> => ( + mapGoalResult(result, view => ({ ref: { id: view.id as never, revision: view.revision } })) + ) + + const legacyGoalResponse = (request: RpcRequest

, result: RpcResult): Promise> => ( + Promise.resolve({ rpcId: request.rpcId, result }) + ) + /** At most one in-flight replay per session; cancel clears it. */ const replays = new Map; finish(aborted: boolean): void }>() @@ -1777,7 +1893,7 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { replays.set(id, { timer: setTimeout(tick, 80), finish }) } - return { + const api: ApiProxy = { sessions: { list: request => ok(request, { items: [...sessions].sort((a, b) => b.updatedAt - a.updatedAt) }), search: (request, signal) => { @@ -2334,60 +2450,44 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { }, }, goals: { - // Mutation-only mirror of the host handlers: each verb CAS-checks the - // projected current goal, appends the whole-value change (the mux - // stream and projection frame ride the shared append path), and - // acknowledges with the new ref only. - create: (request) => { - const missing = requireSession(request) - if (missing !== undefined) return missing - const id = request.payload.sessionId - const current = backscanGoal(logOf(id)) - if (current !== null && current.goal.phase !== 'complete') { - return err(request, { code: 'internal', message: `goal "${current.goal.id}" already exists`, details: { goalCode: 'GOAL_ALREADY_EXISTS' } }) - } - const projection = appendGoalChange(id, { - kind: 'goal/change', version: 1, operation: 'create', - goal: { id: `fx-goal-${logOf(id).length}`, revision: 1, objective: request.payload.objective, phase: 'active', maxGoalRounds: request.payload.maxGoalRounds ?? 256 }, - roundsStarted: 0, createdAt: Date.now(), updatedAt: Date.now(), - }) - return ok(request, { ref: { id: projection.goal.id as never, revision: projection.goal.revision } }) - }, - edit: request => fxMutateGoal(request, request.payload.ref, current => ({ - ...current.goal, - revision: current.goal.revision + 1, - ...request.payload.objective === undefined ? {} : { objective: request.payload.objective }, - ...request.payload.maxGoalRounds === undefined ? {} : { maxGoalRounds: request.payload.maxGoalRounds }, - })), - pause: request => fxMutateGoal(request, request.payload.ref, current => ( - current.goal.phase === 'active' - ? { ...current.goal, revision: current.goal.revision + 1, phase: 'paused' } - : undefined - )), - resume: request => fxMutateGoal(request, request.payload.ref, current => ( - current.goal.phase === 'paused' || current.goal.phase === 'blocked' || current.goal.phase === 'active' - ? { ...current.goal, revision: current.goal.revision + 1, phase: 'active' } - : undefined - )), - complete: request => fxMutateGoal(request, request.payload.ref, current => ( - current.goal.phase === 'complete' - ? undefined - : { ...current.goal, revision: current.goal.revision + 1, phase: 'complete' } - )), - clear: (request) => { - const missing = requireSession(request) - if (missing !== undefined) return missing - const id = request.payload.sessionId - const current = backscanGoal(logOf(id)) - if (current === null || current.goal.id !== request.payload.ref.id || current.goal.revision !== request.payload.ref.revision) { - return err(request, { code: 'internal', message: 'stale or missing goal revision', details: { goalCode: 'GOAL_STALE_REVISION' } }) - } - appendGoalChange(id, { - kind: 'goal/change', version: 1, operation: 'clear', - cleared: { id: current.goal.id, revision: current.goal.revision + 1 }, clearedAt: Date.now(), - }) - return ok(request, { cleared: true as const }) - }, + // Compatibility face only: old API Proxy payloads and acknowledgements + // adapt to the canonical fixture Remote implementation above. + create: request => legacyGoalResponse( + request, + mapGoalResult( + goalRemotes.create(request.payload.sessionId, { + objective: request.payload.objective, + ...request.payload.maxGoalRounds === undefined ? {} : { maxGoalRounds: request.payload.maxGoalRounds }, + }), + value => ({ ref: { id: value.ref.id as never, revision: value.ref.revision } }), + ), + ), + edit: request => legacyGoalResponse( + request, + goalRefResult(goalRemotes.edit(request.payload.sessionId, request.payload.ref, { + ...request.payload.objective === undefined ? {} : { objective: request.payload.objective }, + ...request.payload.maxGoalRounds === undefined ? {} : { maxGoalRounds: request.payload.maxGoalRounds }, + })), + ), + pause: request => legacyGoalResponse( + request, + goalRefResult(goalRemotes.pause(request.payload.sessionId, request.payload.ref)), + ), + resume: request => legacyGoalResponse( + request, + goalRefResult(goalRemotes.resume(request.payload.sessionId, request.payload.ref)), + ), + complete: request => legacyGoalResponse( + request, + goalRefResult(goalRemotes.complete(request.payload.sessionId, request.payload.ref)), + ), + clear: request => legacyGoalResponse( + request, + mapGoalResult( + goalRemotes.clear(request.payload.sessionId, request.payload.ref), + () => ({ cleared: true as const }), + ), + ), }, events: { async *mux(_request, signal) { @@ -2548,6 +2648,36 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { return Promise.resolve({ accepted: true }) }, } + + const rpc: ClientConnectionRpc = { + call(channel, endpoint, payload) { + if (channel !== '/api') { + return Promise.reject(new Error(`fixture connection RPC channel ${JSON.stringify(channel)} is unavailable`)) + } + const args = (payload as { + args: { + agentId: SessionId + ref?: { id: string; revision: number } + request?: { objective?: string; maxGoalRounds?: number } + } + }).args + const sessionId = args.agentId + switch (endpoint) { + case 'goals/create': return Promise.resolve(goalRemotes.create(sessionId, { + objective: args.request?.objective as string, + ...args.request?.maxGoalRounds === undefined ? {} : { maxGoalRounds: args.request.maxGoalRounds }, + })) + case 'goals/edit': return Promise.resolve(goalRemotes.edit(sessionId, args.ref as FxGoalRef, args.request ?? {})) + case 'goals/pause': return Promise.resolve(goalRemotes.pause(sessionId, args.ref as FxGoalRef)) + case 'goals/resume': return Promise.resolve(goalRemotes.resume(sessionId, args.ref as FxGoalRef)) + case 'goals/complete': return Promise.resolve(goalRemotes.complete(sessionId, args.ref as FxGoalRef)) + case 'goals/clear': return Promise.resolve(goalRemotes.clear(sessionId, args.ref as FxGoalRef)) + default: + return Promise.reject(new Error(`fixture connection RPC endpoint ${JSON.stringify(endpoint)} is unavailable`)) + } + }, + } + return { api, rpc } } /** @@ -2559,10 +2689,14 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { */ export class FixtureApiClient extends AbstractApiClient { private readonly api: ApiProxy + /** Generic Remote caller backed by the same in-memory state as the legacy fixture API. */ + readonly rpc: ClientConnectionRpc constructor() { super() - this.api = createFixtureApi(fixtureOptionsFromLocation()) + const world = createFixtureWorld(fixtureOptionsFromLocation()) + this.api = world.api + this.rpc = world.rpc } protected doFetch(): Promise { diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index 521e54160e..c2a6668d46 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -8,7 +8,7 @@ import type { IApiClient } from './api.ts' import { ConnectionController, type ConnectionConfig, type ConnectionSinks, type ConnectionState } from './connection.ts' import { FixtureApiClient } from './fixture.ts' import { WebApiClient } from './web-api-client.ts' -import { createUnavailableConnectionRpc, createWebConnectionRpc } from './rpc.ts' +import { createWebConnectionRpc } from './rpc.ts' import { isLoopbackHostname } from '../loopback-hostname.ts' import type { ClientConnectionRpc } from '../rpc.ts' @@ -74,8 +74,9 @@ export interface ConnectionHandle { export function apply(ctx: Context): void { const pageLocation = typeof location === 'undefined' ? undefined : location const fixture = pageLocation !== undefined && new URLSearchParams(pageLocation.search).has('fixture') - const api: IApiClient = fixture ? new FixtureApiClient() : new WebApiClient() - const rpc = fixture ? createUnavailableConnectionRpc() : createWebConnectionRpc() + const fixtureClient = fixture ? new FixtureApiClient() : undefined + const api: IApiClient = fixtureClient ?? new WebApiClient() + const rpc = fixtureClient?.rpc ?? createWebConnectionRpc() let started = false const handle: ConnectionHandle = { api, diff --git a/packages/client/connection/src/client/rpc.ts b/packages/client/connection/src/client/rpc.ts index 7883f2a9d3..f8bacb1553 100644 --- a/packages/client/connection/src/client/rpc.ts +++ b/packages/client/connection/src/client/rpc.ts @@ -48,18 +48,6 @@ export function createWebConnectionRpc(): ClientConnectionRpc { } } -/** - * Create the fixture-mode caller, where no Host Remote registry exists. - * @returns caller that rejects every generic Remote invocation. - */ -export function createUnavailableConnectionRpc(): ClientConnectionRpc { - return { - call(channel, endpoint) { - return Promise.reject(new Error(`connection RPC ${channel}/${endpoint} is unavailable in fixture mode`)) - }, - } -} - function resolveBase(): string { const location = (globalThis as { location?: { origin?: string } }).location return location?.origin !== undefined && location.origin !== 'null' ? location.origin : INTERNAL_BASE diff --git a/packages/client/connection/tests/client-apply.spec.ts b/packages/client/connection/tests/client-apply.spec.ts index 41e8e9b0e2..9d9bbd2f26 100644 --- a/packages/client/connection/tests/client-apply.spec.ts +++ b/packages/client/connection/tests/client-apply.spec.ts @@ -285,9 +285,37 @@ describe('connection client apply', () => { } }) - it('keeps generic Remote calls unavailable in the client-only fixture', async () => { + it('carries Goal Remotes over the same state as the client-only fixture API', async () => { ;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' } const handle = await mount() - await expect(handle.rpc.call('/api', 'goals/create', {})).rejects.toThrow(/unavailable in fixture mode/) + const created = await handle.rpc.call('/api', 'goals/create', { + args: { agentId: 'fx-alpha', request: { objective: 'fixture remote' } }, + }) + expect(created).toMatchObject({ ok: true, value: { ref: { revision: 1 } } }) + if (!created.ok) throw new Error('fixture Goal create failed') + const ref = (created.value as { ref: { id: string; revision: number } }).ref + const edited = await handle.rpc.call('/api', 'goals/edit', { + args: { agentId: 'fx-alpha', ref, request: { objective: 'edited fixture remote' } }, + }) + expect(edited).toMatchObject({ ok: true, value: { objective: 'edited fixture remote', revision: 2 } }) + const editedRef = { id: ref.id, revision: 2 } + const paused = await handle.rpc.call('/api', 'goals/pause', { + args: { agentId: 'fx-alpha', ref: editedRef }, + }) + expect(paused).toMatchObject({ ok: true, value: { phase: 'paused', activation: 'disarmed', revision: 3 } }) + const resumed = await handle.rpc.call('/api', 'goals/resume', { + args: { agentId: 'fx-alpha', ref: { id: ref.id, revision: 3 } }, + }) + expect(resumed).toMatchObject({ ok: true, value: { phase: 'active', activation: 'armed', revision: 4 } }) + const completed = await handle.rpc.call('/api', 'goals/complete', { + args: { agentId: 'fx-alpha', ref: { id: ref.id, revision: 4 } }, + }) + expect(completed).toMatchObject({ ok: true, value: { phase: 'complete', activation: 'disarmed', revision: 5 } }) + await expect(handle.rpc.call('/api', 'goals/clear', { + args: { agentId: 'fx-alpha', ref: { id: ref.id, revision: 5 } }, + })).resolves.toEqual({ ok: true, value: { id: ref.id, revision: 6 } }) + await expect(handle.rpc.call('/other', 'goals/create', {})).rejects.toThrow(/channel.*unavailable/) + await expect(handle.rpc.call('/api', 'unknown/read', { args: { agentId: 'fx-alpha' } })) + .rejects.toThrow(/endpoint.*unavailable/) }) }) From 737c12935ac1c95bd4118422c19f283abdb540f6 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:04:42 +0800 Subject: [PATCH 38/63] fix(connection): share fixture goal revision lookup --- .../client/connection/src/client/fixture.ts | 29 +++++++++++-------- .../request-response.expected.json | 4 +-- 2 files changed, 19 insertions(+), 14 deletions(-) diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 1a9841aece..776d21fd46 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -1598,12 +1598,9 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { )) }, clear(id: SessionId, ref: FxGoalRef): RpcResult { - const missing = requireGoalSession(id) - if (missing !== undefined) return missing - const current = backscanGoal(logOf(id)) - if (current === null || current.goal.id !== ref.id || current.goal.revision !== ref.revision) { - return goalFailure('stale or missing goal revision') - } + const resolved = resolveGoal(id, ref) + if (!resolved.ok) return resolved + const current = resolved.value const tombstone = { id: current.goal.id, revision: current.goal.revision + 1 } appendGoalChange(id, { kind: 'goal/change', version: 1, operation: 'clear', cleared: tombstone, clearedAt: Date.now(), @@ -1612,18 +1609,26 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { }, } - /** Shared CAS mutation path behind the canonical Remote verbs. */ - function mutateGoal( - id: SessionId, - ref: FxGoalRef, - next: (current: FxGoalProjection) => FxGoalProjection['goal'] | undefined, - ): RpcResult { + /** Resolve one current goal revision for a canonical Remote mutation. */ + function resolveGoal(id: SessionId, ref: FxGoalRef): RpcResult { const missing = requireGoalSession(id) if (missing !== undefined) return missing const current = backscanGoal(logOf(id)) if (current === null || current.goal.id !== ref.id || current.goal.revision !== ref.revision) { return goalFailure('stale or missing goal revision') } + return { ok: true, value: current } + } + + /** Shared CAS mutation path behind the canonical Remote verbs. */ + function mutateGoal( + id: SessionId, + ref: FxGoalRef, + next: (current: FxGoalProjection) => FxGoalProjection['goal'] | undefined, + ): RpcResult { + const resolved = resolveGoal(id, ref) + if (!resolved.ok) return resolved + const current = resolved.value const goal = next(current) if (goal === undefined) { return goalFailure(`invalid goal transition from "${current.goal.phase}"`) diff --git a/scripts/snapshots/translation-prompt-v4/request-response.expected.json b/scripts/snapshots/translation-prompt-v4/request-response.expected.json index b9d67f4bf6..e796de8a8a 100644 --- a/scripts/snapshots/translation-prompt-v4/request-response.expected.json +++ b/scripts/snapshots/translation-prompt-v4/request-response.expected.json @@ -16,11 +16,11 @@ }, { "role": "user", - "content": "# Development guide\n\nEnglish | [中文](development.zh.md)\n\nThe setup tutorial takes a new contributor from prerequisites to a checked checkout. The contributor reference that follows covers repository layout, daily workflow, and CI shape. Design rationale and implementation details belong to the linked Agent Notes and scripts.\n\n## Setup tutorial\n\n### Prerequisites\n\n- Node.js supports 22.19+ and 24+. CI covers 22.19, 24, and 26; see the [Node engine floor Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md).\n- Corepack-enabled pnpm. The repo pins `pnpm@11.7.0` in `package.json`; run `corepack enable` if `pnpm --version` does not resolve through Corepack.\n- Git 2.26 or newer; hook setup enables Git's worktree-specific configuration extension.\n- Optional: a DeepSeek API key for the Web, headless, and ACP automation demos and real-API e2e tests.\n\n### First-time setup\n\nInstall dependencies from the repo root:\n\n```sh\npnpm install\n```\n\nThe install also configures worktree-local lefthook hooks through `scripts/install-lefthook.mjs`. The [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) owns the safety and migration contract.\n\nIf hooks are missing because dependencies were restored from cache or `postinstall` was skipped, install them manually:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\nIf the wrapper rejects existing Git configuration or reports a stale lock, follow its diagnostic and the linked Agent Note rather than editing worktree metadata speculatively. After moving a checkout, rerun the wrapper to regenerate the owned path.\n\nRun typecheck once after a fresh clone:\n\n```sh\npnpm run typecheck\n```\n\nSetup is complete when `pnpm run typecheck` exits successfully.\n\n## Contributor reference\n\n### TypeScript project layout\n\nThe repository typecheck runs the whole-repo `tsc -b` graph: it emits every package/vendor `lib/types` and checks examples, tests, and scripts through two no-emit aggregates.\n\nThe repository's TypeScript configuration has exactly three roles; every tsconfig file plays one of them.\n\n| File | Role | Forms a program? |\n|---|---|---|\n| `tsconfig.json` | Solution root: `extends` base, `files: []`, references to the two aggregates. The whole-repo `tsc -b tsconfig.json` graph, the tsserver discovery entry, and — through the inherited `paths` — the resolution config for tsx running `examples/` and `scripts/` (their nearest tsconfig is this file). | No |\n| `tsconfig.host.json` | Host aggregate: host-side packages (via references), examples, tests, scripts, website. Excludes `packages/client`. | Yes |\n| `tsconfig.client.json` | Client aggregate: `packages/client/*` packages and their tests, `apps/web`. | Yes |\n| `tsconfig.base.json` | Shared compilerOptions and the source `paths` map. Also the resolution facade the vitest configs point vite-tsconfig-paths at: it has no `include`, so its `paths` apply to every importer. | No |\n| `tsconfig.base.client.json` | Browser compiler shape (`jsx`, DOM libs, `types: []`) extended by the client aggregate and every `packages/client/*` package. | No |\n\nHost and client stay two aggregate programs because both sides declaration-merge the cordis `Context` interface under the same keys with different services; one program seeing both merges reports a collision. The collision exists only inside a `ts.Program` — module resolution never triggers it — which is why the solution may reference both aggregates and one paths facade may span both sides. Two disciplines follow:\n\n- `tsconfig.base.json` never gains `include` or `files`: they would leak into every extending package project and narrow the facade's match-all scope.\n- A script that builds a repo-wide `ts.Program` seeds `tsconfig.host.json` or `tsconfig.client.json` explicitly — never the root solution, because flattening both aggregates into one program collides the `Context` merges. Program-backed generators and gates (`scripts/ts-project.ts` consumers, doc-typecheck standalone mode) are host-only by decision; the client side gains program-backed tooling only with a concrete need.\n\nStatic analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. Decision record: [solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md); the tsc-first emit pipeline is the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md).\n\nIf a relevant local check consumes built package output, build once first:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` includes `publint`, which validates package entrypoints against the built `lib/*.js` files, and `verify-node-next-types`, which validates built declarations against a temporary NodeNext consumer. A fresh worktree has no bundled JS or declarations until `pnpm run build` runs; ordinary commits and pushes do not require that build unless their selected checks consume it.\n\n### Environment variables\n\nThe real DeepSeek adapter and key-backed agent demos read credentials from the environment or from a gitignored `.env` at the repo root:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` is optional and defaults to the public API. Never commit real credentials. The real-API e2e suites self-skip when `DEEPSEEK_API_KEY` is not set.\n\n### Git hooks\n\nlefthook is configured in `lefthook.yml` as a fast local checkpoint:\n\n- `pre-commit` applies formatting-only ESLint fixes, validates the staged files with Oxlint and applies its native fixes, regenerates `THIRD_PARTY_NOTICES.md` when a staged file is one of its inputs, checks the staged diff for whitespace errors, and runs the vendor manifest guard.\n- `pre-push` runs only the incremental repository typecheck (`tsc -b` over the root solution, covering both the host and client aggregates).\n\nThe vendor manifest guard checks that changes under `vendor/*/src` are staged with the matching `vendor/README.md` manifest update. See `vendor/README.md` before editing vendored code.\n\nThe hooks intentionally do not run tests, snapshots, documentation checks, builds, or hygiene. Contributors run the [checks relevant to the changed behavior](../AGENTS.md#run-relevant-checks-locally) once; CI owns exhaustive coverage, built-artifact smokes, and the Node 22.19, 24, and 26 compatibility matrix.\n\nContributors can opt into the comprehensive local gate set with `pnpm run check:all`. The command is independent of both Git hooks and is not an agent instruction.\n\n### CI gates\n\nThe keyless [CI workflow](../.github/workflows/ci.yml) groups independent gates into broad lanes and runs a smaller compatibility signal across supported Node versions. Artifact consumers wait for one build within their lane. The separate real-API workflow runs `pnpm run test:e2e` with its configured worker bound. See [scripts/run-gates.ts](../scripts/run-gates.ts) and the workflow files for the current gate and job inventory.\n\n### Daily commands\n\nThe root [contributor instructions](../AGENTS.md#commands) summarize common commands, while [`package.json`](../package.json) and [scripts/run-gates.ts](../scripts/run-gates.ts) own the current script and gate inventories. Select the smallest checks that cover the changed surface. Documentation changes use `pnpm run doc-sync`; package-public behavior changes also update the owning README or JSDoc, and built-artifact checks require `pnpm run build` first.\n\n### Demos\n\nThe one-shot Headless coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\nThe self-referential cordis demo can inspect and modify its live plugin runtime and needs the same credentials (`web` by default, or `acp`):\n\n```sh\npnpm run demo:cordis\n```\n\nThe ACP automation server exposes fresh agent sessions over JSON-RPC stdio and also needs `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n### TODO markers\n\nUse one of three comment tags to flag known issues in the code, ordered by urgency:\n\n- `FIXME` — an issue that should block a new release. A release should not ship with an open `FIXME` unless reviewers explicitly agree the change can be merged anyway.\n- `TODO` — an issue that should be fixed soon, once we have the resources.\n- `XXX` — an issue that we may fix someday; lowest priority, no commitment.\n\nPick the tag that matches the urgency so anyone scanning the code can tell a release blocker from a someday-maybe.\n\n### Documenting types verbatim (`ts type-equiv`)\n\nThe [core data structures](core-data-structures/core.md) docs paste source-equivalent declarations together with their original JSDoc so a reader sees the exact shape and source contract. To keep a paste from drifting when source changes, fence it as ` ```ts type-equiv ` (instead of ` ```ts `) and register it in `scripts/type-equiv.manifest.json` with the source file and symbol it mirrors:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration and attached JSDoc from source via the TypeScript parser and asserts the block matches both. For a class whose implementation bodies do not belong in the catalog, use ` ```ts public-api ` and set `\"projection\": \"public-api\"`; the checked projection retains the public fields, constructor, accessors, methods, and original class/member JSDoc while omitting bodies and private or protected members. Comparison ignores whitespace and non-JSDoc comments but requires every original JSDoc comment, including member documentation, so readers see the source contract beside the exact shape. The gate enforces a 1:1 correspondence by document, symbol, and projection between primary blocks and manifest entries; a paired `.zh.md` block reuses its unsuffixed sibling's entry only when the whole tracked fence sequence is byte-identical and ordered identically. `doc-typecheck` applies the same derivative rule to compilable fences, while skipping both source-equivalence fence kinds from compilation and its opt-out ratio. When you change a documented declaration or its JSDoc, the gate fails until you update the paste; when you add or remove a primary block, update the manifest in the same change.\n" + "content": "# Development guide\n\nEnglish | [中文](development.zh.md)\n\nThe setup tutorial takes a new contributor from prerequisites to a checked checkout. The contributor reference that follows covers repository layout, daily workflow, and CI shape. Design rationale and implementation details belong to the linked Agent Notes and scripts.\n\n## Setup tutorial\n\n### Prerequisites\n\n- Node.js supports 22.19+ and 24+. CI covers 22.19, 24, and 26; see the [Node engine floor Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md).\n- Corepack-enabled pnpm. The repo pins `pnpm@11.7.0` in `package.json`; run `corepack enable` if `pnpm --version` does not resolve through Corepack.\n- Git 2.26 or newer; hook setup enables Git's worktree-specific configuration extension.\n- Optional: a DeepSeek API key for the Web, headless, and ACP automation demos and real-API e2e tests.\n\n### First-time setup\n\nInstall dependencies from the repo root:\n\n```sh\npnpm install\n```\n\nThe install also configures worktree-local lefthook hooks through `scripts/install-lefthook.mjs`. The [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) owns the safety and migration contract.\n\nIf hooks are missing because dependencies were restored from cache or `postinstall` was skipped, install them manually:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\nIf the wrapper rejects existing Git configuration or reports a stale lock, follow its diagnostic and the linked Agent Note rather than editing worktree metadata speculatively. After moving a checkout, rerun the wrapper to regenerate the owned path.\n\nRun typecheck once after a fresh clone:\n\n```sh\npnpm run typecheck\n```\n\nSetup is complete when `pnpm run typecheck` exits successfully.\n\n## Contributor reference\n\n### TypeScript project layout\n\nThe repository typecheck runs the whole-repo `tsc -b` graph: it emits every package/vendor `lib/types` and checks examples, tests, and scripts through two no-emit aggregates.\n\nThe repository's TypeScript configuration has exactly three roles; every tsconfig file plays one of them.\n\n| File | Role | Forms a program? |\n|---|---|---|\n| `tsconfig.json` | Solution root: `extends` base, `files: []`, references to the two aggregates. The whole-repo `tsc -b tsconfig.json` graph, the tsserver discovery entry, and — through the inherited `paths` — the resolution config for tsx running `examples/` and `scripts/` (their nearest tsconfig is this file). | No |\n| `tsconfig.host.json` | Host aggregate: host-side packages (via references), examples, tests, scripts, website. Excludes `packages/client`. | Yes |\n| `tsconfig.client.json` | Client aggregate: `packages/client/*` packages and their tests, `apps/web`. | Yes |\n| `tsconfig.base.json` | Shared compilerOptions and the source `paths` map. Also the resolution facade the vitest configs point vite-tsconfig-paths at: it has no `include`, so its `paths` apply to every importer. | No |\n| `tsconfig.base.client.json` | Browser compiler shape (`jsx`, DOM libs, `types: []`) extended by the client aggregate and every `packages/client/*` package. | No |\n\nHost and client stay two aggregate programs because both sides declaration-merge the cordis `Context` interface under the same keys with different services; one program seeing both merges reports a collision. The collision exists only inside a `ts.Program` — module resolution never triggers it — which is why the solution may reference both aggregates and one paths facade may span both sides. Two disciplines follow:\n\n- `tsconfig.base.json` never gains `include` or `files`: they would leak into every extending package project and narrow the facade's match-all scope.\n- A script that builds a repo-wide `ts.Program` seeds `tsconfig.host.json` or `tsconfig.client.json` explicitly — never the root solution, because flattening both aggregates into one program collides the `Context` merges. Program-backed generators and gates (`scripts/ts-project.ts` consumers, doc-typecheck standalone mode) are host-only by decision; the client side gains program-backed tooling only with a concrete need.\n\nStatic analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. Decision record: [solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md); the tsc-first emit pipeline is the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md).\n\nBusiness services declare callable methods on the Host with `@Remote` or `@RemoteContext`; the Host build generates Host-for-Client types and runtime contributions, and the Client's `client-remotes` composition loads those contributions and mounts concrete methods on `ctx.api` or the corresponding scoped Context. See [API Gateway](api-gateway.md) for the generated artifacts on both sides, their assembly relationships, the SRC development fallback, and the Web build order.\n\nIf a relevant local check consumes built package output, build once first:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` includes `publint`, which validates package entrypoints against the built `lib/*.js` files, and `verify-node-next-types`, which validates built declarations against a temporary NodeNext consumer. A fresh worktree has no bundled JS or declarations until `pnpm run build` runs; ordinary commits and pushes do not require that build unless their selected checks consume it.\n\n### Environment variables\n\nThe real DeepSeek adapter and key-backed agent demos read credentials from the environment or from a gitignored `.env` at the repo root:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` is optional and defaults to the public API. Never commit real credentials. The real-API e2e suites self-skip when `DEEPSEEK_API_KEY` is not set.\n\n### Git hooks\n\nlefthook is configured in `lefthook.yml` as a fast local checkpoint:\n\n- `pre-commit` applies formatting-only ESLint fixes, validates the staged files with Oxlint and applies its native fixes, regenerates `THIRD_PARTY_NOTICES.md` when a staged file is one of its inputs, checks the staged diff for whitespace errors, and runs the vendor manifest guard.\n- `pre-push` runs only the incremental repository typecheck (`tsc -b` over the root solution, covering both the host and client aggregates).\n\nThe vendor manifest guard checks that changes under `vendor/*/src` are staged with the matching `vendor/README.md` manifest update. See `vendor/README.md` before editing vendored code.\n\nThe hooks intentionally do not run tests, snapshots, documentation checks, builds, or hygiene. Contributors run the [checks relevant to the changed behavior](../AGENTS.md#run-relevant-checks-locally) once; CI owns exhaustive coverage, built-artifact smokes, and the Node 22.19, 24, and 26 compatibility matrix.\n\nContributors can opt into the comprehensive local gate set with `pnpm run check:all`. The command is independent of both Git hooks and is not an agent instruction.\n\n### CI gates\n\nThe keyless [CI workflow](../.github/workflows/ci.yml) groups independent gates into broad lanes and runs a smaller compatibility signal across supported Node versions. Artifact consumers wait for one build within their lane. The separate real-API workflow runs `pnpm run test:e2e` with its configured worker bound. See [scripts/run-gates.ts](../scripts/run-gates.ts) and the workflow files for the current gate and job inventory.\n\n### Daily commands\n\nThe root [contributor instructions](../AGENTS.md#commands) summarize common commands, while [`package.json`](../package.json) and [scripts/run-gates.ts](../scripts/run-gates.ts) own the current script and gate inventories. Select the smallest checks that cover the changed surface. Documentation changes use `pnpm run doc-sync`; package-public behavior changes also update the owning README or JSDoc, and built-artifact checks require `pnpm run build` first.\n\n### Demos\n\nThe one-shot Headless coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\nThe self-referential cordis demo can inspect and modify its live plugin runtime and needs the same credentials (`web` by default, or `acp`):\n\n```sh\npnpm run demo:cordis\n```\n\nThe ACP automation server exposes fresh agent sessions over JSON-RPC stdio and also needs `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n### TODO markers\n\nUse one of three comment tags to flag known issues in the code, ordered by urgency:\n\n- `FIXME` — an issue that should block a new release. A release should not ship with an open `FIXME` unless reviewers explicitly agree the change can be merged anyway.\n- `TODO` — an issue that should be fixed soon, once we have the resources.\n- `XXX` — an issue that we may fix someday; lowest priority, no commitment.\n\nPick the tag that matches the urgency so anyone scanning the code can tell a release blocker from a someday-maybe.\n\n### Documenting types verbatim (`ts type-equiv`)\n\nThe [core data structures](core-data-structures/core.md) docs paste source-equivalent declarations together with their original JSDoc so a reader sees the exact shape and source contract. To keep a paste from drifting when source changes, fence it as ` ```ts type-equiv ` (instead of ` ```ts `) and register it in `scripts/type-equiv.manifest.json` with the source file and symbol it mirrors:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration and attached JSDoc from source via the TypeScript parser and asserts the block matches both. For a class whose implementation bodies do not belong in the catalog, use ` ```ts public-api ` and set `\"projection\": \"public-api\"`; the checked projection retains the public fields, constructor, accessors, methods, and original class/member JSDoc while omitting bodies and private or protected members. Comparison ignores whitespace and non-JSDoc comments but requires every original JSDoc comment, including member documentation, so readers see the source contract beside the exact shape. The gate enforces a 1:1 correspondence by document, symbol, and projection between primary blocks and manifest entries; a paired `.zh.md` block reuses its unsuffixed sibling's entry only when the whole tracked fence sequence is byte-identical and ordered identically. `doc-typecheck` applies the same derivative rule to compilable fences, while skipping both source-equivalence fence kinds from compilation and its opt-out ratio. When you change a documented declaration or its JSDoc, the gate fails until you update the paste; when you add or remove a primary block, update the manifest in the same change.\n" }, { "role": "assistant", - "content": "# 开发指南\n\n[English](development.md) | 中文\n\n搭建教程引导新贡献者从准备前置条件开始,直到检出通过检查。后面的贡献者参考介绍仓库布局、日常工作流和 CI 形态。设计依据与实现细节属于链接的 Agent Note 和脚本。\n\n## 搭建教程\n\n### 前置条件\n\n- Node.js 支持 22.19+ 与 24+。CI 覆盖 22.19、24 和 26;见 [Node 引擎下限 Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md)。\n- 启用了 Corepack 的 pnpm。仓库在 `package.json` 中固定使用 `pnpm@11.7.0`;如果 `pnpm --version` 无法通过 Corepack 解析,请先运行 `corepack enable`。\n- Git 2.26 或更高版本;钩子设置会启用 Git 的 worktree 专属配置扩展。\n- 可选:一个 DeepSeek API key,用于 Web、headless 和 ACP(Agent Client Protocol)自动化 agent(智能体)演示以及真实 API 的 e2e 测试。\n\n### 首次搭建\n\n在仓库根目录安装依赖:\n\n```sh\npnpm install\n```\n\n安装过程还会通过 `scripts/install-lefthook.mjs` 配置 worktree 本地的 lefthook 钩子。其安全与迁移契约由 [worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) 负责。\n\n如果依赖是从缓存恢复或 `postinstall` 被跳过而导致缺少钩子,请手动安装:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\n如果包装脚本拒绝现有 Git 配置或报告陈旧锁,请遵循其诊断和所链接的 Agent Note,不要凭猜测编辑 worktree 元数据。移动检出目录后,请重新运行包装脚本以重新生成自有路径。\n\n新克隆后请先运行一次类型检查:\n\n```sh\npnpm run typecheck\n```\n\n`pnpm run typecheck` 成功退出即表示搭建完成。\n\n## 贡献者参考\n\n### TypeScript 项目布局\n\n仓库类型检查会执行全仓 `tsc -b` 图:它会发射每个 package/vendor 的 `lib/types`,并通过两个 no-emit 聚合检查示例、测试和脚本。\n\n仓库的 TypeScript 配置只有三种角色;每个 tsconfig 文件恰好扮演其中一种。\n\n| 文件 | 角色 | 是否构成 program? |\n|---|---|---|\n| `tsconfig.json` | solution 根:`extends` base、`files: []`、引用两个聚合。全仓 `tsc -b tsconfig.json` 图、tsserver 发现入口,并经继承的 `paths` 充当 tsx 运行 `examples/` 与 `scripts/` 时的解析配置(它们最近的 tsconfig 就是此文件)。 | 否 |\n| `tsconfig.host.json` | host 聚合:host 侧各包(经 references)、示例、测试、脚本、website。排除 `packages/client`。 | 是 |\n| `tsconfig.client.json` | client 聚合:`packages/client/*` 各包及其测试、`apps/web`。 | 是 |\n| `tsconfig.base.json` | 共享 compilerOptions 与源码 `paths` 映射。同时是各 vitest 配置让 vite-tsconfig-paths 指向的解析门面:它没有 `include`,因此其 `paths` 适用于任何 importer。 | 否 |\n| `tsconfig.base.client.json` | 浏览器编译形状(`jsx`、DOM lib、`types: []`),由 client 聚合和每个 `packages/client/*` 包 extends。 | 否 |\n\nhost 与 client 保持两个聚合 program,是因为两侧在相同键下以不同服务对 cordis `Context` 接口做声明合并;单一 program 同时看到两份合并会报冲突。这种冲突只存在于 `ts.Program` 内部——模块解析永远不会触发它——所以 solution 可以同时引用两个聚合,一个 paths 门面也可以横跨两侧。由此推出两条纪律:\n\n- `tsconfig.base.json` 永不添加 `include` 或 `files`:它们会泄漏进每个 extends 它的包项目,并收窄门面的全匹配范围。\n- 构造全仓 `ts.Program` 的脚本显式种子 `tsconfig.host.json` 或 `tsconfig.client.json`——永不种子根 solution,因为把两个聚合展平进一个 program 会撞上 `Context` 合并冲突。基于 program 的生成器与门禁(`scripts/ts-project.ts` 的消费者、doc-typecheck standalone 模式)按决策仅覆盖 host 侧;client 侧只在出现真实需求时再获得基于 program 的工具。\n\n静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。决策记录:[solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md);tsc-first 发射管线见 [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md)。\n\n如果相关的本地检查需要使用构建后的包产物,请先构建一次:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` 包含 `publint`(用构建出的 `lib/*.js` 文件校验 package 入口点)和 `verify-node-next-types`(用一个临时的 NodeNext 消费方校验构建出的声明文件)。新 worktree 在 `pnpm run build` 运行之前没有打包的 JS 和声明文件;普通提交和推送无需构建,除非所选检查会使用这些产物。\n\n### 环境变量\n\n真实的 DeepSeek 适配器和需要密钥的 agent 演示从环境变量或仓库根目录一个被 gitignore 的 `.env` 文件读取凭证:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` 可选,默认为公开 API。请勿提交真实凭证。未设置 `DEEPSEEK_API_KEY` 时,真实 API 的 e2e 套件会自动跳过。\n\n### Git 钩子\n\nlefthook 在 `lefthook.yml` 中配置,作为快速的本地检查点:\n\n- `pre-commit` 应用仅用于格式化的 ESLint 修复,使用 Oxlint 验证暂存文件并应用其原生修复,在暂存文件属于 `THIRD_PARTY_NOTICES.md` 的输入时重新生成该文件,然后检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫;\n- `pre-push` 只运行仓库增量类型检查(对根 solution 执行 `tsc -b`,覆盖 host 与 client 两个聚合)。\n\nvendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。请在编辑 vendor 代码前先阅读 `vendor/README.md`。\n\n这些钩子有意不运行测试、快照、文档检查、构建或 `hygiene`。贡献者只运行一次[与改动行为相关的检查](../AGENTS.md#run-relevant-checks-locally);CI 负责全量覆盖率门禁、构建产物冒烟测试,以及 Node 22.19、24 和 26 兼容性矩阵。\n\n贡献者可以选择运行 `pnpm run check:all`,执行全面的本地门禁集。该命令独立于两个 Git 钩子,也不是对 agent 的指令。\n\n### CI 门禁\n\nkeyless [CI 工作流](../.github/workflows/ci.yml) 将独立门禁分组到若干宽粒度 lane,并在受支持的 Node 版本上运行一组较小的兼容性检查。产物消费方在各自 lane 内等待一次 build。单独的真实 API 工作流按其配置的 worker 上限运行 `pnpm run test:e2e`。当前门禁和 job 清单以 [scripts/run-gates.ts](../scripts/run-gates.ts) 和工作流文件为准。\n\n### 日常命令\n\n根目录的[贡献者说明](../AGENTS.md#commands)概述常用命令,[`package.json`](../package.json) 与 [scripts/run-gates.ts](../scripts/run-gates.ts) 则负责当前脚本和门禁清单。请选择覆盖变更表面的最小检查集。文档变更使用 `pnpm run doc-sync`;package 公开行为变更还需更新所属 README 或 JSDoc,而基于构建产物的检查需要先运行 `pnpm run build`。\n\n### 演示\n\n单次运行的 Headless coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\n自指的 cordis 演示可以检查并修改其实时插件运行时,并需要相同的凭证(默认 `web`,也可用 `acp`):\n\n```sh\npnpm run demo:cordis\n```\n\nACP 自动化服务器通过 JSON-RPC stdio 提供全新 agent 会话,同样需要 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n### TODO 标记\n\n请使用以下三种注释标签之一标记代码中的已知问题,按紧急程度排序:\n\n- `FIXME`:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 `FIXME`;\n- `TODO`:应当尽快修复的问题,等资源到位即可处理;\n- `XXX`:也许某天会修复的问题,优先级最低,不作承诺。\n\n请选择与紧急程度匹配的标签,让浏览代码的人一眼分清「发布阻塞」和「有空再说」。\n\n### 逐字记录类型(`ts type-equiv`)\n\n[核心数据结构](core-data-structures/core.md)文档会把与源码等价的声明及其原始 JSDoc 一并粘贴,让读者看到确切形状和源码契约。为防止粘贴内容在源码变化时漂移,请将其围栏为 ` ```ts type-equiv `(而不是 ` ```ts `),并在 `scripts/type-equiv.manifest.json` 中登记它镜像的源文件和符号:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明及其附带的 JSDoc,并断言代码块同时匹配两者。对于不应把实现体写进目录的类,请使用 ` ```ts public-api ` 并设置 `\"projection\": \"public-api\"`;门禁检查的投影会保留公共字段、构造函数、访问器、方法以及类和成员的原始 JSDoc,同时省略实现体和私有或受保护成员。比对会忽略空白和非 JSDoc 注释,但要求保留每条原始 JSDoc(包括成员文档),让读者同时看到源码契约和确切形状。该门禁按文档、符号和投影,在主块与 manifest 条目之间强制 1:1 对应;只有当配对 `.zh.md` 块的完整受跟踪围栏序列与其无后缀兄弟文件按字节一致且顺序相同时,才会复用后者的条目。`doc-typecheck` 对可编译围栏应用同一派生规则,同时跳过两种源码等价围栏的编译,并将其排除在 opt-out 比例之外。当你改动一个已记录的类型声明或其 JSDoc 时,门禁会失败直到你更新粘贴内容;当你增删一个主块时,请在同一个变更里更新 manifest。\n" + "content": "# 开发指南\n\n[English](development.md) | 中文\n\n搭建教程引导新贡献者从准备前置条件开始,直到检出通过检查。后面的贡献者参考介绍仓库布局、日常工作流和 CI 形态。设计依据与实现细节属于链接的 Agent Note 和脚本。\n\n## 搭建教程\n\n### 前置条件\n\n- Node.js 支持 22.19+ 与 24+。CI 覆盖 22.19、24 和 26;见 [Node 引擎下限 Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md)。\n- 启用了 Corepack 的 pnpm。仓库在 `package.json` 中固定使用 `pnpm@11.7.0`;如果 `pnpm --version` 无法通过 Corepack 解析,请先运行 `corepack enable`。\n- Git 2.26 或更高版本;钩子设置会启用 Git 的 worktree 专属配置扩展。\n- 可选:一个 DeepSeek API key,用于 Web、headless 和 ACP(Agent Client Protocol)自动化 agent(智能体)演示以及真实 API 的 e2e 测试。\n\n### 首次搭建\n\n在仓库根目录安装依赖:\n\n```sh\npnpm install\n```\n\n安装过程还会通过 `scripts/install-lefthook.mjs` 配置 worktree 本地的 lefthook 钩子。其安全与迁移契约由 [worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) 负责。\n\n如果依赖是从缓存恢复或 `postinstall` 被跳过而导致缺少钩子,请手动安装:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\n如果包装脚本拒绝现有 Git 配置或报告陈旧锁,请遵循其诊断和所链接的 Agent Note,不要凭猜测编辑 worktree 元数据。移动检出目录后,请重新运行包装脚本以重新生成自有路径。\n\n新克隆后请先运行一次类型检查:\n\n```sh\npnpm run typecheck\n```\n\n`pnpm run typecheck` 成功退出即表示搭建完成。\n\n## 贡献者参考\n\n### TypeScript 项目布局\n\n仓库类型检查会执行全仓 `tsc -b` 图:它会发射每个 package/vendor 的 `lib/types`,并通过两个 no-emit 聚合检查示例、测试和脚本。\n\n仓库的 TypeScript 配置只有三种角色;每个 tsconfig 文件恰好扮演其中一种。\n\n| 文件 | 角色 | 是否构成 program? |\n|---|---|---|\n| `tsconfig.json` | solution 根:`extends` base、`files: []`、引用两个聚合。全仓 `tsc -b tsconfig.json` 图、tsserver 发现入口,并经继承的 `paths` 充当 tsx 运行 `examples/` 与 `scripts/` 时的解析配置(它们最近的 tsconfig 就是此文件)。 | 否 |\n| `tsconfig.host.json` | host 聚合:host 侧各包(经 references)、示例、测试、脚本、website。排除 `packages/client`。 | 是 |\n| `tsconfig.client.json` | client 聚合:`packages/client/*` 各包及其测试、`apps/web`。 | 是 |\n| `tsconfig.base.json` | 共享 compilerOptions 与源码 `paths` 映射。同时是各 vitest 配置让 vite-tsconfig-paths 指向的解析门面:它没有 `include`,因此其 `paths` 适用于任何 importer。 | 否 |\n| `tsconfig.base.client.json` | 浏览器编译形状(`jsx`、DOM lib、`types: []`),由 client 聚合和每个 `packages/client/*` 包 extends。 | 否 |\n\nhost 与 client 保持两个聚合 program,是因为两侧在相同键下以不同服务对 cordis `Context` 接口做声明合并;单一 program 同时看到两份合并会报冲突。这种冲突只存在于 `ts.Program` 内部——模块解析永远不会触发它——所以 solution 可以同时引用两个聚合,一个 paths 门面也可以横跨两侧。由此推出两条纪律:\n\n- `tsconfig.base.json` 永不添加 `include` 或 `files`:它们会泄漏进每个 extends 它的包项目,并收窄门面的全匹配范围。\n- 构造全仓 `ts.Program` 的脚本显式种子 `tsconfig.host.json` 或 `tsconfig.client.json`——永不种子根 solution,因为把两个聚合展平进一个 program 会撞上 `Context` 合并冲突。基于 program 的生成器与门禁(`scripts/ts-project.ts` 的消费者、doc-typecheck standalone 模式)按决策仅覆盖 host 侧;client 侧只在出现真实需求时再获得基于 program 的工具。\n\n静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。决策记录:[solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md);tsc-first 发射管线见 [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md)。\n\n业务 Service 在 Host 使用 `@Remote` 或 `@RemoteContext` 声明可调用方法;Host 构建生成 Host-for-Client 类型与运行时贡献,Client 的 `client-remotes` 组合加载这些贡献并将具体方法挂载到 `ctx.api` 或对应的作用域 Context。两侧的生成产物、装配关系、SRC 开发回退和 Web 构建顺序见 [API Gateway](api-gateway.md)。\n\n如果相关的本地检查需要使用构建后的包产物,请先构建一次:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` 包含 `publint`(用构建出的 `lib/*.js` 文件校验 package 入口点)和 `verify-node-next-types`(用一个临时的 NodeNext 消费方校验构建出的声明文件)。新 worktree 在 `pnpm run build` 运行之前没有打包的 JS 和声明文件;普通提交和推送无需构建,除非所选检查会使用这些产物。\n\n### 环境变量\n\n真实的 DeepSeek 适配器和需要密钥的 agent 演示从环境变量或仓库根目录一个被 gitignore 的 `.env` 文件读取凭证:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` 可选,默认为公开 API。请勿提交真实凭证。未设置 `DEEPSEEK_API_KEY` 时,真实 API 的 e2e 套件会自动跳过。\n\n### Git 钩子\n\nlefthook 在 `lefthook.yml` 中配置,作为快速的本地检查点:\n\n- `pre-commit` 应用仅用于格式化的 ESLint 修复,使用 Oxlint 验证暂存文件并应用其原生修复,在暂存文件属于 `THIRD_PARTY_NOTICES.md` 的输入时重新生成该文件,然后检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫;\n- `pre-push` 只运行仓库增量类型检查(对根 solution 执行 `tsc -b`,覆盖 host 与 client 两个聚合)。\n\nvendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。请在编辑 vendor 代码前先阅读 `vendor/README.md`。\n\n这些钩子有意不运行测试、快照、文档检查、构建或 `hygiene`。贡献者只运行一次[与改动行为相关的检查](../AGENTS.md#run-relevant-checks-locally);CI 负责全量覆盖率门禁、构建产物冒烟测试,以及 Node 22.19、24 和 26 兼容性矩阵。\n\n贡献者可以选择运行 `pnpm run check:all`,执行全面的本地门禁集。该命令独立于两个 Git 钩子,也不是对 agent 的指令。\n\n### CI 门禁\n\nkeyless [CI 工作流](../.github/workflows/ci.yml) 将独立门禁分组到若干宽粒度 lane,并在受支持的 Node 版本上运行一组较小的兼容性检查。产物消费方在各自 lane 内等待一次 build。单独的真实 API 工作流按其配置的 worker 上限运行 `pnpm run test:e2e`。当前门禁和 job 清单以 [scripts/run-gates.ts](../scripts/run-gates.ts) 和工作流文件为准。\n\n### 日常命令\n\n根目录的[贡献者说明](../AGENTS.md#commands)概述常用命令,[`package.json`](../package.json) 与 [scripts/run-gates.ts](../scripts/run-gates.ts) 则负责当前脚本和门禁清单。请选择覆盖变更表面的最小检查集。文档变更使用 `pnpm run doc-sync`;package 公开行为变更还需更新所属 README 或 JSDoc,而基于构建产物的检查需要先运行 `pnpm run build`。\n\n### 演示\n\n单次运行的 Headless coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\n自指的 cordis 演示可以检查并修改其实时插件运行时,并需要相同的凭证(默认 `web`,也可用 `acp`):\n\n```sh\npnpm run demo:cordis\n```\n\nACP 自动化服务器通过 JSON-RPC stdio 提供全新 agent 会话,同样需要 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n### TODO 标记\n\n请使用以下三种注释标签之一标记代码中的已知问题,按紧急程度排序:\n\n- `FIXME`:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 `FIXME`;\n- `TODO`:应当尽快修复的问题,等资源到位即可处理;\n- `XXX`:也许某天会修复的问题,优先级最低,不作承诺。\n\n请选择与紧急程度匹配的标签,让浏览代码的人一眼分清「发布阻塞」和「有空再说」。\n\n### 逐字记录类型(`ts type-equiv`)\n\n[核心数据结构](core-data-structures/core.md)文档会把与源码等价的声明及其原始 JSDoc 一并粘贴,让读者看到确切形状和源码契约。为防止粘贴内容在源码变化时漂移,请将其围栏为 ` ```ts type-equiv `(而不是 ` ```ts `),并在 `scripts/type-equiv.manifest.json` 中登记它镜像的源文件和符号:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明及其附带的 JSDoc,并断言代码块同时匹配两者。对于不应把实现体写进目录的类,请使用 ` ```ts public-api ` 并设置 `\"projection\": \"public-api\"`;门禁检查的投影会保留公共字段、构造函数、访问器、方法以及类和成员的原始 JSDoc,同时省略实现体和私有或受保护成员。比对会忽略空白和非 JSDoc 注释,但要求保留每条原始 JSDoc(包括成员文档),让读者同时看到源码契约和确切形状。该门禁按文档、符号和投影,在主块与 manifest 条目之间强制 1:1 对应;只有当配对 `.zh.md` 块的完整受跟踪围栏序列与其无后缀兄弟文件按字节一致且顺序相同时,才会复用后者的条目。`doc-typecheck` 对可编译围栏应用同一派生规则,同时跳过两种源码等价围栏的编译,并将其排除在 opt-out 比例之外。当你改动一个已记录的类型声明或其 JSDoc 时,门禁会失败直到你更新粘贴内容;当你增删一个主块时,请在同一个变更里更新 manifest。\n" }, { "role": "user", From 2fe4a53557179de0fbebe4e83e8cb18e735f112b Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:07:19 +0800 Subject: [PATCH 39/63] fix(typert): validate and mount remote contributions safely --- packages/host/api-gateway/src/client/index.ts | 72 ++++++++++++----- .../host/api-gateway/tests/client.spec.ts | 80 +++++++++++++++++++ packages/typert/generator/src/analyzer.ts | 4 +- packages/typert/generator/src/workspace.ts | 1 + .../generator/tests/remote-model.spec.ts | 33 +++++++- packages/typert/registry/src/service.ts | 2 +- packages/typert/registry/tests/typert.spec.ts | 2 +- packages/typert/type-meta/src/index.ts | 2 +- .../typert/type-meta/tests/type-meta.spec.ts | 2 + 9 files changed, 173 insertions(+), 25 deletions(-) diff --git a/packages/host/api-gateway/src/client/index.ts b/packages/host/api-gateway/src/client/index.ts index 3fc8389079..5503fc3dcf 100644 --- a/packages/host/api-gateway/src/client/index.ts +++ b/packages/host/api-gateway/src/client/index.ts @@ -4,7 +4,7 @@ * lookup, invocation, or type exposure. */ -import { Service } from 'cordis' +import { Service, symbols } from 'cordis' import type { Context } from 'cordis' import type { ConnectionHandle, RpcError } from '@deepseek-ai/dsh-client-connection/client' import type { @@ -84,7 +84,13 @@ class ClientApiService extends Service implements ClientApi { let disposeMethods: () => void | Promise try { disposeMethods = callerCtx.effect(() => { - const installed = contribution.descriptors.map(descriptor => this.install(descriptor)) + const installed: Array<() => void> = [] + try { + for (const descriptor of contribution.descriptors) installed.push(this.install(descriptor)) + } catch (error) { + for (const dispose of installed.reverse()) dispose() + throw error + } return () => { for (const dispose of installed.reverse()) dispose() } @@ -169,21 +175,27 @@ class ClientApiService extends Service implements ClientApi { private installDirect(descriptor: InvocationDescriptor, token: MountToken): () => void { let namespace = this.direct.get(descriptor.namespace) + const fresh = namespace === undefined if (namespace === undefined) { namespace = { value: Object.create(null) as Record, tokens: new Map() } - this.direct.set(descriptor.namespace, namespace) Object.defineProperty(this, descriptor.namespace, { configurable: true, enumerable: true, value: namespace.value, }) } + try { + Object.defineProperty(namespace.value, descriptor.method, { + configurable: true, + enumerable: true, + value: (...args: unknown[]) => this.invoke(descriptor, undefined, token, this.ownerCtx, args), + }) + } catch (error) { + if (fresh) Reflect.deleteProperty(this, descriptor.namespace) + throw error + } + if (fresh) this.direct.set(descriptor.namespace, namespace) namespace.tokens.set(descriptor.method, token) - Object.defineProperty(namespace.value, descriptor.method, { - configurable: true, - enumerable: true, - value: (...args: unknown[]) => this.invoke(descriptor, undefined, token, this.ownerCtx, args), - }) return () => { /* v8 ignore next -- duplicate live methods are rejected before installation, so no newer token can replace this one. */ if (namespace.tokens.get(descriptor.method) !== token) return @@ -242,7 +254,7 @@ class ClientApiService extends Service implements ClientApi { `client api: ${endpoint} expected ${contract}, got ${String(values.length)}`, ) } - const args: Record = {} + const args = Object.create(null) as Record if (projection !== undefined) { const binder = this.ownerCtx.typert.contexts.getClient(projection.context) if (binder === undefined) { @@ -281,9 +293,12 @@ type InvokeRemote = ( args: readonly unknown[], ) => Promise -class ScopedRemoteNamespace extends Service { +class ScopedRemoteNamespace { + private readonly ctx: Context private readonly ownerCtx: Context private readonly methods = new Set() + private provided = false + readonly name: string static assertMethodAvailable(namespace: string, method: string): void { if (SCOPED_NAMESPACE_FIELDS.has(method) || method in ScopedRemoteNamespace.prototype) { @@ -296,8 +311,12 @@ class ScopedRemoteNamespace extends Service { name: string, private readonly invokeRemote: InvokeRemote, ) { - super(ctx, name) + this.ctx = ctx this.ownerCtx = ctx + this.name = name + Object.defineProperty(this, symbols.tracker, { + value: { associate: name, property: 'ctx' }, + }) } assertMethodAvailable(method: string): void { @@ -309,15 +328,28 @@ class ScopedRemoteNamespace extends Service { install(descriptor: InvocationDescriptor, projection: ScopedProjection, token: MountToken): void { this.assertMethodAvailable(descriptor.method) - if (this.methods.size === 0) this.ownerCtx.set(this.name, this) + const activate = this.methods.size === 0 const method = descriptor.method - Object.defineProperty(this, method, { - configurable: true, - enumerable: true, - value: function (this: ScopedRemoteNamespace, ...args: unknown[]): Promise { - return this.invokeRemote(descriptor, projection, token, this.ctx, args) - }, - }) + try { + Object.defineProperty(this, method, { + configurable: true, + enumerable: true, + value: function (this: ScopedRemoteNamespace, ...args: unknown[]): Promise { + return this.invokeRemote(descriptor, projection, token, this.ctx, args) + }, + }) + if (activate) { + if (this.provided) { + this.ownerCtx.set(this.name, this) + } else { + this.ownerCtx.reflect.provide(this.name, this) + this.provided = true + } + } + } catch (error) { + Reflect.deleteProperty(this, method) + throw error + } this.methods.add(method) } @@ -328,7 +360,7 @@ class ScopedRemoteNamespace extends Service { } } -const SCOPED_NAMESPACE_FIELDS = new Set(['ctx', 'invokeRemote', 'methods', 'name', 'ownerCtx']) +const SCOPED_NAMESPACE_FIELDS = new Set(['ctx', 'invokeRemote', 'methods', 'name', 'ownerCtx', 'provided']) function endpointOf(descriptor: Pick): string { return `${descriptor.namespace}/${descriptor.method}` diff --git a/packages/host/api-gateway/tests/client.spec.ts b/packages/host/api-gateway/tests/client.spec.ts index 5c2c427605..c1f94f2e44 100644 --- a/packages/host/api-gateway/tests/client.spec.ts +++ b/packages/host/api-gateway/tests/client.spec.ts @@ -321,6 +321,34 @@ describe('Client TypeRT API', () => { await disposeScoped() }) + it('rolls back earlier descriptors when a later descriptor fails to install', async () => { + const ctx = await bench(vi.fn()) + const { scope: _scope, ...first } = directDescriptor() + const second: InvocationDescriptor = { + ...first, + id: '@fixture/goals#goals/archive', + method: 'archive', + } + const defineProperty = Object.defineProperty + const spy = vi.spyOn(Object, 'defineProperty').mockImplementation((target, key, attributes) => { + if (key === 'archive') throw new Error('fixture later-descriptor failure') + return defineProperty(target, key, attributes) + }) + try { + expect(() => ctx.api.mount({ package: '@fixture/failing-batch', descriptors: [first, second] })) + .toThrow('fixture later-descriptor failure') + } finally { + spy.mockRestore() + } + + expect((ctx.api as unknown as Record).goals).toBeUndefined() + await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) }) + const retry = ctx.api.mount({ package: '@fixture/retry-batch', descriptors: [first, second] }) + expect(ctx.api.goals.create).toBeTypeOf('function') + expect((ctx.api.goals as unknown as Record).archive).toBeTypeOf('function') + await retry() + }) + it('rejects weak parameter and Context codecs plus malformed scope projections', async () => { const ctx = await bench(vi.fn()) const direct = directDescriptor() @@ -409,6 +437,33 @@ describe('Client TypeRT API', () => { expect((ctx.api as unknown as Record).goals).toBeUndefined() }) + it('preserves a __proto__ wire parameter as an own named argument', async () => { + const call = vi.fn() + .mockResolvedValue({ ok: true, value: { ref: 'goal-1' } }) + const ctx = await bench(call) + const { scope: _scope, ...base } = directDescriptor() + const descriptor: InvocationDescriptor = { + ...base, + id: '@fixture/goals#goals/prototype', + method: 'prototype', + parameters: [{ + name: 'value', + wire: '__proto__', + source: 'json', + codec: { mode: 'strict', typeSymbol: '@fixture#PrototypeValue', schema: z.string() }, + }], + } + const dispose = ctx.api.mount({ package: '@fixture/prototype', descriptors: [descriptor] }) + + const method = (ctx.api.goals as unknown as Record Promise>).prototype + await expect(method?.('wire-value')).resolves.toEqual({ ref: 'goal-1' }) + const payload = call.mock.calls[0]?.[2] as { readonly args: Record } + expect(Object.getPrototypeOf(payload.args)).toBeNull() + expect(Object.hasOwn(payload.args, '__proto__')).toBe(true) + expect(payload.args.__proto__).toBe('wire-value') + await dispose() + }) + it('rolls back Remote registration when concrete method installation fails', async () => { const ctx = await bench(vi.fn()) const defineProperty = Object.defineProperty @@ -423,6 +478,31 @@ describe('Client TypeRT API', () => { } finally { spy.mockRestore() } + + const retry = ctx.api.mount({ package: '@fixture/goals-retry', descriptors: [directDescriptor()] }) + expect(ctx.api.goals.create).toBeTypeOf('function') + await retry() + }) + + it('withdraws a fresh scoped Service when its first method fails to install', async () => { + const ctx = await bench(vi.fn()) + const defineProperty = Object.defineProperty + const spy = vi.spyOn(Object, 'defineProperty').mockImplementation((target, key, attributes) => { + if (key === 'rename') throw new Error('fixture scoped installation failure') + return defineProperty(target, key, attributes) + }) + try { + expect(() => ctx.api.mount({ package: '@fixture/scoped-failure', descriptors: [contextDescriptor()] })) + .toThrow('fixture scoped installation failure') + } finally { + spy.mockRestore() + } + + expect(ctx.get('goals')).toBeUndefined() + await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) }) + const retry = ctx.api.mount({ package: '@fixture/scoped-retry', descriptors: [contextDescriptor()] }) + expect((ctx.get('goals') as unknown as Record).rename).toBeTypeOf('function') + await retry() }) it('throws RPC failures with the structured error as its cause', async () => { diff --git a/packages/typert/generator/src/analyzer.ts b/packages/typert/generator/src/analyzer.ts index 16c30e8bc5..bc5024a7d8 100644 --- a/packages/typert/generator/src/analyzer.ts +++ b/packages/typert/generator/src/analyzer.ts @@ -2810,7 +2810,9 @@ function stringLiteralValue(node: ts.Node | undefined): string | undefined { } function isRemoteSegment(value: string): boolean { - return /^[A-Za-z0-9_$.-]+$/.test(value) + // Generation bootstraps workspace artifacts before dsh-type-meta is built, + // so this extraction-only copy must mirror isTypeRTRemoteSegment(). + return value !== '.' && value !== '..' && /^[A-Za-z0-9_$.-]+$/.test(value) } function expressionName(node: ts.Expression): string | undefined { diff --git a/packages/typert/generator/src/workspace.ts b/packages/typert/generator/src/workspace.ts index 6327872166..4a303c4bd4 100644 --- a/packages/typert/generator/src/workspace.ts +++ b/packages/typert/generator/src/workspace.ts @@ -90,6 +90,7 @@ export class WorkspaceTypertGenerator { throw new TypertAnalysisError(`typert(${artifact.face}): ${artifact.package} package files must include ${file}`) } } + if (artifact.face !== 'host') return const remoteExpected = { types: './lib/typert.remote-client.d.ts', default: './lib/typert.remote-client.js', diff --git a/packages/typert/generator/tests/remote-model.spec.ts b/packages/typert/generator/tests/remote-model.spec.ts index 4f4f3ea7cb..27bdac2fac 100644 --- a/packages/typert/generator/tests/remote-model.spec.ts +++ b/packages/typert/generator/tests/remote-model.spec.ts @@ -274,7 +274,7 @@ export interface BoxPayload { assertRemoteConsumerTypechecks(artifact?.remote?.dts, artifact?.remote?.dtsMap, root) }) - it.each(['create#v2', 'create goal'])('rejects untransportable Remote alias %s', (alias) => { + 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( ' @Remote\n async create(', @@ -301,6 +301,37 @@ export interface RemainingSchema { .toThrow('publishes Remote artifacts but has no Remote methods') }) + it('validates Remote artifacts only on the host face of a dual-face package', () => { + const root = copyFixture() + const manifestPath = join(root, 'packages/remote/package.json') + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as { + dshClient?: object + exports: Record + files: string[] + } + manifest.dshClient = {} + manifest.exports['./client'] = './src/client.ts' + manifest.exports['./client/typert'] = { + types: './lib/typert.client.d.ts', + default: './lib/typert.client.js', + } + manifest.files.push('lib/typert.client.js', 'lib/typert.client.d.ts') + writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`) + writeFileSync(join(root, 'tsconfig.client.json'), `${JSON.stringify({ + extends: './tsconfig.base.json', + files: [], + references: [{ path: './packages/remote' }], + }, null, 2)}\n`) + writeFileSync(join(root, 'packages/remote/src/client.ts'), `/** @typert schema */ +export interface ClientMarker { + readonly ready: boolean +} +`) + + expect(new WorkspaceTypertGenerator(root).generate().map(artifact => artifact.face)) + .toEqual(['host', 'client']) + }) + it.each([ { name: 'missing binding', diff --git a/packages/typert/registry/src/service.ts b/packages/typert/registry/src/service.ts index 2f85138edd..3631253342 100644 --- a/packages/typert/registry/src/service.ts +++ b/packages/typert/registry/src/service.ts @@ -600,7 +600,7 @@ function validateCodec(codec: InvocationDescriptor['result'], subject: string): } function validateWireName(subject: string, value: string): void { - if (!/^[A-Za-z0-9_$.-]+$/.test(value)) { + if (value === '.' || value === '..' || !/^[A-Za-z0-9_$.-]+$/.test(value)) { throw new Error(`typert: invalid ${subject} "${value}" — must contain only RPC endpoint segment characters`) } } diff --git a/packages/typert/registry/tests/typert.spec.ts b/packages/typert/registry/tests/typert.spec.ts index 6661cbeeb4..29654babf7 100644 --- a/packages/typert/registry/tests/typert.spec.ts +++ b/packages/typert/registry/tests/typert.spec.ts @@ -247,7 +247,7 @@ describe('TypertRegistry', () => { })).toThrow('endpoint "goals/create" is already registered') }) - it.each(['create#v2', 'create goal'])('rejects untransportable invocation method %s', async (method) => { + it.each(['create#v2', 'create goal', '.', '..'])('rejects untransportable invocation method %s', async (method) => { const ctx = await makeCtx() expect(() => ctx.typert.remotes.register({ package: '@fixture/invalid-endpoint', diff --git a/packages/typert/type-meta/src/index.ts b/packages/typert/type-meta/src/index.ts index 67a4169f96..3d782dbb77 100644 --- a/packages/typert/type-meta/src/index.ts +++ b/packages/typert/type-meta/src/index.ts @@ -15,7 +15,7 @@ const TYPERT_REMOTE_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/ * @returns whether the value can cross the shared RPC carrier unchanged. */ export function isTypeRTRemoteSegment(value: string): boolean { - return TYPERT_REMOTE_SEGMENT_PATTERN.test(value) + return value !== '.' && value !== '..' && TYPERT_REMOTE_SEGMENT_PATTERN.test(value) } export type { diff --git a/packages/typert/type-meta/tests/type-meta.spec.ts b/packages/typert/type-meta/tests/type-meta.spec.ts index 757488024d..b84b76300c 100644 --- a/packages/typert/type-meta/tests/type-meta.spec.ts +++ b/packages/typert/type-meta/tests/type-meta.spec.ts @@ -164,6 +164,8 @@ describe('type-meta Remote declarations', () => { expect(() => Remote('bad/name')).toThrow('export name') expect(() => Remote('bad#name')).toThrow('export name') expect(() => Remote('bad name')).toThrow('export name') + expect(() => Remote('.')).toThrow('export name') + expect(() => Remote('..')).toThrow('export name') expect(() => RemoteContext('' as 'metaFixture')).toThrow('Context key') expect(() => RemoteContext('metaFixture', 'bad/name')).toThrow('export name') From d9413502278318ad27a849b01a2c59b8cefaea24 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:51:49 +0800 Subject: [PATCH 40/63] fix(typert): preserve remote lookup semantics --- ...08-02-typert-remote-method-calls.i18n.yaml | 4 +- .../2026-08-02-typert-remote-method-calls.md | 12 ++- ...026-08-02-typert-remote-method-calls.zh.md | 12 ++- docs/api-gateway.i18n.yaml | 4 +- docs/api-gateway.md | 6 +- docs/api-gateway.zh.md | 6 +- docs/cordis-catalog/services.md | 6 +- docs/core-data-structures/typert.i18n.yaml | 4 +- docs/core-data-structures/typert.md | 6 +- docs/core-data-structures/typert.zh.md | 6 +- packages/client/ui-goal/src/client/index.ts | 10 +- .../ui-goal/tests/browser-plugin.spec.tsx | 27 ++++-- .../cordis/tool-cordis/src/api-catalog.ts | 2 +- packages/host/api-gateway/README.i18n.yaml | 4 +- packages/host/api-gateway/README.md | 7 +- packages/host/api-gateway/README.zh.md | 7 +- packages/host/api-gateway/src/index.ts | 17 +++- packages/host/api-gateway/src/types.ts | 2 +- .../host/api-gateway/tests/client.spec.ts | 21 ++++ .../host/api-gateway/tests/gateway.spec.ts | 44 ++++++++- packages/host/apiproxy/package.json | 2 + packages/host/apiproxy/src/api-proxy.ts | 18 ++++ .../apiproxy/tests/api-proxy-cold.spec.ts | 96 +++++++++++++++++++ packages/host/apiproxy/tsconfig.json | 6 ++ packages/typert/registry/README.i18n.yaml | 4 +- packages/typert/registry/README.md | 1 + packages/typert/registry/README.zh.md | 1 + packages/typert/registry/src/service.ts | 56 ++++++++++- packages/typert/registry/tests/typert.spec.ts | 34 +++++++ packages/typert/type-meta/README.i18n.yaml | 4 +- packages/typert/type-meta/README.md | 2 +- packages/typert/type-meta/README.zh.md | 2 +- packages/typert/type-meta/src/index.ts | 20 ++++ packages/typert/type-meta/src/types.ts | 31 +++++- pnpm-lock.yaml | 6 ++ 35 files changed, 425 insertions(+), 65 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml index 02e6c428ff..c76dabca3c 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md -2026-08-02-typert-remote-method-calls.md: ddc93b4fc672f320b4e3dc3e11586d92604e6aa4 -2026-08-02-typert-remote-method-calls.zh.md: 808c7d54bff19d9a4e9cf924769df1d405d997b5 +2026-08-02-typert-remote-method-calls.md: d91f6f173c1b56efcd21d3136392837e61f54aae +2026-08-02-typert-remote-method-calls.zh.md: 0c548522d1137f0e0002a740d12ca0b796da5e39 diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md index ddc93b4fc6..d91f6f173c 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md @@ -154,7 +154,7 @@ Descriptors exist only in the local registry on each side. The wire carries only ```text ctx.typert.local 当前进程自己的 Host 或 Client reflection ctx.typert.remotes 消费端显式 mount 的对端 Remote contribution -ctx.typert.lookups wire ID 到 Host 活对象的 provider +ctx.typert.lookups wire ID 到 Host 对象的 provider 与组合策略 ctx.typert.contexts Host Context resolver 与 Client Context binder ``` @@ -162,6 +162,8 @@ Every registration returns a disposer owned by the caller's Cordis fiber. Client The lookup registry retains the stable wire declaration after its live resolver unloads. SRC parsing continues to classify the parameter as a lookup, while invocation fails with `lookup-unavailable`; it never reclassifies the incoming ID as an ordinary JSON business object. Re-registering the same key with different parameter, wire, or canonical type symbols fails for the lifetime of that TypeRT Service. +Business-object packages own stable declarations and default resolvers through `register()`; Host composition supplies an effect-scoped asynchronous policy for the same key through `configure()`. Configuration may precede provider registration, but does not by itself make a lookup available without a live provider; unloading the configuration restores the provider's default resolver. The standard Web Host's API Proxy configures the same `agentFor()` for `agent` and `session`: live Agents are reused, ordinary cold sessions are resumed automatically, concurrent resumes are deduplicated by Session ID, and the subagent ownership fence returns the existing `agent-busy`. The `session` resolver returns the resolved Agent's Session, so the two parameter kinds do not create separate resume lifecycles. + The registry's Host root entry has the complete `TypeRTService` interface merge. The registry implementation shared by Host and Client lives in a separate module without environment declarations. The registry's `/client` entry imports only that shared implementation and does not pass through the Host root entry, so it cannot bring Host Cordis declarations into the Client Program. ## Canonical types, symbols, and Zod @@ -432,7 +434,7 @@ ctx.api.goals.create(sessionId, request, signal?) → Client result codec 验证并返回 CreateGoalResult ``` -Remote does not define a second-layer `{ ok, value/error }` response. Successful values and Gateway errors use the existing RPC response's `result` directly. The current adapter converts every Gateway and business-invocation failure to the existing `RpcError` envelope with `code: 'internal'`; the Gateway's structured error category remains available only in-process, while the message carries the diagnostic across Connection. +Remote does not define a second-layer `{ ok, value/error }` response. Successful values and Gateway errors use the existing RPC response's `result` directly. The adapter converts ordinary Gateway and business-invocation failures to the existing `RpcError` envelope with `code: 'internal'`; an existing RPC error carried by a resolver in `TypeRTLookupFailure` is returned unchanged, preserving stable error codes for cold-resume failures and ownership fences. The Gateway's structured error category remains available only in-process, while the message carries the diagnostic across Connection. The Gateway does not handle per-method permissions, caller identity, idempotency, or long-lived connection state. It only propagates cooperative cancellation from Connection into explicitly cancellation-aware business methods. TypeRT endpoints use Connection's trusted-host policy; unclaimed endpoints retain the legacy API Proxy's trust and privileged-method policies. Connection's WebSocket migration remains separate follow-up work. @@ -451,11 +453,12 @@ The Gateway registers only its ownership matcher and RPC handler with Connection - `@deepseek-ai/dsh-client-remotes`: the only Remote facade depended on by Client business code; directly depends on the Gateway Client face, selects `/remote` contributions, and exposes the merged API types to business packages. - Connection: owns the single HTTP Server/future WebSocket carrier, shared `/api` route and composite FetchHandler, API Proxy fallback, RPC envelope, rpcId, serialization, trust, and error transport. - Business-object packages such as Agent/Session: own lookup, Context providers, canonical ID types, and public type-only entries. +- API Proxy Host composition: configures cold resume, concurrent deduplication, and subagent ownership policy for `agent`/`session` lookups through the existing `agentFor()`. - Business Service packages: declare bindings, Remote methods, and their request/result types, and export the generated `/remote` subpath. ## Shipped scope and deferred work -The shipped vertical path is `@deepseek-ai/dsh-goal/remote → Browser Client API → Connection RPC /api → Host Gateway → GoalService.remoteExportCreate()`. The same direct descriptor with an Agent lookup supports both `ctx.api.goals.create(agentId, request)` and `agentCtx.goals.create(request)`. `@RemoteContext('agent')` remains the distinct scoped-receiver mode. +The shipped vertical path is `@deepseek-ai/dsh-goal/remote → Browser Client API → Connection RPC /api → Host Gateway → GoalService.remoteExportCreate()`. The same direct descriptor with an Agent lookup supports both `ctx.api.goals.create(agentId, request)` and `agentCtx.goals.create(request)`. Ordinary cold sessions are resumed through `agentFor()` during lookup, while subagent-owned identities retain the existing `agent-busy` fence; `@RemoteContext('agent')` remains the distinct scoped-receiver mode. Connection supplies the shared-channel interceptor and current HTTP carrier mapping. WebSocket migration, the TUI runtime and carrier, TUI Agent Scope wiring, Permission/Approval state machines, Session event streams, call authorization, retries, idempotency, and cross-version protocol compatibility remain outside this decision. @@ -486,6 +489,7 @@ Connection supplies the shared-channel interceptor and current HTTP carrier mapp - Importing `@deepseek-ai/dsh-goal/remote` adds the strict `api.goals.create(...)` type and declaration navigation to `remoteExportCreate`; omitting that import omits the namespace. - Mounting the same import's JS contribution supplies endpoint, parameter, result, lookup, Context, and Zod reflection and materializes the call without a handwritten stub. - Root and Agent-scoped calls cross the real shared `/api` carrier, resolve `agentId` to the live Agent, invoke the original Goal receiver, and return through the existing RPC envelope. +- Agent and Session lookups share a single in-flight cold-session resume; ordinary cold sessions receive restored objects, while both cold and live subagent identities return `agent-busy` before business invocation. - The Remote artifacts and maps contain only marked methods and no Browser dependency, preserving the same consumer boundary for a future TUI. - Lifecycle tests withdraw and remount descriptors, Services, lookups, Context providers, and Client namespaces; unavailable dependencies fail without stale calls or raw-ID fallback. - Cancellation tests cover strict generation, SRC final-name recognition, Client signal fusion, Connection-to-Gateway propagation, and Host injection outside wire `args`. @@ -514,3 +518,5 @@ Remote endpoints use Connection's `trusted-host` authority. Loopback is accepted `hasSeen()` favors strict-definition safety over SRC availability. While a strict descriptor is withdrawn, such as during HMR, the Gateway continues to claim the endpoint and reports it unavailable instead of falling back to a weak SRC descriptor. Re-registration restores it; only a TypeRT registry restart forgets the historical strict definition. Cancellation-aware Remote signatures receive Connection's request `AbortSignal`, so an HTTP disconnect or Client-side abort reaches ongoing business work without entering the JSON protocol. Cancellation remains cooperative: methods without the reserved final parameter continue running, and a method that receives the signal must pass it to its own cancellable operations or observe it directly. + +Lookup configuration currently operates at key granularity, so every `agent` or `session` parameter uses the same cold-resume policy. A specific Remote that requires live-only semantics must wait for an explicit per-parameter or per-endpoint policy; the business implementation cannot be left to guess whether the object was just resumed. diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md index 808c7d54bf..0c548522d1 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md @@ -154,7 +154,7 @@ descriptor 只存在于两端本地 registry。wire 上只有 `/api` channel、e ```text ctx.typert.local 当前进程自己的 Host 或 Client reflection ctx.typert.remotes 消费端显式 mount 的对端 Remote contribution -ctx.typert.lookups wire ID 到 Host 活对象的 provider +ctx.typert.lookups wire ID 到 Host 对象的 provider 与组合策略 ctx.typert.contexts Host Context resolver 与 Client Context binder ``` @@ -162,6 +162,8 @@ ctx.typert.contexts Host Context resolver 与 Client Context binder lookup 注册表会在活 resolver 卸载后保留稳定的 wire 声明。SRC 解析仍会把该参数归类为 lookup,而调用会以 `lookup-unavailable` 失败;系统绝不会把传入的 ID 重新归类为普通 JSON 业务对象。在同一个 TypeRT Service 的生命周期内,以不同参数、wire 或规范类型 symbol 重新注册同一 key 会直接失败。 +业务对象包通过 `register()` 拥有稳定声明和默认 resolver;Host 组合通过 `configure()` 为同一个 key 提供 effect-scoped 异步策略。配置可以先于 provider 注册,但没有活 provider 时不会单独形成可用 lookup;配置卸载后恢复 provider 默认 resolver。标准 Web Host 的 API Proxy 为 `agent` 和 `session` 配置同一套 `agentFor()`:live Agent 直接复用,普通冷会话自动恢复,并发恢复按 Session ID 去重,subagent ownership fence 则返回既有 `agent-busy`。`session` resolver 返回解析所得 Agent 的 Session,因而两种参数不会产生两套恢复生命周期。 + Registry 的 Host 根入口拥有完整 `TypeRTService` interface merge;Host 与 Client 共用的 registry 实现位于无环境声明的独立模块。Registry `/client` 入口只引用该共享实现,不经过 Host 根入口,因此不会把 Host Cordis 声明带入 Client Program。 ## 唯一类型、符号与 Zod @@ -432,7 +434,7 @@ ctx.api.goals.create(sessionId, request, signal?) → Client result codec 验证并返回 CreateGoalResult ``` -Remote 不定义第二层 `{ ok, value/error }` response。成功值和 Gateway 错误直接使用既有 RPC response 的 `result`。当前 adapter 把所有 Gateway 与业务调用失败转换为既有 `RpcError` envelope,并统一使用 `code: 'internal'`;Gateway 的结构化错误分类仅在进程内保留,诊断信息则通过 message 跨 Connection 传递。 +Remote 不定义第二层 `{ ok, value/error }` response。成功值和 Gateway 错误直接使用既有 RPC response 的 `result`。adapter 把普通 Gateway 与业务调用失败转换为既有 `RpcError` envelope,并统一使用 `code: 'internal'`;resolver 通过 `TypeRTLookupFailure` 携带的既有 RPC error 则原样返回,使冷恢复失败和 ownership fence 保持稳定错误码。Gateway 的结构化错误分类仅在进程内保留,诊断信息则通过 message 跨 Connection 传递。 Gateway 不处理逐方法权限、调用者身份、幂等或长连接状态。它只把 Connection 的协作式取消传播给显式支持取消的业务方法。TypeRT endpoint 使用 Connection 的 trusted-host 策略;未认领 endpoint 保留旧 API Proxy 的 trust 和 privileged-method 策略。Connection/WebSocket 迁移后续独立完成。 @@ -451,11 +453,12 @@ Gateway 只向 Connection 注册 ownership matcher 和 RPC handler,不注册 H - `@deepseek-ai/dsh-client-remotes`:Client 业务唯一依赖的 Remote facade;直接依赖 Gateway Client face,选择 `/remote` contributions,并向业务包传递合并后的 API 类型。 - Connection:拥有唯一 HTTP Server/未来 WebSocket carrier、共享 `/api` route 与复合 FetchHandler、API Proxy 回退、RPC envelope、rpcId、序列化、trust 和错误传输。 - Agent/Session 等业务对象包:拥有 lookup、Context provider、唯一 ID 类型和纯类型公共出口。 +- API Proxy Host 组合:用既有 `agentFor()` 配置 `agent`/`session` lookup 的冷恢复、并发去重和 subagent ownership 策略。 - 业务 Service 包:声明 binding、Remote 方法及其 request/result 类型,并导出生成的 `/remote` 子路径。 ## 已交付范围与后续工作 -已交付的纵向链路是 `@deepseek-ai/dsh-goal/remote → Browser Client API → Connection RPC /api → Host Gateway → GoalService.remoteExportCreate()`。同一个带 Agent lookup 的 direct descriptor 同时支持 `ctx.api.goals.create(agentId, request)` 与 `agentCtx.goals.create(request)`。`@RemoteContext('agent')` 仍是独立的 scoped receiver 模式。 +已交付的纵向链路是 `@deepseek-ai/dsh-goal/remote → Browser Client API → Connection RPC /api → Host Gateway → GoalService.remoteExportCreate()`。同一个带 Agent lookup 的 direct descriptor 同时支持 `ctx.api.goals.create(agentId, request)` 与 `agentCtx.goals.create(request)`。普通冷会话在 lookup 时通过 `agentFor()` 恢复,subagent-owned identity 保持既有 `agent-busy` fence;`@RemoteContext('agent')` 仍是独立的 scoped receiver 模式。 Connection 提供共享 channel interceptor 与当前 HTTP carrier 映射。WebSocket 迁移、TUI runtime 与 carrier、TUI Agent Scope 接线、Permission/Approval 状态机、Session 事件流、调用授权、重试、幂等及跨版本协议兼容均不属于本决策。 @@ -486,6 +489,7 @@ Connection 提供共享 channel interceptor 与当前 HTTP carrier 映射。WebS - 导入 `@deepseek-ai/dsh-goal/remote` 会加入严格的 `api.goals.create(...)` 类型,并可通过 declaration 导航到 `remoteExportCreate`;不导入时不会出现该 namespace。 - 挂载同一次 import 得到的 JS contribution 会提供 endpoint、参数、结果、lookup、Context 和 Zod 反射,并在无需手写 stub 的情况下实体化调用。 - Root 与 Agent-scoped 调用会经过真实的共享 `/api` carrier,将 `agentId` 解析为活 Agent,调用原始 Goal receiver,并通过既有 RPC envelope 返回。 +- Agent 与 Session lookup 会共享同一次并发冷恢复;普通冷会话得到恢复后的对象,冷态或 live subagent identity 均在业务调用前返回 `agent-busy`。 - Remote 产物与 map 仅包含已标记的方法,不依赖 Browser,从而为未来 TUI 保留相同的消费方边界。 - 生命周期测试会撤回并重新挂载 descriptor、Service、lookup、Context 提供方和 Client namespace;依赖不可用时,调用会失败,且不会使用陈旧调用或回退原始 ID。 - 取消测试覆盖严格生成、SRC 末位参数名识别、Client signal 合并、Connection 到 Gateway 的传播,以及 Host 在 wire `args` 之外的注入。 @@ -514,3 +518,5 @@ Remote endpoint 使用 Connection 的 `trusted-host` authority。系统默认接 `hasSeen()` 优先保障 strict definition 的安全性,而非 SRC 可用性。strict descriptor 撤回时(例如 HMR 期间),Gateway 会继续认领 endpoint 并报告不可用,而不会回退到弱 SRC descriptor。重新注册即可恢复;只有重启 TypeRT 注册表才会忘记历史 strict definition。 支持取消的 Remote 签名会接收 Connection 请求的 `AbortSignal`,因此 HTTP 断连或 Client 侧 abort 能在不进入 JSON 协议的情况下传递到正在进行的业务工作。取消仍是协作式的:没有保留末位参数的方法会继续运行;收到 signal 的方法必须将它传给自身支持取消的操作,或自行观测它。 + +lookup 配置当前以 key 为粒度,因此每个 `agent` 或 `session` 参数都采用同一套冷恢复策略。需要 live-only 语义的特定 Remote 必须等待显式的逐参数或逐 endpoint 策略,不能靠业务实现猜测对象是否刚被恢复。 diff --git a/docs/api-gateway.i18n.yaml b/docs/api-gateway.i18n.yaml index 87abb10c88..58891890d3 100644 --- a/docs/api-gateway.i18n.yaml +++ b/docs/api-gateway.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/api-gateway.md -api-gateway.md: 76af93880d278a17dc46370fd5065fdcdadb9fb6 -api-gateway.zh.md: d447cea6b64bf88084f86a210a5f654bd9445d6c +api-gateway.md: 2e0717fd7b0e5b9ca33d650ffad7ac454046f780 +api-gateway.zh.md: 4d1beebf92cae702dac323cdd974b6220091a214 diff --git a/docs/api-gateway.md b/docs/api-gateway.md index 76af93880d..2e0717fd7b 100644 --- a/docs/api-gateway.md +++ b/docs/api-gateway.md @@ -8,7 +8,7 @@ This is the current-state reference for the TypeRT API Gateway. It describes how Business services use `@Remote` or `@RemoteContext` to select the methods exposed to the Client. Unmarked methods do not enter the generated Client types or runtime contributions and cannot be called through `ctx.api`. -`@Remote` denotes calling a Cordis service registered on the root Host Context. Complex Host objects cannot cross the wire directly; the business package must declare their association with a wire identity through `TypeRTLookupMap` and register a resolution provider with `ctx.typert.lookups` at runtime. For example, an `Agent` parameter named `agent` in the Host signature produces an `agentId` wire field, and the Gateway resolves that id to the current live object before invoking the business method. +`@Remote` denotes calling a Cordis service registered on the root Host Context. Complex Host objects cannot cross the wire directly; the business package must declare their association with a wire identity through `TypeRTLookupMap` and register a default resolution provider with `ctx.typert.lookups` at runtime. For example, an `Agent` parameter named `agent` in the Host signature produces an `agentId` wire field, and the Gateway resolves that id to a Host object before invoking the business method. Host composition can use `ctx.typert.lookups.configure()` to override the resolution policy for a lookup key without changing the parameter name, wire field, or canonical type symbol owned by the business package. `@RemoteContext(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. @@ -117,6 +117,8 @@ The Connection performs the unified trust check for `/api` before the HTTP bridg 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, invokes the service method targeted by the binding, and validates the return value. A missing provider, unknown identity, binding mismatch, missing or extra argument, schema failure, or missing method fails at the boundary 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 `lookup-unavailable`, and unloading the configuration restores the provider's default policy. The standard Web Host's API Proxy configures the same `agentFor()` 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. Resume failures and ownership fences pass through unchanged as existing RPC errors rather than being collapsed into the Gateway's `internal` error. + Unloading a Client contribution removes its descriptors and concrete methods together, aborts its in-flight calls, and makes stale method handles retained by external code reject further calls. A strict endpoint withdrawn on the Host also does not degrade to SRC inference, preventing a hot unload from silently weakening validation. ## SRC development fallback @@ -155,3 +157,5 @@ The running Client watcher consumes these generated files when it rebundles; wit ## Boundaries Remote handles only unary method calls with one request and one result. Session event streams, pagination, incremental reduce, projection, and entity substreams require a separate data protocol and registration model; even when they reuse the Connection, they must not masquerade as Remote methods or enter invocation descriptors. + +Lookup policy is currently configured per key, so all `agent` or `session` parameters share the cold-resume behavior. If a Remote endpoint must accept live objects only, an explicit per-parameter or per-endpoint policy must be added later; the business method must not guess whether the object came from restoration. diff --git a/docs/api-gateway.zh.md b/docs/api-gateway.zh.md index d447cea6b6..4d1beebf92 100644 --- a/docs/api-gateway.zh.md +++ b/docs/api-gateway.zh.md @@ -8,7 +8,7 @@ 业务 Service 通过 `@Remote` 或 `@RemoteContext` 选择对 Client 开放的方法。未标记的方法不会进入生成的 Client 类型或运行时贡献,也不能通过 `ctx.api` 调用。 -`@Remote` 表示调用根 Host Context 中注册的 Cordis Service。复杂的 Host 对象不能直接跨 wire 传输;业务包必须通过 `TypeRTLookupMap` 声明它与 wire identity 的关联,并在运行时向 `ctx.typert.lookups` 注册解析提供方。例如 `Agent` 参数在 Host 签名中名为 `agent`,生成的 wire 字段为 `agentId`,Gateway 在调用业务方法前将 id 解析为当前的实时对象。 +`@Remote` 表示调用根 Host Context 中注册的 Cordis Service。复杂的 Host 对象不能直接跨 wire 传输;业务包必须通过 `TypeRTLookupMap` 声明它与 wire identity 的关联,并在运行时向 `ctx.typert.lookups` 注册默认解析提供方。例如 `Agent` 参数在 Host 签名中名为 `agent`,生成的 wire 字段为 `agentId`,Gateway 在调用业务方法前将 id 解析为 Host 对象。Host 组合可以用 `ctx.typert.lookups.configure()` 覆盖某个 lookup key 的解析策略,而不改变业务包拥有的参数名、wire 字段或规范类型 symbol。 `@RemoteContext(key)` 表示先通过 `ctx.typert.contexts` 把 identity 解析为一个作用域 Context,再从该 Context 取得 Service 并调用方法。它适用于方法本身依赖作用域组合、而不需要显式接收 `Agent` 等对象的情形。 @@ -117,6 +117,8 @@ Connection 在 HTTP bridge 之前执行 `/api` 的统一信任检查,再在共 Gateway 每次调用都从当前注册表解析描述符和实时 Service,不缓存业务对象。它要求 `args` 的字段集合与描述符完全一致,先用 codec 校验 wire 值,再通过注册的 lookup 或 Context provider 解析对象或接收者,最后调用 binding 指向的 Service 方法并校验返回值。缺少 provider、identity 未命中、binding 不一致、参数多缺、schema 失败和方法不存在都在进入或离开业务边界时失败。 +lookup provider 的 `register()` 同时提供稳定声明和默认 resolver;`configure()` 提供由 Host 组合拥有、可异步执行且受 effect 生命周期约束的 resolver。配置可以先于 provider 挂载;没有 provider 时调用仍以 `lookup-unavailable` 失败,配置卸载后则恢复 provider 默认策略。标准 Web Host 的 API Proxy 为 `agent` 与 `session` 配置同一套 `agentFor()` 语义:复用 live Agent,自动恢复普通冷会话,对并发恢复去重,并拒绝由 subagent routing 拥有的 identity;`session` lookup 返回该 Agent 的 Session。恢复失败和 ownership fence 通过既有 RPC error 原样返回,不折叠为 Gateway 的 `internal` 错误。 + Client 卸载一个贡献时会一起移除描述符和具体方法,中止其进行中的调用,并使外部仍持有的旧方法句柄拒绝继续调用。Host 上已经注册过的严格 endpoint 被撤回后也不会降级到 SRC 推断,以免热卸载悄然降低校验强度。 ## SRC 开发回退 @@ -155,3 +157,5 @@ pnpm run build:lib:contracts ## 边界 Remote 只处理有单个请求与单个结果的一元方法调用。Session event stream、分页、增量 reduce、projection 和实体子流需要独立的数据协议与注册模型;即使它们复用 Connection,也不应伪装成 Remote 方法或放入调用描述符。 + +当前 lookup 策略按 key 配置,因此所有 `agent` 或 `session` 参数共享冷恢复行为。某个 Remote endpoint 若必须只接受 live 对象,需要后续增加显式的逐参数或逐 endpoint 策略,不能通过业务方法内部猜测恢复来源。 diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 8a646fc177..8acd669131 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -2593,7 +2593,7 @@ listPackages(filter: TypertPackageFilter = {}): TypertPackageRecord[] toJSONSchema(key: string, params?: z.core.ToJSONSchemaParams): z.core.JSONSchema.BaseSchema ``` -Source: [`packages/typert/registry/src/service.ts:346`](../../packages/typert/registry/src/service.ts) +Source: [`packages/typert/registry/src/service.ts:400`](../../packages/typert/registry/src/service.ts) ## `ctx.typertGateway` — `TypertGatewayService` @@ -2604,12 +2604,12 @@ Resolve strict generated definitions or conservative SRC markers against current * Invoke one live Remote method through strict generated reflection or SRC markers. * @param request - decoded endpoint and exact named wire arguments. * @returns the validated business result. - * @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; business errors retain their identity. + * @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; lookup-policy and business errors retain identity. */ async invoke(request: InvokeRemoteRequest): Promise ``` -Source: [`packages/host/api-gateway/src/index.ts:76`](../../packages/host/api-gateway/src/index.ts) +Source: [`packages/host/api-gateway/src/index.ts:78`](../../packages/host/api-gateway/src/index.ts) ## `ctx.userInteraction` — `UserInteractionService` diff --git a/docs/core-data-structures/typert.i18n.yaml b/docs/core-data-structures/typert.i18n.yaml index a5484d06c4..5b0b70de54 100644 --- a/docs/core-data-structures/typert.i18n.yaml +++ b/docs/core-data-structures/typert.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/typert.md -typert.md: da6e229ff6a2300c36f5734ad05c621a5e63082d -typert.zh.md: b3b0e8897756b5b4f9b645522cc5a1b27eac1d33 +typert.md: 1ff0fe80e483d481f686336c86038cdd169ecdbc +typert.zh.md: 3cc0aa26406e01db5a6c05074210fc9d40b8ec00 diff --git a/docs/core-data-structures/typert.md b/docs/core-data-structures/typert.md index da6e229ff6..1ff0fe80e4 100644 --- a/docs/core-data-structures/typert.md +++ b/docs/core-data-structures/typert.md @@ -114,7 +114,7 @@ interface InvocationDescriptor { ## TypeRT registry -`ctx.typert` separates current-environment descriptors, explicitly selected Remote contributions, live lookup providers, and scoped Context providers. Registrations are Cordis-owned effects and return awaitable disposers. +`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. ```ts type-equiv /** Minimal TypeRT runtime consumed through dependency inversion. */ @@ -135,7 +135,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 use the Gateway's in-process error taxonomy, although the current RPC adapter folds them into the transport's `internal` error code. +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 use the Gateway's in-process error taxonomy, ordinary exceptions are folded by the RPC adapter into the transport's `internal` error code, and existing RPC errors carried by lookup policy through `TypeRTLookupFailure` are returned unchanged. ```ts type-equiv /** One Remote method request after a carrier has decoded its envelope. */ @@ -180,7 +180,7 @@ interface TypertGateway { * Invoke one live Remote method without assuming a carrier or response envelope. * @param request - decoded endpoint and named wire arguments. * @returns the validated business result. - * @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; business errors retain their identity. + * @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; lookup-policy and business errors retain identity. */ invoke(request: InvokeRemoteRequest): Promise } diff --git a/docs/core-data-structures/typert.zh.md b/docs/core-data-structures/typert.zh.md index b3b0e88977..3cc0aa2640 100644 --- a/docs/core-data-structures/typert.zh.md +++ b/docs/core-data-structures/typert.zh.md @@ -114,7 +114,7 @@ interface InvocationDescriptor { ## TypeRT 注册表 -`ctx.typert` 分开保存当前环境的 descriptor、显式选择的 Remote contribution、活 lookup 提供方与 scoped Context 提供方。各项注册都是由 Cordis 持有的 effect,并返回可等待的 disposer。 +`ctx.typert` 分开保存当前环境的 descriptor、显式选择的 Remote contribution、lookup 提供方与 scoped Context 提供方。lookup 提供方拥有稳定 wire 声明和默认 resolver;Host 组合可以为同一个 key 配置 effect-scoped 同步或异步 resolver,配置卸载后恢复默认策略。各项注册都是由 Cordis 持有的 effect,并返回可等待的 disposer。 ```ts type-equiv /** Minimal TypeRT runtime consumed through dependency inversion. */ @@ -135,7 +135,7 @@ interface TypeRTRemoteNamespaceMap {} ## Host Gateway -Connection 会先解码 carrier envelope,再调用 `ctx.typertGateway`。请求将精确的具名 wire 字段与 carrier 的取消 signal 分开携带;基础设施与边界失败使用 Gateway 的进程内错误分类体系,但当前 RPC 适配器会把这些错误折叠为传输层的 `internal` 错误码。 +Connection 会先解码 carrier envelope,再调用 `ctx.typertGateway`。请求将精确的具名 wire 字段与 carrier 的取消 signal 分开携带;基础设施与边界失败使用 Gateway 的进程内错误分类体系,普通异常由 RPC 适配器折叠为传输层的 `internal` 错误码,lookup 策略通过 `TypeRTLookupFailure` 携带的既有 RPC error 则原样返回。 ```ts type-equiv /** One Remote method request after a carrier has decoded its envelope. */ @@ -180,7 +180,7 @@ interface TypertGateway { * Invoke one live Remote method without assuming a carrier or response envelope. * @param request - decoded endpoint and named wire arguments. * @returns the validated business result. - * @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; business errors retain their identity. + * @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; lookup-policy and business errors retain identity. */ invoke(request: InvokeRemoteRequest): Promise } diff --git a/packages/client/ui-goal/src/client/index.ts b/packages/client/ui-goal/src/client/index.ts index 19b88139b5..8fcfd292d2 100644 --- a/packages/client/ui-goal/src/client/index.ts +++ b/packages/client/ui-goal/src/client/index.ts @@ -70,8 +70,6 @@ function isRemoteError(value: unknown): value is { readonly code: string; readon export function apply(ctx: ClientContext): void { ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-goal: dictionaries') - const { goals } = ctx.api - const sessions = ctx.sessions /** The session's current projected CAS ref, read at verb call time (no staleness fence: the RPC's CAS is the guard). */ @@ -96,22 +94,22 @@ export function apply(ctx: ClientContext): void { onEdit: async (objective) => { const ref = refOf(sessionId) if (ref === undefined) return noCurrentGoal - return settle(goals.edit(sessionId, ref, { objective })) + return settle(ctx.api.goals.edit(sessionId, ref, { objective })) }, onPause: async () => { const ref = refOf(sessionId) if (ref === undefined) return noCurrentGoal - return settle(goals.pause(sessionId, ref)) + return settle(ctx.api.goals.pause(sessionId, ref)) }, onResume: async () => { const ref = refOf(sessionId) if (ref === undefined) return noCurrentGoal - return settle(goals.resume(sessionId, ref)) + return settle(ctx.api.goals.resume(sessionId, ref)) }, onClear: async () => { const ref = refOf(sessionId) if (ref === undefined) return noCurrentGoal - return settle(goals.clear(sessionId, ref)) + return settle(ctx.api.goals.clear(sessionId, ref)) }, }), }, GoalDock)) diff --git a/packages/client/ui-goal/tests/browser-plugin.spec.tsx b/packages/client/ui-goal/tests/browser-plugin.spec.tsx index eddb272be4..11c95e27d9 100644 --- a/packages/client/ui-goal/tests/browser-plugin.spec.tsx +++ b/packages/client/ui-goal/tests/browser-plugin.spec.tsx @@ -64,12 +64,16 @@ async function bench(options: { } } const ref = { id: 'g-1', revision: 3 } - ctx.provide('api', { goals: { - edit: answer('goals/edit', { ref }), - pause: answer('goals/pause', { ref }), - resume: answer('goals/resume', { ref }), - clear: answer('goals/clear', ref), - } }) + const goals = (prefix: string) => ({ + edit: answer(`${prefix}/edit`, { ref }), + pause: answer(`${prefix}/pause`, { ref }), + resume: answer(`${prefix}/resume`, { ref }), + clear: answer(`${prefix}/clear`, ref), + }) + let activeGoals = goals('goals') + ctx.provide('api', { + get goals() { return activeGoals }, + }) await ctx.plugin(SlotsService).await() ctx.slots.register({ name: 'root', children: { 'conversation.input.dock': { kind: 'list', scope: 'session' } }, @@ -90,6 +94,7 @@ async function bench(options: { ctx, fiber, calls, + remountGoals: () => { activeGoals = goals('remounted-goals') }, entry: () => { const entry = ctx.slots.entries('conversation.input.dock')[0] if (entry === undefined) return undefined @@ -126,6 +131,16 @@ describe('ui-goal browser plugin', () => { expect(b.calls[3]?.args).toEqual(['s1', ref]) }) + it('verbs read a remounted Remote namespace at action time', async () => { + const b = await bench({ projection: makeProjection() }) + await b.fiber.await() + const verbs = b.entry()!.inject!(sid('s1')) + b.remountGoals() + + expect(await verbs.onPause()).toEqual({ ok: true }) + expect(b.calls).toMatchObject([{ method: 'remounted-goals/pause' }]) + }) + it('a null or absent projection short-circuits every verb without touching the wire', async () => { for (const projection of [null, undefined]) { const b = await bench({ projection }) diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 2627d43b69..b7fd6d3c5a 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -1160,7 +1160,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ methods: [ { signature: 'async invoke(request: InvokeRemoteRequest): Promise', - jsDoc: '/**\n * Invoke one live Remote method through strict generated reflection or SRC markers.\n * @param request - decoded endpoint and exact named wire arguments.\n * @returns the validated business result.\n * @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; business errors retain their identity.\n */', + jsDoc: '/**\n * Invoke one live Remote method through strict generated reflection or SRC markers.\n * @param request - decoded endpoint and exact named wire arguments.\n * @returns the validated business result.\n * @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; lookup-policy and business errors retain identity.\n */', }, ], }, diff --git a/packages/host/api-gateway/README.i18n.yaml b/packages/host/api-gateway/README.i18n.yaml index 273a493c24..8d8d699c7a 100644 --- a/packages/host/api-gateway/README.i18n.yaml +++ b/packages/host/api-gateway/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/api-gateway/README.md -README.md: 43e8f464e2a2790d05628a7fba61143a6a5ab26a -README.zh.md: 761045d0c1afc17dfc230f9f45849c46e4e579fc +README.md: eb48c29628d39e381235b1f72754eb114960b1ad +README.zh.md: e53bb6c216e42fe2e970bf2cb80eac9ea7426497 diff --git a/packages/host/api-gateway/README.md b/packages/host/api-gateway/README.md index 43e8f464e2..eb48c29628 100644 --- a/packages/host/api-gateway/README.md +++ b/packages/host/api-gateway/README.md @@ -8,9 +8,9 @@ Two-sided Remote control for Host and Client Cordis environments. The Host entry `ctx.typertGateway.invoke()` resolves the current descriptor and Cordis Service for each call, validates exact named arguments, resolves registered object or Context identities, invokes the public business method, and validates its result. Business Services extend `GatewayService` and mark methods with `@Remote` or `@RemoteContext` from [`dsh-type-meta`](../../typert/type-meta/README.md); `bindTypeRTGateway()` remains available when another base class owns inheritance. -Strict mode reads generated invocation descriptors from `ctx.typert.local`. Lookup parameters use registered `ctx.typert.lookups` providers, while `@RemoteContext` resolves its receiver through a registered Host Context provider. SRC mode is a development fallback for endpoints that have never had a strict definition; it parses simple parameter names and accepts only JSON-safe values for non-lookup parameters. Withdrawing an observed strict definition fails instead of weakening validation. +Strict mode reads generated invocation descriptors from `ctx.typert.local`. Lookup parameters use the currently active resolver in `ctx.typert.lookups`: the business package registers the stable declaration and default policy, while Host composition can override resolution behavior with effect-scoped `configure()`; `@RemoteContext` resolves its receiver through a registered Host Context provider. SRC mode is a development fallback for endpoints that have never had a strict definition; it parses simple parameter names and accepts only JSON-safe values for non-lookup parameters. Withdrawing an observed strict definition fails instead of weakening validation. -The Host entry registers a trusted-host interceptor on Connection's shared `/api` FetchHandler. Connection passes this composite handler through its HTTP bridge; the handler dispatches claimed endpoints to Gateway and unclaimed endpoints to API Proxy. Direct `invoke()` calls preserve business errors; `TypertGatewayError` distinguishes failures owned by dispatch, binding, providers, lookup, Context, arguments, and codecs. +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 unclaimed endpoints to API Proxy. Direct `invoke()` calls preserve business errors; `TypertGatewayError` distinguishes failures owned by dispatch, binding, providers, lookup, Context, arguments, and codecs. A resolver may use `TypeRTLookupFailure` to carry an existing RPC error, preserving its original error code for policy rejections such as cold-resume failures or ownership fences. 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. @@ -32,7 +32,8 @@ No direct effect; invoked business Services own any model-visible result. ## Known Limitations and Deferred Work -- The Connection adapter currently maps dispatch and business failures to the RPC `internal` code with empty details. Structured `TypertGatewayError` categories remain available only to same-process callers. +- The Connection adapter maps ordinary dispatch failures and business exceptions to the RPC `internal` code with empty details; lookup-policy errors carried by `TypeRTLookupFailure` are returned unchanged. Structured `TypertGatewayError` categories remain available only to same-process callers. - SRC mode supports unique identifier parameters without destructuring, defaults, or rest parameters. It validates JSON safety rather than generated business types and never infers optional fields. - Only strict generated contributions can mount on the Client face. SRC markers have no Client codec or type projection. - The package dispatches unary methods only. Incremental Session data uses a separate named-stream protocol over the same Connection. +- Lookup resolvers are configured per key; an individual Remote parameter or endpoint cannot currently select a live-only policy under the same `agent`/`session` key. diff --git a/packages/host/api-gateway/README.zh.md b/packages/host/api-gateway/README.zh.md index 761045d0c1..e53bb6c216 100644 --- a/packages/host/api-gateway/README.zh.md +++ b/packages/host/api-gateway/README.zh.md @@ -8,9 +8,9 @@ 每次调用时,`ctx.typertGateway.invoke()` 都会解析当前的描述符和 Cordis 服务,校验具名参数是否完全匹配,解析已注册的对象或 Context 身份标识,调用公开的业务方法,并校验其结果。业务服务继承 [`dsh-type-meta`](../../typert/type-meta/README.md) 的 `GatewayService`,并用 `@Remote` 或 `@RemoteContext` 标记方法;已有其他基类时仍可改用 `bindTypeRTGateway()`。 -严格模式从 `ctx.typert.local` 读取生成的调用描述符。查找参数使用已向 `ctx.typert.lookups` 注册的提供方,`@RemoteContext` 则通过已注册的 Host Context 提供方解析其接收者。SRC 模式是开发阶段的回退路径,适用于从未具备严格定义的端点;它解析简单参数名,并且只允许非查找参数使用可安全表示为 JSON 的值。已观测到的严格定义一旦撤回,系统会直接报错,而不会降低校验强度。 +严格模式从 `ctx.typert.local` 读取生成的调用描述符。查找参数使用 `ctx.typert.lookups` 中当前有效的 resolver:业务包注册稳定声明与默认策略,Host 组合可用 effect-scoped `configure()` 覆盖解析行为;`@RemoteContext` 则通过已注册的 Host Context 提供方解析其接收者。SRC 模式是开发阶段的回退路径,适用于从未具备严格定义的端点;它解析简单参数名,并且只允许非查找参数使用可安全表示为 JSON 的值。已观测到的严格定义一旦撤回,系统会直接报错,而不会降低校验强度。 -Connection 可用时,Host 入口会在 Connection 共享的 `/api` FetchHandler 上注册 trusted-host interceptor。Connection 把这个复合 handler 交给 HTTP bridge;handler 将已认领 endpoint 分发给 Gateway,未认领 endpoint 则交给 API Proxy。直接调用 `invoke()` 会保留业务错误;`TypertGatewayError` 可区分分发、绑定、提供方、查找、Context、参数和编解码器各自负责的故障。 +Connection 可用时,Host 入口会在 Connection 共享的 `/api` FetchHandler 上注册 trusted-host interceptor。Connection 把这个复合 handler 交给 HTTP bridge;handler 将已认领 endpoint 分发给 Gateway,未认领 endpoint 则交给 API Proxy。直接调用 `invoke()` 会保留业务错误;`TypertGatewayError` 可区分分发、绑定、提供方、查找、Context、参数和编解码器各自负责的故障。resolver 可以用 `TypeRTLookupFailure` 携带既有 RPC error,使冷恢复失败或 ownership fence 等策略拒绝保持原错误码。 支持取消的 Remote 方法会把 `signal: AbortSignal` 声明为最后一个 Host 参数。signal 是 descriptor 元数据,而不是 wire 参数:Connection 将它提供给 Gateway,Gateway 则在已解码的业务参数之后注入它。SRC 识别这个保留的末位参数名,严格生成还要求它具有全局 `AbortSignal` 类型。 @@ -32,7 +32,8 @@ Connection 可用时,Host 入口会在 Connection 共享的 `/api` FetchHandle ## 已知限制与延期工作 -- Connection 适配器目前将分发故障和业务故障映射为 RPC 的 `internal` 代码,且不附带详细信息。结构化的 `TypertGatewayError` 类别仅供同进程调用方使用。 +- Connection 适配器将普通分发故障和业务异常映射为 RPC 的 `internal` 代码,且不附带详细信息;`TypeRTLookupFailure` 携带的 lookup 策略错误会原样返回。结构化的 `TypertGatewayError` 类别仅供同进程调用方使用。 - SRC 模式仅支持名称唯一的标识符参数,不支持解构、默认值或剩余参数。它只校验值能否安全表示为 JSON,不校验生成的业务类型,也绝不会推断可选字段。 - Client 侧只能挂载严格模式生成的贡献项。SRC 标记不具备 Client 编解码器或类型投影。 - 该包只分发一元方法。增量会话数据通过同一个 Connection 上独立的具名流协议传输。 +- lookup resolver 按 key 配置;当前无法让单个 Remote 参数或 endpoint 在同一 `agent`/`session` key 下选择 live-only 策略。 diff --git a/packages/host/api-gateway/src/index.ts b/packages/host/api-gateway/src/index.ts index 7dd2410873..8ea26b5990 100644 --- a/packages/host/api-gateway/src/index.ts +++ b/packages/host/api-gateway/src/index.ts @@ -8,6 +8,7 @@ import { Context, Service, symbols } from 'cordis' import type { ConnectionRpcHandler } from '@deepseek-ai/dsh-client-connection' import { remoteMethods, + TypeRTLookupFailure, type InvocationDescriptor, type InvocationParameterDescriptor, type TypeRTCodec, @@ -36,6 +37,7 @@ interface ResolvedBinding { } type ConnectionRpcResult = Awaited> +type ConnectionRpcError = Extract['error'] const NEVER_ABORTED_SIGNAL = new AbortController().signal /** Dispatch failure produced outside the invoked business method. */ @@ -126,7 +128,7 @@ export class TypertGatewayService extends Service implements TypertGateway { * Invoke one live Remote method through strict generated reflection or SRC markers. * @param request - decoded endpoint and exact named wire arguments. * @returns the validated business result. - * @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; business errors retain their identity. + * @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; lookup-policy and business errors retain identity. */ async invoke(request: InvokeRemoteRequest): Promise { const endpoint = endpointOf(request.namespace, request.method) @@ -142,7 +144,8 @@ export class TypertGatewayService extends Service implements TypertGateway { ) } validateBinding(receiver, descriptor.service, descriptor.namespace, endpoint) - const args = descriptor.parameters.map(parameter => this.resolveParameter(parameter, request.args, endpoint)) + const args = await Promise.all(descriptor.parameters.map(parameter => + this.resolveParameter(parameter, request.args, endpoint))) if (descriptor.cancellation !== undefined) args.push(request.signal ?? NEVER_ABORTED_SIGNAL) const implementation = descriptor.implementation ?? descriptor.method const method = Reflect.get(receiver, implementation) as unknown @@ -375,11 +378,11 @@ export class TypertGatewayService extends Service implements TypertGateway { return context } - private resolveParameter( + private async resolveParameter( parameter: InvocationParameterDescriptor, args: Readonly>, endpoint: string, - ): unknown { + ): Promise { const value = decode(parameter.codec, args[parameter.wire], 'input-invalid', endpoint, parameter.wire) if (parameter.source === 'json') return value const key = parameter.lookup @@ -412,8 +415,9 @@ export class TypertGatewayService extends Service implements TypertGateway { } let resolved: unknown try { - resolved = provider.resolve(value) + resolved = await provider.resolve(value) } catch (cause) { + if (cause instanceof TypeRTLookupFailure) throw cause throw new TypertGatewayError( 'lookup-failed', endpoint, @@ -434,6 +438,9 @@ export class TypertGatewayService extends Service implements TypertGateway { } function rpcFailure(error: unknown): ConnectionRpcResult { + if (error instanceof TypeRTLookupFailure) { + return { ok: false, error: error.failure as ConnectionRpcError } + } return { ok: false, error: { diff --git a/packages/host/api-gateway/src/types.ts b/packages/host/api-gateway/src/types.ts index b7f36eb340..f4bb276c22 100644 --- a/packages/host/api-gateway/src/types.ts +++ b/packages/host/api-gateway/src/types.ts @@ -41,7 +41,7 @@ export interface TypertGateway { * Invoke one live Remote method without assuming a carrier or response envelope. * @param request - decoded endpoint and named wire arguments. * @returns the validated business result. - * @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; business errors retain their identity. + * @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; lookup-policy and business errors retain identity. */ invoke(request: InvokeRemoteRequest): Promise } diff --git a/packages/host/api-gateway/tests/client.spec.ts b/packages/host/api-gateway/tests/client.spec.ts index c1f94f2e44..2fbcbb9280 100644 --- a/packages/host/api-gateway/tests/client.spec.ts +++ b/packages/host/api-gateway/tests/client.spec.ts @@ -484,6 +484,27 @@ describe('Client TypeRT API', () => { await retry() }) + it('withdraws a fresh direct namespace when its first method fails to install', async () => { + const ctx = await bench(vi.fn()) + const defineProperty = Object.defineProperty + const spy = vi.spyOn(Object, 'defineProperty').mockImplementation((target, key, attributes) => { + if (key === 'create') throw new Error('fixture direct method installation failure') + return defineProperty(target, key, attributes) + }) + try { + expect(() => ctx.api.mount({ package: '@fixture/direct-method-failure', descriptors: [directDescriptor()] })) + .toThrow('fixture direct method installation failure') + } finally { + spy.mockRestore() + } + + expect((ctx.api as unknown as Record).goals).toBeUndefined() + await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) }) + const retry = ctx.api.mount({ package: '@fixture/direct-method-retry', descriptors: [directDescriptor()] }) + expect(ctx.api.goals.create).toBeTypeOf('function') + await retry() + }) + it('withdraws a fresh scoped Service when its first method fails to install', async () => { const ctx = await bench(vi.fn()) const defineProperty = Object.defineProperty diff --git a/packages/host/api-gateway/tests/gateway.spec.ts b/packages/host/api-gateway/tests/gateway.spec.ts index aebe23da57..0871dc2761 100644 --- a/packages/host/api-gateway/tests/gateway.spec.ts +++ b/packages/host/api-gateway/tests/gateway.spec.ts @@ -9,6 +9,7 @@ import { bindTypeRTGateway, Remote, RemoteContext, + TypeRTLookupFailure, type InvocationDescriptor, type TypeRTContext, type TypeRTLookup, @@ -91,7 +92,7 @@ class GoalService extends Service { type FakeRpcResult = | { readonly ok: true; readonly value: unknown } - | { readonly ok: false; readonly error: { readonly code: 'internal'; readonly message: string; readonly details: object } } + | { readonly ok: false; readonly error: { readonly code: string; readonly message: string; readonly details: object } } type FakeRpcHandler = (endpoint: string, payload: unknown, signal: AbortSignal) => Promise @@ -568,7 +569,7 @@ describe('TypertGatewayService', () => { registerStrict(ctx, [createDescriptor()]) const throwing = ctx.typert.lookups.register('gatewayFixture', { ...agentLookup({ id: 'agent-1' }), - resolve: () => { throw new Error('lookup failed') }, + resolve: async () => { throw new Error('lookup failed') }, }) const failure = await expectCode(ctx.typertGateway.invoke({ namespace: 'goals', @@ -578,15 +579,26 @@ describe('TypertGatewayService', () => { expect(failure.cause).toEqual(new Error('lookup failed')) await throwing() - ctx.typert.lookups.register('gatewayFixture', { + const missing = ctx.typert.lookups.register('gatewayFixture', { ...agentLookup({ id: 'agent-1' }), - resolve: () => undefined, + resolve: () => Promise.resolve(undefined), }) await expectCode(ctx.typertGateway.invoke({ namespace: 'goals', method: 'create', args: { agentId: 'agent-1', request: { title: 'ship' } }, }), 'lookup-not-found') + await missing() + + ctx.typert.lookups.register('gatewayFixture', { + ...agentLookup({ id: 'agent-1' }), + resolve: async id => ({ id }), + }) + await expect(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'create', + args: { agentId: 'agent-1', request: { title: 'ship' } }, + })).resolves.toMatchObject({ agentId: 'agent-1', title: 'ship' }) }) it('never downgrades an observed strict endpoint after definition disposal', async () => { @@ -968,6 +980,30 @@ describe('TypertGatewayService', () => { expect(connection.handler).toBeUndefined() }) + it('preserves a lookup policy rejection through the Connection RPC result', async () => { + const ctx = new Context() + await ctx.plugin(TypertRegistry) + await ctx.plugin(FakeConnectionService) + await ctx.plugin(TypertGatewayService) + await ctx.plugin(GoalService) + registerStrict(ctx, [createDescriptor()]) + const failure = { + code: 'agent-busy', + message: 'session is owned by subagent routing', + details: { reason: 'use subagent delivery for this child session' }, + } + ctx.typert.lookups.register('gatewayFixture', { + ...agentLookup({ id: 'agent-1' }), + resolve: () => { throw new TypeRTLookupFailure(failure) }, + }) + const handler = rawConnection(ctx).handler + if (handler === undefined) throw new Error('fixture Connection did not retain the /api interceptor') + + await expect(handler('goals/create', { + args: { agentId: 'agent-1', request: { title: 'ship' } }, + }, new AbortController().signal)).resolves.toEqual({ ok: false, error: failure }) + }) + it('caches SRC ownership until the Cordis Service set changes', async () => { const ctx = new Context() await ctx.plugin(TypertRegistry) diff --git a/packages/host/apiproxy/package.json b/packages/host/apiproxy/package.json index 426860eebc..ce740a025f 100644 --- a/packages/host/apiproxy/package.json +++ b/packages/host/apiproxy/package.json @@ -56,6 +56,8 @@ "@deepseek-ai/dsh-skill": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/dsh-type-meta": "workspace:^", + "@deepseek-ai/dsh-typert-registry": "workspace:^", "@deepseek-ai/dsh-user-approval": "workspace:^", "@deepseek-ai/dsh-user-interaction": "workspace:^", "@deepseek-ai/dsh-workspace": "workspace:^", diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 2a54e2c113..f2c199feca 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -19,6 +19,9 @@ import { SessionQueryError, type SessionSearchCursor } from '@deepseek-ai/dsh-se import { SubagentError } from '@deepseek-ai/dsh-subagent' import type { SubagentListEntry as CatalogSubagentListEntry } from '@deepseek-ai/dsh-subagent' import type { Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace' +import { TypeRTLookupFailure } from '@deepseek-ai/dsh-type-meta' +// Type-only: resolves the optional `ctx.typert` lookup-policy composition. +import type {} from '@deepseek-ai/dsh-typert-registry' import { workspaceDomainState, workspaceRecord, WorkspaceId as brandWorkspaceId, WorkspaceMoveInvalidError, WorkspaceUnknownSessionError, @@ -1099,6 +1102,21 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro } } + // Remote object parameters use the same identity policy as API Proxy methods: + // ordinary cold sessions resume once, while subagent-owned identities retain + // their stable caller-facing rejection. The provider packages continue to + // own wire declarations and live-only defaults; this Host composition owns + // the broader lookup policy. + ctx.inject(['typert'], (typeCtx) => { + const resolveAgent = async (sessionId: SessionId): Promise => { + const found = await agentFor(sessionId) + if ('error' in found) throw new TypeRTLookupFailure(found.error) + return found.agent + } + typeCtx.typert.lookups.configure('agent', resolveAgent) + typeCtx.typert.lookups.configure('session', async sessionId => (await resolveAgent(sessionId)).session) + }) + type SessionReadState = { id: SessionId header: SessionHeader diff --git a/packages/host/apiproxy/tests/api-proxy-cold.spec.ts b/packages/host/apiproxy/tests/api-proxy-cold.spec.ts index 4b6337ede8..e5e137f0c4 100644 --- a/packages/host/apiproxy/tests/api-proxy-cold.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-cold.spec.ts @@ -11,6 +11,8 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import SessionStore from '@deepseek-ai/dsh-session' import AgentRegistry from '@deepseek-ai/dsh-agent' +import { TypeRTLookupFailure } from '@deepseek-ai/dsh-type-meta' +import TypertRegistry from '@deepseek-ai/dsh-typert-registry' import { MessageId } from '@deepseek-ai/dsh-llm' import type { Agent } from '@deepseek-ai/dsh-agent' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' @@ -180,6 +182,100 @@ describe('cold history recovery view', () => { }) }) +describe('Remote Agent and Session lookup policy', () => { + it('deduplicates a cold resume across Agent and Session parameters', async () => { + const ctx = new Context() + await ctx.plugin(TypertRegistry) + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + await ctx.plugin(UserInteractionService) + const sessionId = sid('session-remote-cold') + const meta = header(sessionId, 1000) + const inspect = vi.fn(() => Promise.resolve({ meta, events: [] as SessionEvent[] })) + ctx.provide('sessionPersistence', { + list: () => Promise.resolve([meta]), + inspect, + locate: () => undefined, + } as never) + const resumedSession = { id: sessionId, header: meta, events: [] } as unknown as import('@deepseek-ai/dsh-session').Session + const resumedAgent = { id: sessionId, session: resumedSession, status: 'idle', ctx } as Agent + const release = Promise.withResolvers() + const resume = vi.spyOn(ctx.agents, 'resume').mockImplementation(async () => { + await release.promise + return { agent: resumedAgent, dispose: () => Promise.resolve() } + }) + const defaultAgentLookup = ctx.typert.lookups.get('agent') + const defaultSessionLookup = ctx.typert.lookups.get('session') + createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + await vi.waitFor(() => { + expect(ctx.typert.lookups.get('agent')).not.toBe(defaultAgentLookup) + expect(ctx.typert.lookups.get('session')).not.toBe(defaultSessionLookup) + }) + const agentLookup = ctx.typert.lookups.get('agent') + const sessionLookup = ctx.typert.lookups.get('session') + if (agentLookup === undefined || sessionLookup === undefined) throw new Error('core lookup providers were not mounted') + + const resolvedAgent = Promise.resolve(agentLookup.resolve(sessionId)) + const resolvedSession = Promise.resolve(sessionLookup.resolve(sessionId)) + await vi.waitFor(() => { expect(resume).toHaveBeenCalledOnce() }) + release.resolve(undefined) + + await expect(resolvedAgent).resolves.toBe(resumedAgent) + await expect(resolvedSession).resolves.toBe(resumedSession) + expect(inspect).toHaveBeenCalledOnce() + }) + + it('preserves the subagent ownership fence for cold and live Remote lookups', async () => { + const ctx = new Context() + await ctx.plugin(TypertRegistry) + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + await ctx.plugin(UserInteractionService) + const coldId = sid('session-remote-cold-child') + const coldMeta = header(coldId, 1000, { + parentSession: sid('session-parent'), + origin: 'subagent', + }) + const inspect = vi.fn(() => Promise.resolve({ meta: coldMeta, events: [] as SessionEvent[] })) + ctx.provide('sessionPersistence', { + list: () => Promise.resolve([coldMeta]), + inspect, + locate: () => undefined, + } as never) + const liveSession = ctx.sessions.create(sid('session-remote-live-child'), { + meta: { cwd: '/proj', parentSession: sid('session-parent'), origin: 'subagent' }, + }) + const liveAgent = { id: liveSession.id, session: liveSession, status: 'idle', ctx } as Agent + ctx.agents.register(liveAgent) + const resume = vi.spyOn(ctx.agents, 'resume') + const defaultAgentLookup = ctx.typert.lookups.get('agent') + const defaultSessionLookup = ctx.typert.lookups.get('session') + createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + await vi.waitFor(() => { + expect(ctx.typert.lookups.get('agent')).not.toBe(defaultAgentLookup) + expect(ctx.typert.lookups.get('session')).not.toBe(defaultSessionLookup) + }) + const agentLookup = ctx.typert.lookups.get('agent') + const sessionLookup = ctx.typert.lookups.get('session') + if (agentLookup === undefined || sessionLookup === undefined) throw new Error('core lookup providers were not mounted') + const ownershipFailure = { + failure: { + code: 'agent-busy', + details: { reason: 'use subagent delivery for this child session' }, + }, + } + + const coldFailure = Promise.resolve(agentLookup.resolve(coldId)) + const liveFailure = Promise.resolve(sessionLookup.resolve(liveSession.id)) + await expect(coldFailure).rejects.toBeInstanceOf(TypeRTLookupFailure) + await expect(coldFailure).rejects.toMatchObject(ownershipFailure) + await expect(liveFailure).rejects.toBeInstanceOf(TypeRTLookupFailure) + await expect(liveFailure).rejects.toMatchObject(ownershipFailure) + expect(resume).not.toHaveBeenCalled() + expect(inspect).toHaveBeenCalledOnce() + }) +}) + describe('subagent ownership fence', () => { it('reads a cold child without an Agent and rejects generic resume or adoption', async () => { const ctx = new Context() diff --git a/packages/host/apiproxy/tsconfig.json b/packages/host/apiproxy/tsconfig.json index c648d7a30d..23c170f4fd 100644 --- a/packages/host/apiproxy/tsconfig.json +++ b/packages/host/apiproxy/tsconfig.json @@ -38,6 +38,12 @@ { "path": "../../core/tools" }, + { + "path": "../../typert/type-meta" + }, + { + "path": "../../typert/registry" + }, { "path": "../../session-persistence/session-persistence" }, diff --git a/packages/typert/registry/README.i18n.yaml b/packages/typert/registry/README.i18n.yaml index b8c97637c9..a6180c6bfc 100644 --- a/packages/typert/registry/README.i18n.yaml +++ b/packages/typert/registry/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/typert/registry/README.md -README.md: 83c03ab284abf2b7cab4dd1ee70d7e855184a1e0 -README.zh.md: db2140e51d85be53bbf6eb4d1dd86ec38ceefc58 +README.md: dae8c3ed124fd6e2d61eb47964e2c07dda762b48 +README.zh.md: aea74b3753feccd88ee132363dc60ade02161498 diff --git a/packages/typert/registry/README.md b/packages/typert/registry/README.md index 83c03ab284..dae8c3ed12 100644 --- a/packages/typert/registry/README.md +++ b/packages/typert/registry/README.md @@ -9,6 +9,7 @@ Package reflection is keyed by `#`. Schemas are keyed by `>() + private readonly resolvers = new Map>() private readonly definitions = new Map() private readonly changes: ChangeSource @@ -229,13 +231,61 @@ class LookupStore { TypeRTLookupWire >, ) => this.register(ctx, key, provider), - get: key => this.providers.get(key)?.provider, + configure: >( + key: K, + resolver: TypeRTLookupResolver< + TypeRTLookupHost, + TypeRTLookupWire + >, + ) => this.configure(ctx, key, resolver), + get: key => this.get(key), definitions: () => [...this.definitions.values()], keys: () => [...this.providers.keys()], subscribe: listener => this.changes.subscribe(ctx, listener), } } + private get(key: string): TypeRTLookupProvider | undefined { + const provider = this.providers.get(key)?.provider + if (provider === undefined) return undefined + const resolver = this.resolvers.get(key)?.provider + if (resolver === undefined) return provider + return { + parameter: provider.parameter, + wire: provider.wire, + hostTypeSymbol: provider.hostTypeSymbol, + wireTypeSymbol: provider.wireTypeSymbol, + resolve: id => resolver.resolve(id), + } + } + + private configure( + ctx: Context, + key: string, + resolver: TypeRTLookupResolver, + ): TypeRTDisposer { + validateSegment('lookup key', key) + if (this.resolvers.has(key)) throw new Error(`typert: lookup "${key}" resolver is already configured`) + const owner = {} + // The map erases each merge-declared Wire type; restore it only at the + // typed configure() boundary so strict function variance remains sound. + const entry: ProviderEntry = { + provider: { resolve: async id => resolver(id as Wire) }, + owner, + } + const { resolvers, changes } = this + return ctx.effect(function* () { + resolvers.set(key, entry) + changes.emit({ kind: 'lookup', key }) + yield () => { + /* v8 ignore next -- duplicate configuration is rejected, so this effect remains the key's unique owner. */ + if (resolvers.get(key) !== entry) return + resolvers.delete(key) + changes.emit({ kind: 'lookup', key }) + } + }, `typert.lookups.configure(${JSON.stringify(key)})`) + } + private register(ctx: Context, key: string, provider: TypeRTLookupProvider): TypeRTDisposer { validateSegment('lookup key', key) validateSegment('lookup parameter', provider.parameter) @@ -271,6 +321,10 @@ class LookupStore { } } +interface LookupResolverEntry { + resolve(id: unknown): Promise +} + function lookupDefinitionEquals(left: TypeRTLookupDefinition, right: TypeRTLookupDefinition): boolean { return left.parameter === right.parameter && left.wire === right.wire diff --git a/packages/typert/registry/tests/typert.spec.ts b/packages/typert/registry/tests/typert.spec.ts index 29654babf7..087cf00fc4 100644 --- a/packages/typert/registry/tests/typert.spec.ts +++ b/packages/typert/registry/tests/typert.spec.ts @@ -355,6 +355,40 @@ describe('TypertRegistry', () => { expect(ctx.typert.contexts.getClient('registryFixture')).toBeUndefined() }) + it('configures an asynchronous lookup resolver independently of provider load order', async () => { + const ctx = await makeCtx() + const fallback = { id: 'fallback' } + const configured = { id: 'configured' } + const disposeResolver = ctx.typert.lookups.configure('fixture', async id => + id === configured.id ? configured : undefined) + + expect(ctx.typert.lookups.get('fixture')).toBeUndefined() + const disposeProvider = ctx.typert.lookups.register('fixture', { + parameter: 'agent', + wire: 'agentId', + hostTypeSymbol: '@fixture/agent#Agent', + wireTypeSymbol: '@fixture/session#SessionId', + resolve: id => id === fallback.id ? fallback : undefined, + }) + await expect(ctx.typert.lookups.get('fixture')?.resolve('configured')).resolves.toBe(configured) + expect(() => ctx.typert.lookups.configure('fixture', () => undefined)).toThrow('already configured') + + await disposeProvider() + expect(ctx.typert.lookups.get('fixture')).toBeUndefined() + const disposeReloadedProvider = ctx.typert.lookups.register('fixture', { + parameter: 'agent', + wire: 'agentId', + hostTypeSymbol: '@fixture/agent#Agent', + wireTypeSymbol: '@fixture/session#SessionId', + resolve: id => id === fallback.id ? fallback : undefined, + }) + await expect(ctx.typert.lookups.get('fixture')?.resolve('configured')).resolves.toBe(configured) + + await disposeResolver() + expect(ctx.typert.lookups.get('fixture')?.resolve('fallback')).toBe(fallback) + await disposeReloadedProvider() + }) + it('publishes provider changes, rejects duplicate providers, and disposes subscriptions', async () => { const ctx = await makeCtx() const changes: string[] = [] diff --git a/packages/typert/type-meta/README.i18n.yaml b/packages/typert/type-meta/README.i18n.yaml index a3e0643ace..510b8d3854 100644 --- a/packages/typert/type-meta/README.i18n.yaml +++ b/packages/typert/type-meta/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/typert/type-meta/README.md -README.md: 245df305efcf711486b2d3f32e40a8b415f2682e -README.zh.md: 592aa5d027a52a7a277a90ba5d51f19101f055f6 +README.md: b394c843409e840b75bbb08b128614379e528001 +README.zh.md: 5bd9bb18289a0320e0603d8b373e60d7f1e3c7e5 diff --git a/packages/typert/type-meta/README.md b/packages/typert/type-meta/README.md index 245df305ef..b394c84340 100644 --- a/packages/typert/type-meta/README.md +++ b/packages/typert/type-meta/README.md @@ -20,7 +20,7 @@ Decorator initializers retain markers in a module-private `WeakMap` keyed by the Business packages extend `TypeRTLookupMap` and `TypeRTContextMap` to associate Host objects or scoped Contexts with their wire identities. Generated artifacts extend `TypeRTRemoteMap`, `TypeRTRemoteContextMap`, and `TypeRTRemoteNamespaceMap` so Client imports expose only selected Remote methods. `InvocationDescriptor` is the shared runtime form consumed by the registry, Gateway, and Client API. -Lookup and Context packages own both sides of their contract: declaration merging supplies the static association, while runtime providers register identity resolution with `ctx.typert`. Strict codecs carry generated schemas; `src-json` codecs identify the weaker source-launch path. +Lookup and Context packages own both sides of their contract: declaration merging supplies the static association, while runtime providers register identity resolution with `ctx.typert`. A lookup provider supplies the stable declaration and default resolver, while Host composition may separately configure a synchronous or asynchronous resolver; policy rejections may use `TypeRTLookupFailure` to carry a failure value owned by the boundary adapter. Strict codecs carry generated schemas; `src-json` codecs identify the weaker source-launch path. ## Model Experience diff --git a/packages/typert/type-meta/README.zh.md b/packages/typert/type-meta/README.zh.md index 592aa5d027..5bd9bb1828 100644 --- a/packages/typert/type-meta/README.zh.md +++ b/packages/typert/type-meta/README.zh.md @@ -20,7 +20,7 @@ Host 方法通过将 `signal: AbortSignal` 声明为最后一个参数来启用 业务包扩展 `TypeRTLookupMap` 和 `TypeRTContextMap`,以关联 Host 对象或作用域 Context 与其协议身份。生成的产物扩展 `TypeRTRemoteMap`、`TypeRTRemoteContextMap` 和 `TypeRTRemoteNamespaceMap`,使 Client 导入后仅暴露选定的 Remote 方法。`InvocationDescriptor` 是供注册表、Gateway 和 Client API 使用的共享运行时形式。 -查找包与 Context 包同时负责其契约的两侧:声明合并提供静态关联,运行时提供方则向 `ctx.typert` 注册身份解析。严格编解码器携带生成的 schema;`src-json` 编解码器标识约束更弱的源码启动路径。 +查找包与 Context 包同时负责其契约的两侧:声明合并提供静态关联,运行时提供方则向 `ctx.typert` 注册身份解析。lookup provider 提供稳定声明与默认 resolver,Host 组合可以另行配置同步或异步 resolver;策略拒绝可用 `TypeRTLookupFailure` 携带由边界适配器拥有的失败值。严格编解码器携带生成的 schema;`src-json` 编解码器标识约束更弱的源码启动路径。 ## 模型体验 diff --git a/packages/typert/type-meta/src/index.ts b/packages/typert/type-meta/src/index.ts index 3d782dbb77..7ded29fa4a 100644 --- a/packages/typert/type-meta/src/index.ts +++ b/packages/typert/type-meta/src/index.ts @@ -18,6 +18,25 @@ export function isTypeRTRemoteSegment(value: string): boolean { return value !== '.' && value !== '..' && TYPERT_REMOTE_SEGMENT_PATTERN.test(value) } +/** + * A lookup policy rejection whose typed payload belongs to the active boundary adapter. + * Gateway adapters preserve this payload instead of collapsing it into an infrastructure failure. + */ +export class TypeRTLookupFailure extends Error { + /** Adapter-owned failure returned to the caller. */ + readonly failure: Failure + + /** + * Wrap one adapter failure without exposing the rejected identity. + * @param failure - typed failure owned by the active boundary adapter. + */ + constructor(failure: Failure) { + super('TypeRT lookup policy rejected the requested identity') + this.name = 'TypeRTLookupFailure' + this.failure = failure + } +} + export type { InvocationDescriptor, InvocationParameterDescriptor, @@ -36,6 +55,7 @@ export type { TypeRTLookupHost, TypeRTLookupMap, TypeRTLookupProvider, + TypeRTLookupResolver, TypeRTLookupRegistry, TypeRTLookupWire, TypeRTRemoteContextApi, diff --git a/packages/typert/type-meta/src/types.ts b/packages/typert/type-meta/src/types.ts index 6de5c7f823..7831c08e37 100644 --- a/packages/typert/type-meta/src/types.ts +++ b/packages/typert/type-meta/src/types.ts @@ -176,7 +176,16 @@ export interface TypeRTRemoteContribution { readonly descriptors: readonly InvocationDescriptor[] } -/** Runtime resolver for one declared Host object lookup. */ +/** + * Resolve one validated wire identity, synchronously or asynchronously. + * @param id - validated wire identity. + * @returns the Host object, or `undefined` when unavailable. + */ +export type TypeRTLookupResolver = ( + id: Wire, +) => Host | undefined | Promise + +/** Runtime provider for one declared Host object lookup. */ export interface TypeRTLookupProvider { /** Source parameter name recognized by the SRC weak parser. */ readonly parameter: string @@ -187,11 +196,11 @@ export interface TypeRTLookupProvider { /** Canonical wire type symbol used by strict generation. */ readonly wireTypeSymbol: string /** - * Resolve a wire identity to the current live Host object. + * Resolve a wire identity through the provider's default policy. * @param id - validated wire identity. - * @returns the live object, or `undefined` when it is unavailable. + * @returns the object, `undefined` when unavailable, or either asynchronously. */ - resolve(id: Wire): Host | undefined + resolve(id: Wire): Host | undefined | Promise } /** Stable wire declaration retained after a lookup provider unloads. */ @@ -304,6 +313,20 @@ export interface TypeRTLookupRegistry { TypeRTLookupWire >, ): TypeRTDisposer + /** + * Replace one provider's default resolution policy while this contribution is active. + * Configuration may precede provider registration; without a live provider, `get()` remains unavailable. + * @param key - lookup key whose wire declaration remains provider-owned. + * @param resolver - composition-owned resolver used by every lookup of this key. + * @returns disposer restoring the provider's default resolver. + */ + configure>( + key: K, + resolver: TypeRTLookupResolver< + TypeRTLookupHost, + TypeRTLookupWire + >, + ): TypeRTDisposer /** * Look up one provider by runtime key. * @param key - descriptor lookup key. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 79d38a43cd..e0adfb22c1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3838,6 +3838,12 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools + '@deepseek-ai/dsh-type-meta': + specifier: workspace:^ + version: link:../../typert/type-meta + '@deepseek-ai/dsh-typert-registry': + specifier: workspace:^ + version: link:../../typert/registry '@deepseek-ai/dsh-user-approval': specifier: workspace:^ version: link:../../ui/user-approval From bb61dc13f221fb9052a52a0c7e337fbd8e4c5898 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 7 Aug 2026 15:48:29 +0800 Subject: [PATCH 41/63] refactor(api): colocate gateway and remote assembly --- ...08-02-typert-remote-method-calls.i18n.yaml | 4 +- .../2026-08-02-typert-remote-method-calls.md | 24 ++- ...026-08-02-typert-remote-method-calls.zh.md | 24 ++- AGENTS.md | 1 + apps/cli/composition.md | 4 +- apps/web/tests/assembled-boot.ts | 6 +- docs/api-gateway.i18n.yaml | 4 +- docs/api-gateway.md | 21 +- docs/api-gateway.zh.md | 21 +- docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 2 +- docs/architecture.zh.md | 2 +- docs/capability-seams.md | 4 +- docs/config-catalog.md | 4 +- docs/cordis-catalog/services.md | 2 +- docs/core-data-structures/typert.i18n.yaml | 4 +- docs/core-data-structures/typert.md | 8 +- docs/core-data-structures/typert.zh.md | 8 +- docs/development.i18n.yaml | 4 +- docs/development.md | 2 +- docs/development.zh.md | 2 +- docs/event-producer-consumer.md | 2 +- docs/module-graph.md | 39 +++- knip.json | 2 +- packages/README.i18n.yaml | 4 +- packages/README.md | 1 + packages/README.zh.md | 1 + .../{client/remotes => api}/README.i18n.yaml | 6 +- packages/api/README.md | 17 ++ packages/api/README.zh.md | 17 ++ .../gateway}/README.i18n.yaml | 6 +- .../api-gateway => api/gateway}/README.md | 6 +- .../api-gateway => api/gateway}/README.zh.md | 6 +- .../api-gateway => api/gateway}/package.json | 4 +- .../gateway}/src/client/index.ts | 16 +- .../api-gateway => api/gateway}/src/index.ts | 2 +- .../gateway}/src/invariant.ts | 8 +- .../api-gateway => api/gateway}/src/types.ts | 2 +- .../gateway}/tests/client.spec.ts | 0 .../gateway}/tests/gateway.spec.ts | 2 +- .../api-gateway => api/gateway}/tsconfig.json | 0 packages/api/gateway/tsdown.config.ts | 3 + packages/api/remotes/README.i18n.yaml | 6 + packages/api/remotes/README.md | 25 +++ packages/api/remotes/README.zh.md | 25 +++ packages/{client => api}/remotes/package.json | 19 +- packages/api/remotes/src/agent-lookup.ts | 193 ++++++++++++++++++ .../remotes/src/client/index.ts | 11 +- packages/api/remotes/src/index.ts | 18 ++ .../{client => api}/remotes/src/invariant.ts | 8 +- .../remotes/tests/built-lib.e2e.ts | 16 +- .../{client => api}/remotes/tsconfig.json | 14 +- packages/api/remotes/tsdown.config.ts | 3 + packages/bundle/base/cordis.patch.yml | 2 +- packages/bundle/base/package.json | 2 +- packages/bundle/web-app/cordis.patch.yml | 4 +- packages/bundle/web-app/package.json | 2 +- packages/client/remotes/README.md | 22 -- packages/client/remotes/README.zh.md | 22 -- packages/client/remotes/src/index.ts | 4 - packages/client/remotes/tsdown.config.ts | 3 - packages/client/runtime/package.json | 6 +- packages/client/runtime/src/client/index.ts | 2 +- packages/client/runtime/tsconfig.json | 2 +- packages/client/ui-goal/package.json | 6 +- packages/client/ui-goal/src/client/index.ts | 2 +- packages/client/ui-goal/tsconfig.json | 2 +- packages/host/api-gateway/tsdown.config.ts | 3 - packages/host/apiproxy/package.json | 5 +- packages/host/apiproxy/src/api-proxy.ts | 161 ++------------- packages/host/apiproxy/tsconfig.json | 9 +- packages/typert/type-meta/src/index.ts | 1 + packages/typert/type-meta/src/types.ts | 10 + pnpm-lock.yaml | 130 ++++++------ scripts/gen-cordis-catalog.ts | 2 +- scripts/run-gates.ts | 2 +- scripts/type-equiv.manifest.json | 10 +- .../verify-package-readme-model-experience.ts | 4 +- tsconfig.base.json | 12 +- tsconfig.client.json | 4 +- tsconfig.host.json | 2 +- vitest.config.ts | 4 +- 82 files changed, 645 insertions(+), 432 deletions(-) rename packages/{client/remotes => api}/README.i18n.yaml (56%) create mode 100644 packages/api/README.md create mode 100644 packages/api/README.zh.md rename packages/{host/api-gateway => api/gateway}/README.i18n.yaml (56%) rename packages/{host/api-gateway => api/gateway}/README.md (86%) rename packages/{host/api-gateway => api/gateway}/README.zh.md (86%) rename packages/{host/api-gateway => api/gateway}/package.json (92%) rename packages/{host/api-gateway => api/gateway}/src/client/index.ts (96%) rename packages/{host/api-gateway => api/gateway}/src/index.ts (99%) rename packages/{host/api-gateway => api/gateway}/src/invariant.ts (77%) rename packages/{host/api-gateway => api/gateway}/src/types.ts (97%) rename packages/{host/api-gateway => api/gateway}/tests/client.spec.ts (100%) rename packages/{host/api-gateway => api/gateway}/tests/gateway.spec.ts (99%) rename packages/{host/api-gateway => api/gateway}/tsconfig.json (100%) create mode 100644 packages/api/gateway/tsdown.config.ts create mode 100644 packages/api/remotes/README.i18n.yaml create mode 100644 packages/api/remotes/README.md create mode 100644 packages/api/remotes/README.zh.md rename packages/{client => api}/remotes/package.json (64%) create mode 100644 packages/api/remotes/src/agent-lookup.ts rename packages/{client => api}/remotes/src/client/index.ts (64%) create mode 100644 packages/api/remotes/src/index.ts rename packages/{client => api}/remotes/src/invariant.ts (70%) rename packages/{client => api}/remotes/tests/built-lib.e2e.ts (95%) rename packages/{client => api}/remotes/tsconfig.json (63%) create mode 100644 packages/api/remotes/tsdown.config.ts delete mode 100644 packages/client/remotes/README.md delete mode 100644 packages/client/remotes/README.zh.md delete mode 100644 packages/client/remotes/src/index.ts delete mode 100644 packages/client/remotes/tsdown.config.ts delete mode 100644 packages/host/api-gateway/tsdown.config.ts diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml index c76dabca3c..9ba0cf8dc1 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md -2026-08-02-typert-remote-method-calls.md: d91f6f173c1b56efcd21d3136392837e61f54aae -2026-08-02-typert-remote-method-calls.zh.md: 0c548522d1137f0e0002a740d12ca0b796da5e39 +2026-08-02-typert-remote-method-calls.md: c4f3a5b94bf25b4581b9430cfcb4f02f707e0749 +2026-08-02-typert-remote-method-calls.zh.md: e11d8ebe42d44cc9805e942a31f13f7ae847815a diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md index d91f6f173c..c4f3a5b94b 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md @@ -20,7 +20,9 @@ A business Service extends `GatewayService` and declares callable methods with ` The Remote consumer projection contains `.d.ts`, `.d.ts.map`, and `.js` files. The `.d.ts` exposes only methods marked with a Remote decorator and refers to the business package's single public type symbols. The `.d.ts.map` navigates consumer API methods back to their Host business method implementations. The `.js` carries endpoint, parameter, Context, and Zod information for the same contract. At the assembly layer, the Browser Client mounts the required Remote JS contributions onto the Client API Service. The projection and API abstraction remain platform-independent so that a future TUI can reuse them. -`@deepseek-ai/dsh-host-api-gateway`, located at `packages/host/api-gateway`, provides two symmetric faces: its default entry provides Host `ctx.typertGateway`, while its `/client` entry provides consumer-side `ctx.api`. Each side consumes a locally generated `InvocationDescriptor` from the same model; descriptors are not sent over the wire. The Remote data protocol runs over Connection's shared `/api` RPC channel. The business calling interface does not change when Connection migrates from HTTP to WebSocket. +`@deepseek-ai/dsh-api-gateway`, located at `packages/api/gateway`, provides two symmetric faces: its default entry provides Host `ctx.typertGateway`, while its `/client` entry provides consumer-side `ctx.api`. Each side consumes a locally generated `InvocationDescriptor` from the same model; descriptors are not sent over the wire. The Remote data protocol runs over Connection's shared `/api` RPC channel. The business calling interface does not change when Connection migrates from HTTP to WebSocket. + +`@deepseek-ai/dsh-api-remotes`, located at `packages/api/remotes`, is the BFF layer above the Gateway. Its Host entry owns Agent/Session identity resolution and TypeRT lookup configuration; its `/client` entry selects the generated Remote contributions exposed by the application. The Client entry consumes the shared `TypeRTClientApi` contract through Cordis rather than importing the concrete Gateway implementation. ## Components and Cordis services @@ -29,10 +31,10 @@ The Remote consumer projection contains `.d.ts`, `.d.ts.map`, and `.js` files. T | `@deepseek-ai/dsh-type-meta` | Declares only the minimal `ctx.typert` protocol | `GatewayService`, decorators, binding fallback, descriptors, lookup/Context, and the Remote map; no dependency on the compiler, Zod, Connection, or Browser | | TypeRT registry | `ctx.typert` | Separately stores reflection for the current environment, imported Remote contributions, lookup providers, and Context providers | | TypeRT generator/loader | No new business service | Generates three kinds of `lib` artifacts from the Host/Client Programs and registers the current environment's artifacts with `ctx.typert` | -| Host API Gateway's Host face | `ctx.typertGateway` | Associates Host definitions with live Services, decodes parameters, resolves receivers, invokes methods, and encodes results | +| API Gateway's Host face | `ctx.typertGateway` | Associates Host definitions with live Services, decodes parameters, resolves receivers, invokes methods, and encodes results | | Connection | `ctx.connection` | Exclusively owns the HTTP Server/future WebSocket, the shared `/api` route, RPC envelope, rpcId, serialization, trust, error transport, TypeRT interception, and legacy API Proxy fallback | -| Host API Gateway's Client face | `ctx.api` | Mounts Remote contributions, materializes root and scoped APIs, and delegates canonical calls to `ctx.connection.rpc` | -| Client Remotes | No new service | Serves as the only Remote facade for Client business code, selecting and mounting `/remote` contributions while exposing the Gateway Client face and the selected API declarations | +| API Gateway's Client face | `ctx.api` | Mounts Remote contributions, materializes root and scoped APIs, and delegates canonical calls to `ctx.connection.rpc` | +| API Remotes | No new service | Owns Host Agent/Session lookup policy and serves as the only Client business facade, selecting and mounting `/remote` contributions while exposing the selected API declarations | | Agent/Session owning packages | Existing domain services | Provide both static interface merges and runtime lookup/Context providers | | Business packages such as Goal | Existing business Services | Declare only bindings, Remote methods, and canonical DTOs, and export the generated `/remote` subpath | @@ -162,7 +164,7 @@ Every registration returns a disposer owned by the caller's Cordis fiber. Client The lookup registry retains the stable wire declaration after its live resolver unloads. SRC parsing continues to classify the parameter as a lookup, while invocation fails with `lookup-unavailable`; it never reclassifies the incoming ID as an ordinary JSON business object. Re-registering the same key with different parameter, wire, or canonical type symbols fails for the lifetime of that TypeRT Service. -Business-object packages own stable declarations and default resolvers through `register()`; Host composition supplies an effect-scoped asynchronous policy for the same key through `configure()`. Configuration may precede provider registration, but does not by itself make a lookup available without a live provider; unloading the configuration restores the provider's default resolver. The standard Web Host's API Proxy configures the same `agentFor()` for `agent` and `session`: live Agents are reused, ordinary cold sessions are resumed automatically, concurrent resumes are deduplicated by Session ID, and the subagent ownership fence returns the existing `agent-busy`. The `session` resolver returns the resolved Agent's Session, so the two parameter kinds do not create separate resume lifecycles. +Business-object packages own stable declarations and default resolvers through `register()`; Host composition supplies an effect-scoped asynchronous policy for the same key through `configure()`. Configuration may precede provider registration, but does not by itself make a lookup available without a live provider; unloading the configuration restores the provider's default resolver. API Remotes creates the shared `agentFor()` resolver for `agent` and `session`: live Agents are reused, ordinary cold sessions are resumed automatically, concurrent resumes are deduplicated by Session ID, and the subagent ownership fence returns the existing `agent-busy`. The standard Web API Proxy supplies its Agent defaults and scope setup and consumes that resolver for legacy methods. The `session` resolver returns the resolved Agent's Session, so the two parameter kinds do not create separate resume lifecycles. The registry's Host root entry has the complete `TypeRTService` interface merge. The registry implementation shared by Host and Client lives in a separate module without environment declarations. The registry's `/client` entry imports only that shared implementation and does not pass through the Host root entry, so it cannot bring Host Cordis declarations into the Client Program. @@ -295,7 +297,7 @@ TypeRT.local 当前环境自己的反射模型 TypeRT.remotes 已导入的 Remote contribution ``` -`@deepseek-ai/dsh-client-remotes/client` centrally loads the required Remote contributions: +`@deepseek-ai/dsh-api-remotes/client` centrally loads the required Remote contributions: ```text import goalsRemote from '@deepseek-ai/dsh-goal/remote' @@ -305,7 +307,7 @@ ctx.api.mount(goalsRemote) ctx.api.mount(sessionsRemote) ``` -Client business packages depend only on `@deepseek-ai/dsh-client-remotes/client`, not directly on the Host API Gateway or the runtime entry of each business `/remote`. Client Remotes itself depends on the Gateway Client face and re-exports declarations so the selected Remote map reaches business compilation. Adding or removing a complete Client capability changes only this assembly point. +Client business packages depend only on `@deepseek-ai/dsh-api-remotes/client`, not directly on the API Gateway or the runtime entry of each business `/remote`. API Remotes consumes the shared `TypeRTClientApi` contract and Cordis `ctx.api` service, then re-exports declarations so the selected Remote map reaches business compilation. Adding or removing a complete Client capability changes only this assembly point. `ctx.api.mount()` registers a contribution with `TypeRT.remotes`, and its disposer is owned by the Cordis fiber that called the method. Duplicate endpoints, conflicting invocation modes for the same namespace and method, or conflicts between a descriptor and an existing type identity fail immediately. @@ -449,11 +451,11 @@ The Gateway registers only its ownership matcher and RPC handler with Connection - `@deepseek-ai/dsh-type-meta`: lightweight protocols for decorators, bindings, lookup, Remote Context, and descriptors. - TypeRT generator: analyzes Host/Client Programs, generates local faces and Remote consumer projections, and emits canonical symbol/Zod information. - TypeRT runtime: separately stores the current environment's local reflection and imported Remote contributions. -- `@deepseek-ai/dsh-host-api-gateway`: its default entry associates Host definitions with Services, claims Remote endpoints, performs lookup, resolves Context receivers, invokes methods, encodes results, and registers an `/api` interceptor with Connection; its `/client` entry mounts Remote contributions, creates strict API methods, and delegates calls to `ctx.connection.rpc`. The entries share the Remote protocol but do not import each other's Cordis interface merges. -- `@deepseek-ai/dsh-client-remotes`: the only Remote facade depended on by Client business code; directly depends on the Gateway Client face, selects `/remote` contributions, and exposes the merged API types to business packages. +- `@deepseek-ai/dsh-api-gateway`: its default entry associates Host definitions with Services, claims Remote endpoints, performs lookup, resolves Context receivers, invokes methods, encodes results, and registers an `/api` interceptor with Connection; its `/client` entry mounts Remote contributions, creates strict API methods, and delegates calls to `ctx.connection.rpc`. The entries share the Remote protocol but do not import each other's Cordis interface merges. +- `@deepseek-ai/dsh-api-remotes`: the BFF layer; owns the Host Agent/Session resolver, selects Client `/remote` contributions, and exposes the merged API types to business packages through the shared `TypeRTClientApi` contract. - Connection: owns the single HTTP Server/future WebSocket carrier, shared `/api` route and composite FetchHandler, API Proxy fallback, RPC envelope, rpcId, serialization, trust, and error transport. - Business-object packages such as Agent/Session: own lookup, Context providers, canonical ID types, and public type-only entries. -- API Proxy Host composition: configures cold resume, concurrent deduplication, and subagent ownership policy for `agent`/`session` lookups through the existing `agentFor()`. +- API Proxy Host composition: supplies Web Agent defaults and scope setup to API Remotes and consumes the same `agentFor()` for legacy methods. - Business Service packages: declare bindings, Remote methods, and their request/result types, and export the generated `/remote` subpath. ## Shipped scope and deferred work @@ -462,6 +464,8 @@ The shipped vertical path is `@deepseek-ai/dsh-goal/remote → Browser Client AP Connection supplies the shared-channel interceptor and current HTTP carrier mapping. WebSocket migration, the TUI runtime and carrier, TUI Agent Scope wiring, Permission/Approval state machines, Session event streams, call authorization, retries, idempotency, and cross-version protocol compatibility remain outside this decision. +The package topology is `api/remotes → api/gateway → client/connection → host/webserver`. Connection and WebServer retain their existing paths in this change; moving them later to `api/connection` and `api/webserver` changes package placement rather than these service boundaries. The legacy API Proxy likewise remains under `host/apiproxy` as the fallback for methods not yet migrated to Remote. + ## Alternatives considered **Continue using the central API Proxy package.** This would require business methods, Host routes, and Client interfaces to be declared repeatedly in several locations. It would also keep direct calls, stateful interactions, and event streams tied to the same lifecycle, so this alternative is rejected. diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md index 0c548522d1..e11d8ebe42 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md @@ -20,7 +20,9 @@ Host 与 Browser Client 使用独立的 TypeScript Program,因为两边会以 Remote 消费端投影同时包含 `.d.ts`、`.d.ts.map` 和 `.js`。`.d.ts` 只暴露被 Remote decorator 标记的方法,并引用业务包唯一的公共类型符号;`.d.ts.map` 把消费端 API 方法导航回 Host 业务方法实现;`.js` 携带同一契约的 endpoint、参数、Context 和 Zod 信息。Browser Client 在 assembly 层把需要的 Remote JS 贡献集中挂到 Client API Service;该投影和 API 抽象保持平台无关,以便未来 TUI 复用。 -`@deepseek-ai/dsh-host-api-gateway` 在 `packages/host/api-gateway` 内提供对称的两个 face:默认入口提供 Host `ctx.typertGateway`,`/client` 入口提供消费端 `ctx.api`。两边各自在本地消费由同一模型生成的 `InvocationDescriptor`,descriptor 不通过 wire 发送。Remote 数据协议运行在 Connection 共享的 `/api` RPC channel 上;业务调用界面不随 Connection 从 HTTP 迁移到 WebSocket 而改变。 +`@deepseek-ai/dsh-api-gateway` 位于 `packages/api/gateway`,提供对称的两个 face:默认入口提供 Host `ctx.typertGateway`,`/client` 入口提供消费端 `ctx.api`。两边各自在本地消费由同一模型生成的 `InvocationDescriptor`,descriptor 不通过 wire 发送。Remote 数据协议运行在 Connection 共享的 `/api` RPC channel 上;业务调用界面不随 Connection 从 HTTP 迁移到 WebSocket 而改变。 + +`@deepseek-ai/dsh-api-remotes` 位于 `packages/api/remotes`,是 Gateway 上层的 BFF 层。其 Host 入口负责 Agent/Session 身份解析与 TypeRT lookup 配置;`/client` 入口选择应用对外暴露的生成 Remote contribution。Client 入口通过 Cordis 消费共享的 `TypeRTClientApi` 契约,而不导入具体 Gateway 实现。 ## 组件和 Cordis 服务 @@ -29,10 +31,10 @@ Remote 消费端投影同时包含 `.d.ts`、`.d.ts.map` 和 `.js`。`.d.ts` 只 | `@deepseek-ai/dsh-type-meta` | 只声明 `ctx.typert` 的最小协议 | `GatewayService`、decorator、binding 回退、descriptor、lookup/Context 和 Remote map;不依赖 compiler、Zod、Connection 或 Browser | | TypeRT registry | `ctx.typert` | 分开保存当前环境 reflection、导入的 Remote contribution、lookup provider 和 Context provider | | TypeRT generator/loader | 无新增业务服务 | 从 Host/Client Program 生成三类 `lib` 产物,并把当前环境产物注册到 `ctx.typert` | -| Host API Gateway 的 Host face | `ctx.typertGateway` | 关联 Host definition 与活 Service,解码参数、解析 receiver、调用方法和编码结果 | +| API Gateway 的 Host face | `ctx.typertGateway` | 关联 Host definition 与活 Service,解码参数、解析 receiver、调用方法和编码结果 | | Connection | `ctx.connection` | 独占 HTTP Server/未来 WebSocket、共享 `/api` route、RPC envelope、rpcId、序列化、trust、错误传输、TypeRT 拦截和旧 API Proxy 回退 | -| Host API Gateway 的 Client face | `ctx.api` | mount Remote contribution,实体化根 API 和 scoped API,把规范调用交给 `ctx.connection.rpc` | -| Client Remotes | 无新增服务 | 作为 Client 业务的唯一 Remote facade,选择并挂载 `/remote` contribution,同时传递 Gateway Client face 和所选 API 的类型声明 | +| API Gateway 的 Client face | `ctx.api` | mount Remote contribution,实体化根 API 和 scoped API,把规范调用交给 `ctx.connection.rpc` | +| API Remotes | 无新增服务 | 负责 Host Agent/Session lookup 策略,并作为 Client 业务的唯一 facade,选择并挂载 `/remote` contribution,同时暴露所选 API 声明 | | Agent/Session owning 包 | 既有领域服务 | 同时提供静态 interface merge 与运行时 lookup/Context provider | | Goal 等业务包 | 既有业务 Service | 只声明 binding、Remote 方法和唯一 DTO,并导出生成的 `/remote` 子路径 | @@ -162,7 +164,7 @@ ctx.typert.contexts Host Context resolver 与 Client Context binder lookup 注册表会在活 resolver 卸载后保留稳定的 wire 声明。SRC 解析仍会把该参数归类为 lookup,而调用会以 `lookup-unavailable` 失败;系统绝不会把传入的 ID 重新归类为普通 JSON 业务对象。在同一个 TypeRT Service 的生命周期内,以不同参数、wire 或规范类型 symbol 重新注册同一 key 会直接失败。 -业务对象包通过 `register()` 拥有稳定声明和默认 resolver;Host 组合通过 `configure()` 为同一个 key 提供 effect-scoped 异步策略。配置可以先于 provider 注册,但没有活 provider 时不会单独形成可用 lookup;配置卸载后恢复 provider 默认 resolver。标准 Web Host 的 API Proxy 为 `agent` 和 `session` 配置同一套 `agentFor()`:live Agent 直接复用,普通冷会话自动恢复,并发恢复按 Session ID 去重,subagent ownership fence 则返回既有 `agent-busy`。`session` resolver 返回解析所得 Agent 的 Session,因而两种参数不会产生两套恢复生命周期。 +业务对象包通过 `register()` 拥有稳定声明和默认 resolver;Host 组合通过 `configure()` 为同一个 key 提供 effect-scoped 异步策略。配置可以先于 provider 注册,但没有活 provider 时不会单独形成可用 lookup;配置卸载后恢复 provider 默认 resolver。API Remotes 为 `agent` 和 `session` 创建共享的 `agentFor()` resolver:live Agent 直接复用,普通冷会话自动恢复,并发恢复按 Session ID 去重,subagent ownership fence 则返回既有 `agent-busy`。标准 Web API Proxy 提供 Agent 默认值和 scope 设置,并让旧方法使用该 resolver。`session` resolver 返回解析所得 Agent 的 Session,因而两种参数不会产生两套恢复生命周期。 Registry 的 Host 根入口拥有完整 `TypeRTService` interface merge;Host 与 Client 共用的 registry 实现位于无环境声明的独立模块。Registry `/client` 入口只引用该共享实现,不经过 Host 根入口,因此不会把 Host Cordis 声明带入 Client Program。 @@ -295,7 +297,7 @@ TypeRT.local 当前环境自己的反射模型 TypeRT.remotes 已导入的 Remote contribution ``` -`@deepseek-ai/dsh-client-remotes/client` 集中加载需要的 Remote contribution: +`@deepseek-ai/dsh-api-remotes/client` 集中加载需要的 Remote contribution: ```text import goalsRemote from '@deepseek-ai/dsh-goal/remote' @@ -305,7 +307,7 @@ ctx.api.mount(goalsRemote) ctx.api.mount(sessionsRemote) ``` -Client 业务包只引用 `@deepseek-ai/dsh-client-remotes/client`,不直接依赖 Host API Gateway 或各业务 `/remote` 运行时入口。Client Remotes 自己依赖 Gateway Client face,并通过声明 re-export 把所选 Remote map 传给业务编译;新增或移除整套 Client 能力只修改这一处 assembly。 +Client 业务包只引用 `@deepseek-ai/dsh-api-remotes/client`,不直接依赖 API Gateway 或各业务 `/remote` 运行时入口。API Remotes 消费共享的 `TypeRTClientApi` 契约和 Cordis `ctx.api` 服务,再重新导出声明,使所选 Remote map 进入业务编译;新增或移除整套 Client 能力只修改这一处 assembly。 `ctx.api.mount()` 把 contribution 注册到 `TypeRT.remotes`,并由调用该方法的 Cordis fiber 持有 disposer。endpoint 重复、同一 namespace/method 模式冲突或 descriptor 与现有类型身份冲突时直接失败。 @@ -449,11 +451,11 @@ Gateway 只向 Connection 注册 ownership matcher 和 RPC handler,不注册 H - `@deepseek-ai/dsh-type-meta`:轻量 decorator、binding、lookup、Remote Context 和 descriptor 协议。 - TypeRT generator:分析 Host/Client Program,生成本地 face 和 Remote 消费端投影,并生成规范 symbol/Zod 信息。 - TypeRT runtime:分别保存当前环境的 local reflection 与导入的 Remote contribution。 -- `@deepseek-ai/dsh-host-api-gateway`:默认入口关联 Host definition 与 Service,认领 Remote endpoint,执行 lookup、Context receiver 解析、调用和结果编码,并向 Connection 注册 `/api` interceptor;`/client` 入口挂载 Remote contribution,创建严格 API 方法,并把调用交给 `ctx.connection.rpc`。两个入口共享 Remote 协议,但不互相导入各自的 Cordis interface merge。 -- `@deepseek-ai/dsh-client-remotes`:Client 业务唯一依赖的 Remote facade;直接依赖 Gateway Client face,选择 `/remote` contributions,并向业务包传递合并后的 API 类型。 +- `@deepseek-ai/dsh-api-gateway`:默认入口关联 Host definition 与 Service,认领 Remote endpoint,执行 lookup、Context receiver 解析、调用和结果编码,并向 Connection 注册 `/api` interceptor;`/client` 入口挂载 Remote contribution,创建严格 API 方法,并把调用交给 `ctx.connection.rpc`。两个入口共享 Remote 协议,但不互相导入各自的 Cordis interface merge。 +- `@deepseek-ai/dsh-api-remotes`:BFF 层;负责 Host Agent/Session resolver,选择 Client `/remote` contribution,并通过共享的 `TypeRTClientApi` 契约向业务包暴露合并后的 API 类型。 - Connection:拥有唯一 HTTP Server/未来 WebSocket carrier、共享 `/api` route 与复合 FetchHandler、API Proxy 回退、RPC envelope、rpcId、序列化、trust 和错误传输。 - Agent/Session 等业务对象包:拥有 lookup、Context provider、唯一 ID 类型和纯类型公共出口。 -- API Proxy Host 组合:用既有 `agentFor()` 配置 `agent`/`session` lookup 的冷恢复、并发去重和 subagent ownership 策略。 +- API Proxy Host 组合:向 API Remotes 提供 Web Agent 默认值和 scope 设置,并让旧方法使用同一个 `agentFor()`。 - 业务 Service 包:声明 binding、Remote 方法及其 request/result 类型,并导出生成的 `/remote` 子路径。 ## 已交付范围与后续工作 @@ -462,6 +464,8 @@ Gateway 只向 Connection 注册 ownership matcher 和 RPC handler,不注册 H Connection 提供共享 channel interceptor 与当前 HTTP carrier 映射。WebSocket 迁移、TUI runtime 与 carrier、TUI Agent Scope 接线、Permission/Approval 状态机、Session 事件流、调用授权、重试、幂等及跨版本协议兼容均不属于本决策。 +包拓扑为 `api/remotes → api/gateway → client/connection → host/webserver`。Connection 与 WebServer 在本次变更中保留既有路径;后续将它们移到 `api/connection` 和 `api/webserver` 只会改变包位置,不会改变这些服务边界。旧 API Proxy 同样保留在 `host/apiproxy` 下,作为尚未迁移到 Remote 的方法的回退路径。 + ## Alternatives considered **继续使用中央 API Proxy 包。** 该方案要求业务方法、Host 路由和 Client 接口在多个位置重复声明,也会继续把直接调用、带状态交互和事件流绑在同一生命周期中,因此不采用。 diff --git a/AGENTS.md b/AGENTS.md index 0d27b20df0..c265d3cf32 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,6 +12,7 @@ DeepSeek Harness SDK is a plugin-based agent harness on vendored Cordis: **every vendor/ Vendored Cordis source — manifest + sync procedure in vendor/README.md packages/ @deepseek-ai/dsh- workspaces at packages/// core/ product API spine: session, system-prompt, tools, agent, agent-loop + api/ Remote BFF assembly and TypeRT RPC gateway typert/ type graph generator, loader, and runtime registry llm/ LLM seam + DeepSeek adapters (direct-fetch + pi-ai design twin) bash/ bash executor seam + local/pwsh impls + model-facing shell tools diff --git a/apps/cli/composition.md b/apps/cli/composition.md index 0246f6163f..45dc52561a 100644 --- a/apps/cli/composition.md +++ b/apps/cli/composition.md @@ -22,7 +22,7 @@ flowchart LR cfg --> plugin_dsh_base_typert plugin_dsh_base_typert_loader["typert-loader
@deepseek-ai/dsh-typert-loader"] cfg --> plugin_dsh_base_typert_loader - plugin_dsh_base_typert_gateway["typert-gateway
@deepseek-ai/dsh-host-api-gateway"] + plugin_dsh_base_typert_gateway["typert-gateway
@deepseek-ai/dsh-api-gateway"] cfg --> plugin_dsh_base_typert_gateway plugin_dsh_base_session_title["session-title
@deepseek-ai/dsh-session-title"] cfg --> plugin_dsh_base_session_title @@ -167,7 +167,7 @@ flowchart LR | `session` | `@deepseek-ai/dsh-session` | | `typert` | `@deepseek-ai/dsh-typert-registry` | | `typert-loader` | `@deepseek-ai/dsh-typert-loader` | -| `typert-gateway` | `@deepseek-ai/dsh-host-api-gateway` | +| `typert-gateway` | `@deepseek-ai/dsh-api-gateway` | | `session-title` | `@deepseek-ai/dsh-session-title` | | `session-title-llm` | `@deepseek-ai/dsh-session-title-first-message-llm` | | `user-interaction` | `@deepseek-ai/dsh-user-interaction` | diff --git a/apps/web/tests/assembled-boot.ts b/apps/web/tests/assembled-boot.ts index ebb2aa513a..729428e47b 100644 --- a/apps/web/tests/assembled-boot.ts +++ b/apps/web/tests/assembled-boot.ts @@ -18,9 +18,9 @@ import { AppWebEntry } from '@deepseek-ai/dsh-client-web' const PLUGINS: readonly (WebBootEntry & { bundlePath: string })[] = [ { id: '@deepseek-ai/dsh-typert-registry', bundlePath: 'packages/typert/registry/lib/client.js', url: '/plugins/typert-registry.js', rev: 'fx', inject: [], immediately: true }, { id: '@deepseek-ai/dsh-client-connection', bundlePath: 'packages/client/connection/lib/client.js', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true }, - { id: '@deepseek-ai/dsh-host-api-gateway', bundlePath: 'packages/host/api-gateway/lib/client.js', url: '/plugins/api-gateway.js', rev: 'fx', inject: ['@deepseek-ai/dsh-typert-registry', '@deepseek-ai/dsh-client-connection'], immediately: true }, - { id: '@deepseek-ai/dsh-client-remotes', bundlePath: 'packages/client/remotes/lib/client.js', url: '/plugins/client-remotes.js', rev: 'fx', inject: ['@deepseek-ai/dsh-host-api-gateway'], immediately: true }, - { id: '@deepseek-ai/dsh-client-runtime', bundlePath: 'packages/client/runtime/lib/client.js', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection', '@deepseek-ai/dsh-client-remotes', '@deepseek-ai/dsh-typert-registry'], immediately: true }, + { id: '@deepseek-ai/dsh-api-gateway', bundlePath: 'packages/api/gateway/lib/client.js', url: '/plugins/api-gateway.js', rev: 'fx', inject: ['@deepseek-ai/dsh-typert-registry', '@deepseek-ai/dsh-client-connection'], immediately: true }, + { id: '@deepseek-ai/dsh-api-remotes', bundlePath: 'packages/api/remotes/lib/client.js', url: '/plugins/api-remotes.js', rev: 'fx', inject: ['@deepseek-ai/dsh-api-gateway'], immediately: true }, + { id: '@deepseek-ai/dsh-client-runtime', bundlePath: 'packages/client/runtime/lib/client.js', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection', '@deepseek-ai/dsh-api-remotes', '@deepseek-ai/dsh-typert-registry'], immediately: true }, { id: '@deepseek-ai/dsh-client-ui-theme', bundlePath: 'packages/client/ui-theme/lib/client.js', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true }, { id: '@deepseek-ai/dsh-client-locale', bundlePath: 'packages/client/locale/lib/client.js', url: '/plugins/locale.js', rev: 'fx', inject: [], immediately: true }, { id: '@deepseek-ai/dsh-client-ui-layout', bundlePath: 'packages/client/ui-layout/lib/client.js', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] }, diff --git a/docs/api-gateway.i18n.yaml b/docs/api-gateway.i18n.yaml index 58891890d3..05038eb8b9 100644 --- a/docs/api-gateway.i18n.yaml +++ b/docs/api-gateway.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/api-gateway.md -api-gateway.md: 2e0717fd7b0e5b9ca33d650ffad7ac454046f780 -api-gateway.zh.md: 4d1beebf92cae702dac323cdd974b6220091a214 +api-gateway.md: 090758d58306d5ea806567f0de710a1c1f5ed747 +api-gateway.zh.md: 9d7286b6b86918f3bc1e7a6cdd9bdf04447abc57 diff --git a/docs/api-gateway.md b/docs/api-gateway.md index 2e0717fd7b..090758d583 100644 --- a/docs/api-gateway.md +++ b/docs/api-gateway.md @@ -61,7 +61,7 @@ The Client uses concrete functions on ordinary objects, not a JavaScript Proxy. import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { AgentContext } from '@deepseek-ai/dsh-client-runtime/client' import type { Context } from 'cordis' -import type {} from '@deepseek-ai/dsh-client-remotes/client' +import type {} from '@deepseek-ai/dsh-api-remotes/client' declare const ctx: Context declare const agentCtx: AgentContext @@ -71,9 +71,9 @@ await ctx.api.goals.create(agentId, { objective: 'ship it' }) await agentCtx.goals.create({ objective: 'ship it' }) ``` -Client applications assemble only `@deepseek-ai/dsh-client-remotes`. That package imports the `/remote` subpaths of selected business packages as runtime values, mounts their contributions on `ctx.api`, and re-exports the declaration merges from the same files. Adding a Host Remote package is an explicit choice by the Client composition owner; business components do not need to load the Host API Gateway or the business package's Remote JS separately. +Client applications assemble only `@deepseek-ai/dsh-api-remotes`. That package imports the `/remote` subpaths of selected business packages as runtime values, mounts their contributions on `ctx.api`, and re-exports the declaration merges from the same files. Adding a Host Remote package is an explicit choice by the Client composition owner; business components do not need to load the TypeRT Gateway or the business package's Remote JS separately. -A future TUI can assemble the same React-independent `client-remotes` and `ctx.api` contract, so the Host methods visible to it are likewise limited to the Remote methods selected at generation time. This document does not define or implement the TUI composition. +A future TUI can assemble the same React-independent `api-remotes` and `ctx.api` contract, so the Host methods visible to it are likewise limited to the Remote methods selected at generation time. This document does not define or implement the TUI composition. ## Component responsibilities @@ -82,12 +82,13 @@ A future TUI can assemble the same React-independent `client-remotes` and `ctx.a | Shared | `@deepseek-ai/dsh-type-meta` | Declares decorators, Gateway bindings, merge-extensible protocol maps, invocation descriptors, and provider types; starts no TypeScript analysis and registers no Cordis services | | Build | `@deepseek-ai/dsh-typert-generator` | Strictly analyzes Remote signatures, the type graph, lookups, Contexts, and source locations from the Host `ts.Program`, then generates Host and Host-for-Client artifacts | | 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-host-api-gateway` | Provides `ctx.typertGateway`, claims Remote endpoints, resolves objects or Contexts, invokes live Cordis services, and validates boundaries | -| Client | `@deepseek-ai/dsh-host-api-gateway/client` | Provides `ctx.api`, mounts generated descriptors as concrete methods, and initiates, validates, and cancels calls through the Connection | -| Client | `@deepseek-ai/dsh-client-remotes/client` | Explicitly selects and mounts the `/remote` contributions allowed by the application and brings the corresponding declaration merges into business code | +| Host | `@deepseek-ai/dsh-api-remotes` | 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, resolves objects or Contexts, invokes live Cordis services, and validates boundaries | +| Client | `@deepseek-ai/dsh-api-gateway/client` | Provides `ctx.api`, mounts generated descriptors as concrete methods, and initiates, validates, 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 current `/api` HTTP bridge | -The Host API Gateway package owns the Host dispatcher and Client API as peer entries, but the two builds never enter the same `ts.Program`. The Host entry does not import the Client Cordis `Context` merge, and the Client entry does not import the Host Gateway service. +The API Gateway package owns the Host dispatcher and Client API as peer entries, but the two builds never enter the same `ts.Program`. The Host entry does not import the Client Cordis `Context` merge, and the Client entry does not import the Host Gateway service. ## Strict generation pipeline @@ -99,7 +100,7 @@ Each contributing business package writes generated files to its own `lib/` dire |---|---|---| | `typert.host.js` | Host Loader | Runtime reflection for the Host face, strict invocation descriptors, and schema registration values | | `typert.host.d.ts` | Host type system | Generated declarations for the Host face | -| `typert.remote-client.js` | `client-remotes` | A mountable `TypeRTRemoteContribution` containing strict descriptors and runtime codecs | +| `typert.remote-client.js` | `api-remotes` | A mountable `TypeRTRemoteContribution` containing strict descriptors and runtime codecs | | `typert.remote-client.d.ts` | Client type system | Declaration merges for `TypeRTRemoteNamespaceMap` and `TypeRTRemoteContextMap`, plus Client-safe type references | | `typert.remote-client.d.ts.map` | Editor | Maps generated method properties back to Remote method declarations in the Host package | @@ -117,7 +118,7 @@ The Connection performs the unified trust check for `/api` before the HTTP bridg 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, invokes the service method targeted by the binding, and validates the return value. A missing provider, unknown identity, binding mismatch, missing or extra argument, schema failure, or missing method fails at the boundary 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 `lookup-unavailable`, and unloading the configuration restores the provider's default policy. The standard Web Host's API Proxy configures the same `agentFor()` 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. Resume failures and ownership fences pass through unchanged as existing RPC errors rather than being collapsed into the Gateway's `internal` error. +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 `lookup-unavailable`, and unloading the configuration restores the provider's default policy. API Remotes owns the standard `agentFor()` 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. The Web API Proxy supplies its Agent defaults and scope setup, then consumes the same resolver for legacy methods. Resume failures and ownership fences pass through unchanged as existing RPC errors rather than being collapsed into the Gateway's `internal` error. Unloading a Client contribution removes its descriptors and concrete methods together, aborts its in-flight calls, and makes stale method handles retained by external code reject further calls. A strict endpoint withdrawn on the Host also does not degrade to SRC inference, preventing a hot unload from silently weakening validation. @@ -158,4 +159,6 @@ The running Client watcher consumes these generated files when it rebundles; wit Remote handles only unary method calls with one request and one result. Session event streams, pagination, incremental reduce, projection, and entity substreams require a separate data protocol and registration model; even when they reuse the Connection, they must not masquerade as Remote methods or enter invocation descriptors. +The API layers are organized as `remotes → gateway → connection → webserver`. The BFF and TypeRT RPC layers live under `packages/api`; Connection and WebServer remain at `packages/client/connection` and `packages/host/webserver`, with service contracts that permit a later package-only move to `packages/api`. The legacy API Proxy remains at `packages/host/apiproxy` as the fallback for endpoints not yet migrated to Remote. + Lookup policy is currently configured per key, so all `agent` or `session` parameters share the cold-resume behavior. If a Remote endpoint must accept live objects only, an explicit per-parameter or per-endpoint policy must be added later; the business method must not guess whether the object came from restoration. diff --git a/docs/api-gateway.zh.md b/docs/api-gateway.zh.md index 4d1beebf92..9d7286b6b8 100644 --- a/docs/api-gateway.zh.md +++ b/docs/api-gateway.zh.md @@ -61,7 +61,7 @@ Client 使用普通对象上的具体函数,不使用 JavaScript Proxy。直 import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { AgentContext } from '@deepseek-ai/dsh-client-runtime/client' import type { Context } from 'cordis' -import type {} from '@deepseek-ai/dsh-client-remotes/client' +import type {} from '@deepseek-ai/dsh-api-remotes/client' declare const ctx: Context declare const agentCtx: AgentContext @@ -71,9 +71,9 @@ await ctx.api.goals.create(agentId, { objective: 'ship it' }) await agentCtx.goals.create({ objective: 'ship it' }) ``` -Client 应用只装配 `@deepseek-ai/dsh-client-remotes`。该包以运行时值导入被选业务包的 `/remote` 子路径,并向 `ctx.api` 挂载贡献,同时重新导出相同文件中的声明合并。增加一个 Host Remote 包是 Client 组合所有者的显式选择;业务组件不需要分别加载 Host API Gateway 或业务包的 Remote JS。 +Client 应用只装配 `@deepseek-ai/dsh-api-remotes`。该包以运行时值导入被选业务包的 `/remote` 子路径,并向 `ctx.api` 挂载贡献,同时重新导出相同文件中的声明合并。增加一个 Host Remote 包是 Client 组合所有者的显式选择;业务组件不需要分别加载 TypeRT Gateway 或业务包的 Remote JS。 -未来的 TUI 可以装配同一个不依赖 React 的 `client-remotes` 与 `ctx.api` 契约,因此它能看到的 Host 方法同样只限于生成时选择的 Remote 方法。本文不定义或实现 TUI 组合。 +未来的 TUI 可以装配同一个不依赖 React 的 `api-remotes` 与 `ctx.api` 契约,因此它能看到的 Host 方法同样只限于生成时选择的 Remote 方法。本文不定义或实现 TUI 组合。 ## 组件职责 @@ -82,12 +82,13 @@ Client 应用只装配 `@deepseek-ai/dsh-client-remotes`。该包以运行时值 | 共享 | `@deepseek-ai/dsh-type-meta` | 声明 decorator、Gateway binding、可合并协议映射、调用描述符及提供方类型;不启动 TypeScript 分析,也不注册 Cordis 服务 | | 构建 | `@deepseek-ai/dsh-typert-generator` | 从 Host `ts.Program` 严格分析 Remote 签名、类型图、lookup、Context 与源码位置,并生成 Host 和 Host-for-Client 产物 | | Host | `@deepseek-ai/dsh-typert-registry` 与 Loader | 把生成的 Host 描述符、schema 及业务包注册项放入 `ctx.typert`,并持有 lookup 与 Context 提供方 | -| Host | `@deepseek-ai/dsh-host-api-gateway` | 提供 `ctx.typertGateway`,认领 Remote endpoint,解析对象或 Context,调用实时 Cordis Service 并校验边界 | -| Client | `@deepseek-ai/dsh-host-api-gateway/client` | 提供 `ctx.api`,把生成的描述符挂成具体方法,并通过 Connection 发起、校验和取消调用 | -| Client | `@deepseek-ai/dsh-client-remotes/client` | 显式选择并挂载本应用允许使用的 `/remote` 贡献,向业务代码带入对应的声明合并 | +| Host | `@deepseek-ai/dsh-api-remotes` | 负责应用的 Agent/Session 身份策略,并配置对应的 TypeRT lookup | +| Host | `@deepseek-ai/dsh-api-gateway` | 提供 `ctx.typertGateway`,认领 Remote endpoint,解析对象或 Context,调用实时 Cordis Service 并校验边界 | +| Client | `@deepseek-ai/dsh-api-gateway/client` | 提供 `ctx.api`,把生成的描述符挂成具体方法,并通过 Connection 发起、校验和取消调用 | +| Client | `@deepseek-ai/dsh-api-remotes/client` | 显式选择并挂载本应用允许使用的 `/remote` 贡献,向业务代码带入对应的声明合并 | | 双侧 | `@deepseek-ai/dsh-client-connection` | 提供 RPC carrier、请求关联、信任边界、取消、响应 envelope 与当前 `/api` HTTP bridge | -Host API Gateway 包同时拥有 Host dispatcher 与 Client API 两个对等入口,但两侧构建不会进入同一个 `ts.Program`。Host 入口不导入 Client 的 Cordis `Context` 合并,Client 入口也不导入 Host Gateway 服务。 +API Gateway 包同时拥有 Host dispatcher 与 Client API 两个对等入口,但两侧构建不会进入同一个 `ts.Program`。Host 入口不导入 Client 的 Cordis `Context` 合并,Client 入口也不导入 Host Gateway 服务。 ## 严格生成链路 @@ -99,7 +100,7 @@ Host API Gateway 包同时拥有 Host dispatcher 与 Client API 两个对等入 |---|---|---| | `typert.host.js` | Host Loader | Host face 的运行时反射、严格调用描述符和 schema 注册值 | | `typert.host.d.ts` | Host 类型系统 | Host face 的生成声明 | -| `typert.remote-client.js` | `client-remotes` | 可挂载的 `TypeRTRemoteContribution`,包含严格描述符与运行时 codec | +| `typert.remote-client.js` | `api-remotes` | 可挂载的 `TypeRTRemoteContribution`,包含严格描述符与运行时 codec | | `typert.remote-client.d.ts` | Client 类型系统 | `TypeRTRemoteNamespaceMap` 与 `TypeRTRemoteContextMap` 的声明合并及 Client-safe 类型引用 | | `typert.remote-client.d.ts.map` | 编辑器 | 将生成的方法属性映射回 Host 包中的 Remote 方法声明 | @@ -117,7 +118,7 @@ Connection 在 HTTP bridge 之前执行 `/api` 的统一信任检查,再在共 Gateway 每次调用都从当前注册表解析描述符和实时 Service,不缓存业务对象。它要求 `args` 的字段集合与描述符完全一致,先用 codec 校验 wire 值,再通过注册的 lookup 或 Context provider 解析对象或接收者,最后调用 binding 指向的 Service 方法并校验返回值。缺少 provider、identity 未命中、binding 不一致、参数多缺、schema 失败和方法不存在都在进入或离开业务边界时失败。 -lookup provider 的 `register()` 同时提供稳定声明和默认 resolver;`configure()` 提供由 Host 组合拥有、可异步执行且受 effect 生命周期约束的 resolver。配置可以先于 provider 挂载;没有 provider 时调用仍以 `lookup-unavailable` 失败,配置卸载后则恢复 provider 默认策略。标准 Web Host 的 API Proxy 为 `agent` 与 `session` 配置同一套 `agentFor()` 语义:复用 live Agent,自动恢复普通冷会话,对并发恢复去重,并拒绝由 subagent routing 拥有的 identity;`session` lookup 返回该 Agent 的 Session。恢复失败和 ownership fence 通过既有 RPC error 原样返回,不折叠为 Gateway 的 `internal` 错误。 +lookup provider 的 `register()` 同时提供稳定声明和默认 resolver;`configure()` 提供由 Host 组合拥有、可异步执行且受 effect 生命周期约束的 resolver。配置可以先于 provider 挂载;没有 provider 时调用仍以 `lookup-unavailable` 失败,配置卸载后则恢复 provider 默认策略。API Remotes 负责 `agent` 与 `session` 的标准 `agentFor()` 语义:复用 live Agent,自动恢复普通冷会话,对并发恢复去重,并拒绝由 subagent routing 拥有的 identity;`session` lookup 返回该 Agent 的 Session。Web API Proxy 提供 Agent 默认值与 scope 设置,再让旧方法使用同一个 resolver。恢复失败和 ownership fence 通过既有 RPC error 原样返回,不折叠为 Gateway 的 `internal` 错误。 Client 卸载一个贡献时会一起移除描述符和具体方法,中止其进行中的调用,并使外部仍持有的旧方法句柄拒绝继续调用。Host 上已经注册过的严格 endpoint 被撤回后也不会降级到 SRC 推断,以免热卸载悄然降低校验强度。 @@ -158,4 +159,6 @@ pnpm run build:lib:contracts Remote 只处理有单个请求与单个结果的一元方法调用。Session event stream、分页、增量 reduce、projection 和实体子流需要独立的数据协议与注册模型;即使它们复用 Connection,也不应伪装成 Remote 方法或放入调用描述符。 +API 各层按 `remotes → gateway → connection → webserver` 组织。BFF 与 TypeRT RPC 层位于 `packages/api`;Connection 与 WebServer 仍位于 `packages/client/connection` 和 `packages/host/webserver`,其服务契约允许未来只移动包,将它们放到 `packages/api`。旧 API Proxy 仍位于 `packages/host/apiproxy`,作为尚未迁移到 Remote 的 endpoint 的回退路径。 + 当前 lookup 策略按 key 配置,因此所有 `agent` 或 `session` 参数共享冷恢复行为。某个 Remote endpoint 若必须只接受 live 对象,需要后续增加显式的逐参数或逐 endpoint 策略,不能通过业务方法内部猜测恢复来源。 diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 774bc296b1..0164acefcf 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/architecture.md -architecture.md: db5991d98dfbc6b04992d62d5a465c375c9a78b8 -architecture.zh.md: 2eb8c3834a6ffc3283c8aa669be481b534bb5914 +architecture.md: 35a73d4a307f5f48cc41cc496742a2ac210e8877 +architecture.zh.md: 185958221a477bb690e3ab5c91c33ba892ab2d73 diff --git a/docs/architecture.md b/docs/architecture.md index db5991d98d..35a73d4a30 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -48,7 +48,7 @@ Harnesses are [Cordis](cordis-primer.md) contexts; packages contribute services, | `ctx.credentials` | [`credentials/`](../packages/credentials/README.md) | named secret references resolved per operation, never inlined in configuration | | `ctx.directoryPicker` | [`host/directory-picker`](../packages/host/directory-picker/README.md) | GUI-host directory picking (`native`/`browse` interactions) | | `ctx.typert` | [`typert/registry`](../packages/typert/registry/README.md) | runtime registry for generated package reflection and live Zod schemas | -| `ctx.typertGateway` | [`host/api-gateway`](../packages/host/api-gateway/README.md) | dispatches TypeRT Remote unary calls through the [API Gateway](api-gateway.md) | +| `ctx.typertGateway` | [`api/gateway`](../packages/api/gateway/README.md) | dispatches TypeRT Remote unary calls through the [API Gateway](api-gateway.md) | | `ctx.invariants` | [`support/invariants`](../packages/support/invariants/README.md) | package-name-selected registry of package-owned runtime checks | ## Event diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 2eb8c3834a..185958221a 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -48,7 +48,7 @@ | `ctx.credentials` | [`credentials/`](../packages/credentials/README.md) | 具名密钥引用,按操作解析,绝不内联进配置 | | `ctx.directoryPicker` | [`host/directory-picker`](../packages/host/directory-picker/README.md) | GUI 宿主目录选取(`native`/`browse` 交互) | | `ctx.typert` | [`typert/registry`](../packages/typert/registry/README.md) | 生成的包反射和实时 Zod schema 的运行时注册表 | -| `ctx.typertGateway` | [`host/api-gateway`](../packages/host/api-gateway/README.md) | 通过 [API Gateway](api-gateway.md) 分发 TypeRT Remote 一元调用 | +| `ctx.typertGateway` | [`api/gateway`](../packages/api/gateway/README.md) | 通过 [API Gateway](api-gateway.md) 分发 TypeRT Remote 一元调用 | | `ctx.invariants` | [`support/invariants`](../packages/support/invariants/README.md) | 按包名筛选包自有运行时检查的注册表 | ## 事件 diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 18839bf3c2..2de89ef94b 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -367,8 +367,8 @@ flowchart LR | `ctx.toolResultPrune` | `core` | [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune) | - | [`compact-basic`](../packages/compact/compact-basic) | - | Rewrites oversized current tool results through replayable single-node surface replacements before summary compaction. | | `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`cli-demo`](../packages/examples/cli-demo), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`invariants`](../packages/support/invariants) | - | Owns append-only Session instances and emits the durable session event feed. | | `ctx.invariants` | `core` | [`invariants`](../packages/support/invariants) | - | [`session`](../packages/core/session), [`agent`](../packages/core/agent), [`scope`](../packages/core/scope), [`agent-loop`](../packages/core/agent-loop) | - | Companion subpaths register owner-local checks; the service owns selection, uniqueness, child fibers, and package-attributed failures. | -| `ctx.typert` | `core` | [`typert-registry`](../packages/typert/registry) | - | [`typert-loader`](../packages/typert/loader), `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` | - | - | - | Associates generated Remote descriptors with live Cordis services, resolves registered identities, and exposes unary calls through the shared Connection RPC carrier. | +| `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.sessionPersistence` | `seam` | [`session-persistence`](../packages/session-persistence/session-persistence) | [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop), [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | - | Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time. | | `ctx.settings` | `seam` | [`settings`](../packages/settings/settings) | [`settings-local`](../packages/settings/settings-local) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), `apiproxy` | - | Plugins register namespace schemas and resolve layered values; providers store the raw document. The LLM adapters register their entry config as the composition base under the user section; the web gateway serves redacted layered descriptors and writes the user layer. | | `ctx.credentials` | `seam` | [`credentials`](../packages/credentials/credentials) | [`credentials-local`](../packages/credentials/credentials-local) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), `apiproxy` | - | Configuration carries references to secrets; providers own the values. Consumers resolve per operation, so a rotated credential reaches the very next request; the web gateway exposes value-free views and write-only storage. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 77baad512d..21f38d18e2 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2520,9 +2520,10 @@ Source: [`packages/context/workspace-context/src/config.ts:18`](../packages/cont These load from a `cordis.yml` entry with no `config:` block; they declare no config surface. - `@deepseek-ai/dsh-agent` ([`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts)) +- `@deepseek-ai/dsh-api-gateway` — requires `typert` ([`packages/api/gateway/src/index.ts`](../packages/api/gateway/src/index.ts)) +- `@deepseek-ai/dsh-api-remotes` ([`packages/api/remotes/src/index.ts`](../packages/api/remotes/src/index.ts)) - `@deepseek-ai/dsh-client-locale` ([`packages/client/locale/src/index.ts`](../packages/client/locale/src/index.ts)) - `@deepseek-ai/dsh-client-modules` — requires `httpServer` · `loader` ([`packages/client/modules/src/index.ts`](../packages/client/modules/src/index.ts)) -- `@deepseek-ai/dsh-client-remotes` ([`packages/client/remotes/src/index.ts`](../packages/client/remotes/src/index.ts)) - `@deepseek-ai/dsh-client-runtime` ([`packages/client/runtime/src/index.ts`](../packages/client/runtime/src/index.ts)) - `@deepseek-ai/dsh-client-ui-command` ([`packages/client/ui-command/src/index.ts`](../packages/client/ui-command/src/index.ts)) - `@deepseek-ai/dsh-client-ui-conversation` ([`packages/client/ui-conversation/src/index.ts`](../packages/client/ui-conversation/src/index.ts)) @@ -2549,7 +2550,6 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-commands` ([`packages/ui/commands/src/index.ts`](../packages/ui/commands/src/index.ts)) - `@deepseek-ai/dsh-fs-policy` ([`packages/fs/fs-policy/src/index.ts`](../packages/fs/fs-policy/src/index.ts)) - `@deepseek-ai/dsh-goal-session` — requires `agents` · `goals` · `sessions` ([`packages/goal/goal-session/src/index.ts`](../packages/goal/goal-session/src/index.ts)) -- `@deepseek-ai/dsh-host-api-gateway` — requires `typert` ([`packages/host/api-gateway/src/index.ts`](../packages/host/api-gateway/src/index.ts)) - `@deepseek-ai/dsh-host-directory-picker-auto` — requires `httpServer` · `loader` ([`packages/host/directory-picker-auto/src/index.ts`](../packages/host/directory-picker-auto/src/index.ts)) - `@deepseek-ai/dsh-host-directory-picker-native` ([`packages/host/directory-picker-native/src/index.ts`](../packages/host/directory-picker-native/src/index.ts)) - `@deepseek-ai/dsh-llm` ([`packages/llm/llm/src/index.ts`](../packages/llm/llm/src/index.ts)) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 8acd669131..cf763dca98 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -2609,7 +2609,7 @@ Resolve strict generated definitions or conservative SRC markers against current async invoke(request: InvokeRemoteRequest): Promise ``` -Source: [`packages/host/api-gateway/src/index.ts:78`](../../packages/host/api-gateway/src/index.ts) +Source: [`packages/api/gateway/src/index.ts:78`](../../packages/api/gateway/src/index.ts) ## `ctx.userInteraction` — `UserInteractionService` diff --git a/docs/core-data-structures/typert.i18n.yaml b/docs/core-data-structures/typert.i18n.yaml index 5b0b70de54..a6e1eb5415 100644 --- a/docs/core-data-structures/typert.i18n.yaml +++ b/docs/core-data-structures/typert.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/typert.md -typert.md: 1ff0fe80e483d481f686336c86038cdd169ecdbc -typert.zh.md: 3cc0aa26406e01db5a6c05074210fc9d40b8ec00 +typert.md: a61ed8587833e03fd5c1246311e62a6ffaeb3bd0 +typert.zh.md: 18c24018f4abd644cf35185c2bd06b6980195481 diff --git a/docs/core-data-structures/typert.md b/docs/core-data-structures/typert.md index 1ff0fe80e4..a61ed85878 100644 --- a/docs/core-data-structures/typert.md +++ b/docs/core-data-structures/typert.md @@ -2,7 +2,7 @@ English | [中文](typert.zh.md) -Types shared by generated Remote artifacts, the Host Gateway, and consumer API assemblies. The [TypeRT Gateway Agent Note](../../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md) owns the architecture and transport decisions; this page records the literal public contracts from [`dsh-type-meta`](../../packages/typert/type-meta/src/types.ts) and [`dsh-host-api-gateway`](../../packages/host/api-gateway/src/types.ts). +Types shared by generated Remote artifacts, the Host Gateway, and consumer API assemblies. The [TypeRT Gateway Agent Note](../../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md) owns the architecture and transport decisions; this page records the literal public contracts from [`dsh-type-meta`](../../packages/typert/type-meta/src/types.ts) and [`dsh-api-gateway`](../../packages/api/gateway/src/types.ts). ## Lookup and Context declarations @@ -126,7 +126,7 @@ interface TypeRTService { } ``` -Generated consumer declarations merge direct namespaces into the map inherited by `ClientApi`. +Generated consumer declarations merge direct namespaces into the map inherited by `TypeRTClientApi`. ```ts type-equiv /** Merge-extensible direct namespace surface generated for Client API services. */ @@ -191,8 +191,8 @@ interface TypertGateway { `ctx.api` exposes only namespaces contributed by imported `/remote` artifacts. Mounting installs the generated descriptors and concrete root/scoped methods as one fiber-owned operation; no JavaScript Proxy or Host Service type enters the consumer. ```ts type-equiv -/** Typed API service augmented by generated direct Remote namespaces. */ -interface ClientApi extends TypeRTRemoteNamespaceMap { +/** Client API capability implemented by the Gateway and consumed by Remote assemblies. */ +interface TypeRTClientApi extends TypeRTRemoteNamespaceMap { /** * Mount one generated Host-for-Client contribution in the caller's fiber. * @param contribution - explicitly selected Remote package artifact. diff --git a/docs/core-data-structures/typert.zh.md b/docs/core-data-structures/typert.zh.md index 3cc0aa2640..18c24018f4 100644 --- a/docs/core-data-structures/typert.zh.md +++ b/docs/core-data-structures/typert.zh.md @@ -2,7 +2,7 @@ [English](typert.md) | 中文 -以下类型由生成的 Remote 产物、Host Gateway 与消费方 API assembly 共用。[TypeRT Gateway Agent Note](../../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md) 负责架构与传输决策;本页记录 [`dsh-type-meta`](../../packages/typert/type-meta/src/types.ts) 和 [`dsh-host-api-gateway`](../../packages/host/api-gateway/src/types.ts) 中公共契约的字面定义。 +以下类型由生成的 Remote 产物、Host Gateway 与消费方 API assembly 共用。[TypeRT Gateway Agent Note](../../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md) 负责架构与传输决策;本页记录 [`dsh-type-meta`](../../packages/typert/type-meta/src/types.ts) 和 [`dsh-api-gateway`](../../packages/api/gateway/src/types.ts) 中公共契约的字面定义。 ## Lookup 与 Context 声明 @@ -126,7 +126,7 @@ interface TypeRTService { } ``` -生成的消费方声明会把 direct namespace 合并到 `ClientApi` 继承的 map 中。 +生成的消费方声明会把 direct namespace 合并到 `TypeRTClientApi` 继承的 map 中。 ```ts type-equiv /** Merge-extensible direct namespace surface generated for Client API services. */ @@ -191,8 +191,8 @@ interface TypertGateway { `ctx.api` 只暴露由已导入 `/remote` 产物贡献的 namespace。挂载会把生成的 descriptor 与具体的 root/scoped 方法作为一项由 fiber 持有的操作统一注册;JavaScript Proxy 与 Host 服务类型都不会进入消费方。 ```ts type-equiv -/** Typed API service augmented by generated direct Remote namespaces. */ -interface ClientApi extends TypeRTRemoteNamespaceMap { +/** Client API capability implemented by the Gateway and consumed by Remote assemblies. */ +interface TypeRTClientApi extends TypeRTRemoteNamespaceMap { /** * Mount one generated Host-for-Client contribution in the caller's fiber. * @param contribution - explicitly selected Remote package artifact. diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index 2ea336b1f0..b0809af72e 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/development.md -development.md: d480f548dd24ea81d132e4b4c0cc364ce1b0cd53 -development.zh.md: 08ef7fd2d3da7db83eb3ca4dff9f9c85f6d7cb5e +development.md: f832956c4c7cbde96613a69db6c636a2246786a7 +development.zh.md: 3ae70e7135ad5faee0e37d99f55cdb41373aab2c diff --git a/docs/development.md b/docs/development.md index d480f548dd..f832956c4c 100644 --- a/docs/development.md +++ b/docs/development.md @@ -62,7 +62,7 @@ Host and client stay two aggregate programs because both sides declaration-merge Static analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. Decision record: [solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md); the tsc-first emit pipeline is the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md). -Business services declare callable methods on the Host with `@Remote` or `@RemoteContext`; the Host build generates Host-for-Client types and runtime contributions, and the Client's `client-remotes` composition loads those contributions and mounts concrete methods on `ctx.api` or the corresponding scoped Context. See [API Gateway](api-gateway.md) for the generated artifacts on both sides, their assembly relationships, the SRC development fallback, and the Web build order. +Business services declare callable methods on the Host with `@Remote` or `@RemoteContext`; the Host build generates Host-for-Client types and runtime contributions, and the Client's `api-remotes` composition loads those contributions and mounts concrete methods on `ctx.api` or the corresponding scoped Context. See [API Gateway](api-gateway.md) for the generated artifacts on both sides, their assembly relationships, the SRC development fallback, and the Web build order. If a relevant local check consumes built package output, build once first: diff --git a/docs/development.zh.md b/docs/development.zh.md index 08ef7fd2d3..3ae70e7135 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -62,7 +62,7 @@ host 与 client 保持两个聚合 program,是因为两侧在相同键下以 静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。决策记录:[solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md);tsc-first 发射管线见 [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md)。 -业务 Service 在 Host 使用 `@Remote` 或 `@RemoteContext` 声明可调用方法;Host 构建生成 Host-for-Client 类型与运行时贡献,Client 的 `client-remotes` 组合加载这些贡献并将具体方法挂载到 `ctx.api` 或对应的作用域 Context。两侧的生成产物、装配关系、SRC 开发回退和 Web 构建顺序见 [API Gateway](api-gateway.md)。 +业务 Service 在 Host 使用 `@Remote` 或 `@RemoteContext` 声明可调用方法;Host 构建生成 Host-for-Client 类型与运行时贡献,Client 的 `api-remotes` 组合加载这些贡献并将具体方法挂载到 `ctx.api` 或对应的作用域 Context。两侧的生成产物、装配关系、SRC 开发回退和 Web 构建顺序见 [API Gateway](api-gateway.md)。 如果相关的本地检查需要使用构建后的包产物,请先构建一次: diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 34f4d37ffd..92bf908613 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -66,7 +66,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `credentials/changed` | `runtime` (`emit`) | `ui-models` | | `internal/dispatch` | - | [`commands`](../packages/ui/commands), [`compact`](../packages/compact/compact), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`plan-mode`](../packages/plan/plan-mode), [`pty-local`](../packages/pty/pty-local), `runtime`, [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`subagent`](../packages/subagent/subagent), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`workflow`](../packages/workflow/workflow) | | `internal/plugin` | - | `hmr`, `loader`, `modules`, `webserver` | -| `internal/service` | - | `api-gateway` | +| `internal/service` | - | `gateway` | | `internal/status` | - | [`agent`](../packages/core/agent) | | `locale/change` | `locale` (`emit`) | `locale` | | `models/changed` | `runtime` (`emit`) | `ui-models` | diff --git a/docs/module-graph.md b/docs/module-graph.md index ac46e968b3..43923e0865 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -146,6 +146,10 @@ flowchart TD pkg_user_approval["user-approval"] pkg_user_interaction["user-interaction"] end + subgraph group_api["packages/api"] + pkg_api_gateway["api-gateway"] + pkg_api_remotes["api-remotes"] + end subgraph group_bundle["packages/bundle"] pkg_base["base"] pkg_headless["headless"] @@ -156,7 +160,6 @@ flowchart TD pkg_client_hmr["client-hmr"] pkg_client_locale["client-locale"] pkg_client_modules["client-modules"] - pkg_client_remotes["client-remotes"] pkg_client_runtime["client-runtime"] pkg_client_schema_form["client-schema-form"] pkg_client_test_runtime["client-test-runtime"] @@ -212,7 +215,6 @@ flowchart TD end subgraph group_host["packages/host"] pkg_frontend_static["frontend-static"] - pkg_host_api_gateway["host-api-gateway"] pkg_host_apiproxy["host-apiproxy"] pkg_host_directory_picker["host-directory-picker"] pkg_host_directory_picker_auto["host-directory-picker-auto"] @@ -367,13 +369,13 @@ flowchart TD pkg_system_prompt --> pkg_scope pkg_web --> pkg_invariants pkg_web --> pkg_llm + pkg_api_gateway --> pkg_client_connection + pkg_api_gateway --> pkg_invariants + pkg_api_gateway --> pkg_typert_registry pkg_credentials_local --> pkg_atomic_write pkg_credentials_local --> pkg_credentials pkg_credentials_local --> pkg_invariants pkg_credentials_local --> pkg_paths - pkg_host_api_gateway --> pkg_client_connection - pkg_host_api_gateway --> pkg_invariants - pkg_host_api_gateway --> pkg_typert_registry pkg_lsp --> pkg_brand pkg_lsp --> pkg_invariants pkg_lsp --> pkg_llm @@ -614,6 +616,7 @@ flowchart TD pkg_permission --> pkg_session_projection pkg_permission --> pkg_settings pkg_permission --> pkg_user_approval +<<<<<<< HEAD <<<<<<< HEAD pkg_client_ui_conversation --> pkg_client_locale pkg_client_ui_conversation --> pkg_client_runtime @@ -630,6 +633,14 @@ flowchart TD pkg_client_remotes --> pkg_host_api_gateway pkg_client_remotes --> pkg_invariants >>>>>>> d6e02ebbcc (feat(goal): add TypeRT gateway example) +======= + pkg_api_remotes --> pkg_agent + pkg_api_remotes --> pkg_goal + pkg_api_remotes --> pkg_invariants + pkg_api_remotes --> pkg_session + pkg_api_remotes --> pkg_session_persistence + pkg_api_remotes --> pkg_typert_registry +>>>>>>> c671d9e48c (refactor(api): colocate gateway and remote assembly) pkg_pty_local --> pkg_agent pkg_pty_local --> pkg_invariants pkg_pty_local --> pkg_pty @@ -783,6 +794,7 @@ flowchart TD pkg_tool_ask_user --> pkg_invariants pkg_tool_ask_user --> pkg_tools pkg_tool_ask_user --> pkg_user_interaction +<<<<<<< HEAD <<<<<<< HEAD pkg_client_ui_command --> pkg_client_connection pkg_client_ui_command --> pkg_client_locale @@ -815,6 +827,9 @@ flowchart TD pkg_client_ui_skill --> pkg_invariants ======= pkg_client_runtime --> pkg_client_remotes +======= + pkg_client_runtime --> pkg_api_remotes +>>>>>>> c671d9e48c (refactor(api): colocate gateway and remote assembly) pkg_client_runtime --> pkg_invariants pkg_client_runtime --> pkg_type_meta pkg_client_runtime --> pkg_typert_registry @@ -1132,8 +1147,8 @@ flowchart TD pkg_client_ui_command --> pkg_client_ui_slash pkg_client_ui_command --> pkg_client_ui_slots pkg_client_ui_command --> pkg_invariants + pkg_client_ui_goal --> pkg_api_remotes pkg_client_ui_goal --> pkg_client_locale - pkg_client_ui_goal --> pkg_client_remotes pkg_client_ui_goal --> pkg_client_runtime pkg_client_ui_goal --> pkg_client_ui_conversation pkg_client_ui_goal --> pkg_client_ui_primitives @@ -1231,8 +1246,8 @@ flowchart TD | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`type-meta`](../packages/typert/type-meta) | | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`web`](../packages/web/web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | +| [`api-gateway`](../packages/api/gateway) | `api` | [`client-connection`](../packages/client/connection), [`invariants`](../packages/support/invariants), [`typert-registry`](../packages/typert/registry) | | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | -| [`host-api-gateway`](../packages/host/api-gateway) | `host` | [`client-connection`](../packages/client/connection), [`invariants`](../packages/support/invariants), [`typert-registry`](../packages/typert/registry) | | [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`settings-local`](../packages/settings/settings-local) | `settings` | [`atomic-write`](../packages/util/atomic-write), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`settings`](../packages/settings/settings) | @@ -1295,11 +1310,15 @@ flowchart TD | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) | | [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection), [`settings`](../packages/settings/settings), [`user-approval`](../packages/ui/user-approval) | <<<<<<< HEAD +<<<<<<< HEAD | [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`token-meter`](../packages/llm/token-meter) | | [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | ======= | [`client-remotes`](../packages/client/remotes) | `client` | [`goal`](../packages/goal/goal), [`host-api-gateway`](../packages/host/api-gateway), [`invariants`](../packages/support/invariants) | >>>>>>> d6e02ebbcc (feat(goal): add TypeRT gateway example) +======= +| [`api-remotes`](../packages/api/remotes) | `api` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`typert-registry`](../packages/typert/registry) | +>>>>>>> c671d9e48c (refactor(api): colocate gateway and remote assembly) | [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess) | | [`tasks-local`](../packages/tasks/tasks-local) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tasks`](../packages/tasks/tasks), [`timeout`](../packages/util/timeout) | | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | @@ -1326,6 +1345,7 @@ flowchart TD | [`agent-loop-testkit`](../packages/support/agent-loop-testkit) | `support` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-ask-user`](../packages/ui/tool-ask-user) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | <<<<<<< HEAD +<<<<<<< HEAD | [`client-ui-command`](../packages/client/ui-command) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-deliverables`](../packages/client/ui-deliverables) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | @@ -1333,6 +1353,9 @@ flowchart TD ======= | [`client-runtime`](../packages/client/runtime) | `client` | [`client-remotes`](../packages/client/remotes), [`invariants`](../packages/support/invariants), [`type-meta`](../packages/typert/type-meta), [`typert-registry`](../packages/typert/registry) | >>>>>>> d6e02ebbcc (feat(goal): add TypeRT gateway example) +======= +| [`client-runtime`](../packages/client/runtime) | `client` | [`api-remotes`](../packages/api/remotes), [`invariants`](../packages/support/invariants), [`type-meta`](../packages/typert/type-meta), [`typert-registry`](../packages/typert/registry) | +>>>>>>> c671d9e48c (refactor(api): colocate gateway and remote assembly) | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) | | [`workspace-context`](../packages/context/workspace-context) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | `guard` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) | @@ -1383,7 +1406,7 @@ flowchart TD | [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | | [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | | [`client-ui-command`](../packages/client/ui-command) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`client-locale`](../packages/client/locale), [`client-remotes`](../packages/client/remotes), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | +| [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | | [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`plan-mode`](../packages/plan/plan-mode) | | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) | | [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | diff --git a/knip.json b/knip.json index 3ce9a32d99..3d7836104f 100644 --- a/knip.json +++ b/knip.json @@ -115,7 +115,7 @@ "tests/**/*.ts" ] }, - "packages/client/remotes": { + "packages/api/remotes": { "entry": [ "tests/**/*.e2e.ts" ], diff --git a/packages/README.i18n.yaml b/packages/README.i18n.yaml index d10f476f79..8aa9b92b91 100644 --- a/packages/README.i18n.yaml +++ b/packages/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/README.md -README.md: 8fbb6069a784a5bd45423a4e1ae11834a597750d -README.zh.md: 42a8d691344c716021188df6fd870a841d543f36 +README.md: 229feae568ba6e40a9c633696097eff46fd5bc95 +README.zh.md: b84aef020a7e3edf305df709d399fbc7b093b6a3 diff --git a/packages/README.md b/packages/README.md index 8fbb6069a7..229feae568 100644 --- a/packages/README.md +++ b/packages/README.md @@ -11,6 +11,7 @@ Packages live at `packages///`; groups are containers, while names r | Group | Role | Release expectation | |---|---|---| | [`core/`](core/README.md) | Product API spine: sessions, prompts, tools, agent services, and the concrete loop | Product — stable surface | +| [`api/`](api/README.md) | Remote BFF assembly and TypeRT RPC gateway | Product — stable surface | | [`typert/`](typert/README.md) | Type graph generation, artifact loading, and runtime registry | Product — stable surface | | [`goal/`](goal/README.md) | Same-session goal persistence and lifecycle | Product — stable surface | | [`feedback/`](feedback/README.md) | Human feedback | Product — stable surface | diff --git a/packages/README.zh.md b/packages/README.zh.md index 42a8d69134..b84aef020a 100644 --- a/packages/README.zh.md +++ b/packages/README.zh.md @@ -11,6 +11,7 @@ | 组 | 职责 | 发布预期 | |---|---|---| | [`core/`](core/README.md) | 产品 API 主干:会话、提示词、工具、agent(智能体)服务与具体循环 | 产品:稳定表面 | +| [`api/`](api/README.md) | Remote BFF 装配与 TypeRT RPC Gateway | 产品:稳定表面 | | [`typert/`](typert/README.md) | 类型图生成、产物加载与运行时注册表 | 产品:稳定表面 | | [`goal/`](goal/README.md) | 同会话 goal 的持久化与生命周期 | 产品:稳定表面 | | [`feedback/`](feedback/README.md) | 人类反馈 | 产品:稳定表面 | diff --git a/packages/client/remotes/README.i18n.yaml b/packages/api/README.i18n.yaml similarity index 56% rename from packages/client/remotes/README.i18n.yaml rename to packages/api/README.i18n.yaml index 86f2aded18..855eeb8eaa 100644 --- a/packages/client/remotes/README.i18n.yaml +++ b/packages/api/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write packages/client/remotes/README.md -README.md: e29188b8e3ae5ecefe194f1355558e9bdeaae7dd -README.zh.md: e6425ab190a28e0a38c3713c4e21645789a8f00c +# pnpm run verify-translation-pairing --write packages/api/README.md +README.md: 0dcded5922fea1ea6676315029ba0eadd74dd3df +README.zh.md: 1b9bb9133a955d0cbef0ca91728aab1545831d94 diff --git a/packages/api/README.md b/packages/api/README.md new file mode 100644 index 0000000000..0dcded5922 --- /dev/null +++ b/packages/api/README.md @@ -0,0 +1,17 @@ +# api/ — Remote API layers + +English | [中文](README.zh.md) + +The application-facing Remote stack. `remotes` owns BFF policy and the selected business API, while `gateway` implements the TypeRT unary RPC endpoints shared by Host and Client environments. + +| Package | Role | ctx key | +|---|---|---| +| [`remotes/`](remotes/README.md) | Host Agent/Session lookup policy and Client Remote contribution assembly | no service; configures `ctx.typert` and consumes `ctx.api` | +| [`gateway/`](gateway/README.md) | Host TypeRT dispatcher and Client API endpoint | `ctx.typertGateway` / `ctx.api` | + +The runtime dependency direction is `remotes → gateway → connection → webserver`: the BFF consumes the shared `TypeRTClientApi` contract, Gateway delegates transport to Connection, and Connection mounts on the HTTP server. Cordis service injection and Client module metadata preserve this order without importing the concrete Gateway from the Remotes Client entry. + +## Known Limitations and Deferred Work + +- Connection and WebServer remain at [`client/connection`](../client/connection/README.md) and [`host/webserver`](../host/webserver/README.md); a later package-only move can place them under `api/connection` and `api/webserver` without changing their service contracts. +- The legacy API Proxy remains at [`host/apiproxy`](../host/apiproxy/README.md) as the fallback for methods not yet migrated to Remote. It consumes the Host resolver owned by `api-remotes` so migrated and legacy methods retain one Agent/Session identity policy. diff --git a/packages/api/README.zh.md b/packages/api/README.zh.md new file mode 100644 index 0000000000..1b9bb9133a --- /dev/null +++ b/packages/api/README.zh.md @@ -0,0 +1,17 @@ +# api/:Remote API 层 + +[English](README.md) | 中文 + +面向应用的 Remote 技术栈。`remotes` 负责 BFF 策略和选定的业务 API,`gateway` 则实现 Host 与 Client 环境共用的 TypeRT 一元 RPC endpoint。 + +| 包 | 职责 | ctx key | +|---|---|---| +| [`remotes/`](remotes/README.md) | Host Agent/Session lookup 策略与 Client Remote contribution 装配 | 无服务;配置 `ctx.typert` 并消费 `ctx.api` | +| [`gateway/`](gateway/README.md) | Host TypeRT 分发器与 Client API endpoint | `ctx.typertGateway` / `ctx.api` | + +运行时依赖方向为 `remotes → gateway → connection → webserver`:BFF 消费共享的 `TypeRTClientApi` 契约,Gateway 把传输交给 Connection,Connection 再挂载到 HTTP server。Cordis 服务注入与 Client 模块元数据在不让 Remotes Client 入口导入具体 Gateway 实现的前提下维持该顺序。 + +## 已知限制与延期工作 + +- Connection 与 WebServer 仍位于 [`client/connection`](../client/connection/README.md) 和 [`host/webserver`](../host/webserver/README.md);后续可以只移动包,将它们放到 `api/connection` 和 `api/webserver` 下,而无需改变服务契约。 +- 旧 API Proxy 仍位于 [`host/apiproxy`](../host/apiproxy/README.md),作为尚未迁移到 Remote 的方法的回退路径。它使用由 `api-remotes` 持有的 Host resolver,使已迁移与旧方法共用同一套 Agent/Session 身份策略。 diff --git a/packages/host/api-gateway/README.i18n.yaml b/packages/api/gateway/README.i18n.yaml similarity index 56% rename from packages/host/api-gateway/README.i18n.yaml rename to packages/api/gateway/README.i18n.yaml index 8d8d699c7a..41bbb0621f 100644 --- a/packages/host/api-gateway/README.i18n.yaml +++ b/packages/api/gateway/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write packages/host/api-gateway/README.md -README.md: eb48c29628d39e381235b1f72754eb114960b1ad -README.zh.md: e53bb6c216e42fe2e970bf2cb80eac9ea7426497 +# pnpm run verify-translation-pairing --write packages/api/gateway/README.md +README.md: 9e3d4d89788bbc6edebfc0c0127999fed3ed9261 +README.zh.md: 9bbd46c71185a2fbf8da163565d6c19141c079ca diff --git a/packages/host/api-gateway/README.md b/packages/api/gateway/README.md similarity index 86% rename from packages/host/api-gateway/README.md rename to packages/api/gateway/README.md index eb48c29628..9e3d4d8978 100644 --- a/packages/host/api-gateway/README.md +++ b/packages/api/gateway/README.md @@ -1,8 +1,8 @@ -# @deepseek-ai/dsh-host-api-gateway +# @deepseek-ai/dsh-api-gateway English | [中文](README.zh.md) -Two-sided Remote control for Host and Client Cordis environments. The Host entry provides `ctx.typertGateway`, while `@deepseek-ai/dsh-host-api-gateway/client` provides `ctx.api`; both consume the same generated `InvocationDescriptor` contract and leave transport, request correlation, trust, and response envelopes to Connection. +Two-sided TypeRT RPC endpoint for Host and Client Cordis environments. The Host entry provides `ctx.typertGateway`, while `@deepseek-ai/dsh-api-gateway/client` provides `ctx.api`; both consume the same generated `InvocationDescriptor` contract and leave business selection to API Remotes and transport, request correlation, trust, and response envelopes to Connection. ## Host service: `TypertGatewayService` (ctx key: `typertGateway`) @@ -20,7 +20,7 @@ A cancellation-aware Remote method declares `signal: AbortSignal` as its final H Each call validates positional inputs, constructs the descriptor's exact named `args`, and sends it through `ctx.connection.rpc.call('/api', endpoint, ...)`. Generated cancellation-aware methods accept a final optional `AbortSignal`; the Client combines it with the contribution mount lifetime before calling Connection. The returned value is validated before reaching application code. Withdrawing a contribution removes its descriptors and methods together, aborts in-flight calls, and makes retained method handles reject. -Generated declaration merges provide the TypeScript API. The Client entry contains no Host Service or Host Cordis interface merge, and method lookup and invocation use ordinary objects and functions rather than a JavaScript Proxy. +Generated declaration merges provide the TypeScript API through the shared `TypeRTClientApi` contract. The Client entry contains no Host Service or Host Cordis interface merge, and method lookup and invocation use ordinary objects and functions rather than a JavaScript Proxy. ## Model Experience diff --git a/packages/host/api-gateway/README.zh.md b/packages/api/gateway/README.zh.md similarity index 86% rename from packages/host/api-gateway/README.zh.md rename to packages/api/gateway/README.zh.md index e53bb6c216..9bbd46c711 100644 --- a/packages/host/api-gateway/README.zh.md +++ b/packages/api/gateway/README.zh.md @@ -1,8 +1,8 @@ -# @deepseek-ai/dsh-host-api-gateway +# @deepseek-ai/dsh-api-gateway [English](README.md) | 中文 -为 Host 与 Client 两侧的 Cordis 环境提供 Remote 控制。Host 入口提供 `ctx.typertGateway`,`@deepseek-ai/dsh-host-api-gateway/client` 则提供 `ctx.api`;两者使用同一份生成的 `InvocationDescriptor` 契约,并将传输、请求关联、信任和响应封装交由 Connection 处理。 +为 Host 与 Client 两侧的 Cordis 环境提供 TypeRT RPC endpoint。Host 入口提供 `ctx.typertGateway`,`@deepseek-ai/dsh-api-gateway/client` 则提供 `ctx.api`;两者使用同一份生成的 `InvocationDescriptor` 契约,并将业务选择交给 API Remotes,将传输、请求关联、信任和响应封装交给 Connection。 ## Host 服务:`TypertGatewayService`(ctx key:`typertGateway`) @@ -20,7 +20,7 @@ Connection 可用时,Host 入口会在 Connection 共享的 `/api` FetchHandle 每次调用都会校验位置参数,构造与描述符完全匹配的具名 `args`,再通过 `ctx.connection.rpc.call('/api', endpoint, ...)` 发送。生成的支持取消的方法接受最后一个可选 `AbortSignal`;Client 会在调用 Connection 前将它与贡献项的挂载生命周期合并。返回值经过校验后才会交给应用代码。撤回贡献项会同时移除其描述符和方法、中止正在进行的调用,并使外部仍持有的方法句柄在调用时返回拒绝。 -生成的声明合并提供 TypeScript API。Client 入口不包含 Host 服务或 Host Cordis 接口合并;方法查找和调用使用普通对象与函数,而不使用 JavaScript Proxy。 +生成的声明合并通过共享的 `TypeRTClientApi` 契约提供 TypeScript API。Client 入口不包含 Host 服务或 Host Cordis 接口合并;方法查找和调用使用普通对象与函数,而不使用 JavaScript Proxy。 ## 模型体验 diff --git a/packages/host/api-gateway/package.json b/packages/api/gateway/package.json similarity index 92% rename from packages/host/api-gateway/package.json rename to packages/api/gateway/package.json index 794ae323aa..fa351d84bf 100644 --- a/packages/host/api-gateway/package.json +++ b/packages/api/gateway/package.json @@ -1,6 +1,6 @@ { - "name": "@deepseek-ai/dsh-host-api-gateway", - "description": "Host dispatcher and Client API for TypeRT Remote invocations", + "name": "@deepseek-ai/dsh-api-gateway", + "description": "TypeRT Remote Host dispatcher and Client API endpoint", "version": "0.0.1", "private": true, "type": "module", diff --git a/packages/host/api-gateway/src/client/index.ts b/packages/api/gateway/src/client/index.ts similarity index 96% rename from packages/host/api-gateway/src/client/index.ts rename to packages/api/gateway/src/client/index.ts index 5503fc3dcf..bafffa80f7 100644 --- a/packages/host/api-gateway/src/client/index.ts +++ b/packages/api/gateway/src/client/index.ts @@ -9,10 +9,9 @@ import type { Context } from 'cordis' import type { ConnectionHandle, RpcError } from '@deepseek-ai/dsh-client-connection/client' import type { InvocationDescriptor, + TypeRTClientApi, TypeRTCodec, - TypeRTDisposer, TypeRTRemoteContribution, - TypeRTRemoteNamespaceMap, } from '@deepseek-ai/dsh-type-meta' type RemoteMethod = (...args: unknown[]) => Promise @@ -40,14 +39,7 @@ interface ScopedProjection { } /** Typed API service augmented by generated direct Remote namespaces. */ -export interface ClientApi extends TypeRTRemoteNamespaceMap { - /** - * Mount one generated Host-for-Client contribution in the caller's fiber. - * @param contribution - explicitly selected Remote package artifact. - * @returns disposer withdrawing descriptors and concrete methods together. - */ - mount(contribution: TypeRTRemoteContribution): TypeRTDisposer -} +export type ClientApi = TypeRTClientApi declare module 'cordis' { interface Context { @@ -67,7 +59,7 @@ export function apply(ctx: Context): void { new ClientApiService(ctx) } -class ClientApiService extends Service implements ClientApi { +class ClientApiService extends Service implements TypeRTClientApi { private readonly ownerCtx: Context private readonly direct = new Map() private readonly scoped = new Map() @@ -77,7 +69,7 @@ class ClientApiService extends Service implements ClientApi { this.ownerCtx = ctx } - mount(contribution: TypeRTRemoteContribution): TypeRTDisposer { + mount(contribution: TypeRTRemoteContribution): ReturnType { this.validateContribution(contribution) const callerCtx = this.ctx const disposeRemote = callerCtx.typert.remotes.register(contribution) diff --git a/packages/host/api-gateway/src/index.ts b/packages/api/gateway/src/index.ts similarity index 99% rename from packages/host/api-gateway/src/index.ts rename to packages/api/gateway/src/index.ts index 8ea26b5990..13cf460f4d 100644 --- a/packages/host/api-gateway/src/index.ts +++ b/packages/api/gateway/src/index.ts @@ -1,7 +1,7 @@ /** * Live TypeRT Remote dispatch over Cordis Services and registered providers. * Transport, request correlation, and response envelopes belong to Connection. - * @module @deepseek-ai/dsh-host-api-gateway + * @module @deepseek-ai/dsh-api-gateway */ import { Context, Service, symbols } from 'cordis' diff --git a/packages/host/api-gateway/src/invariant.ts b/packages/api/gateway/src/invariant.ts similarity index 77% rename from packages/host/api-gateway/src/invariant.ts rename to packages/api/gateway/src/invariant.ts index 65c94b4ac4..711c4edab5 100644 --- a/packages/host/api-gateway/src/invariant.ts +++ b/packages/api/gateway/src/invariant.ts @@ -1,16 +1,16 @@ /** - * Package-owned invariant companion for `@deepseek-ai/dsh-host-api-gateway`. - * @module @deepseek-ai/dsh-host-api-gateway/invariant + * Package-owned invariant companion for `@deepseek-ai/dsh-api-gateway`. + * @module @deepseek-ai/dsh-api-gateway/invariant */ /* jscpd:ignore-start */ import type { Context } from 'cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' -const PACKAGE_NAME = '@deepseek-ai/dsh-host-api-gateway' +const PACKAGE_NAME = '@deepseek-ai/dsh-api-gateway' /** Cordis companion plugin name. */ -export const name = 'host-api-gateway-invariant' +export const name = 'api-gateway-invariant' /** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] diff --git a/packages/host/api-gateway/src/types.ts b/packages/api/gateway/src/types.ts similarity index 97% rename from packages/host/api-gateway/src/types.ts rename to packages/api/gateway/src/types.ts index f4bb276c22..0917ba2ca6 100644 --- a/packages/host/api-gateway/src/types.ts +++ b/packages/api/gateway/src/types.ts @@ -1,6 +1,6 @@ /** * Carrier-independent TypeRT Gateway request, service, and error contracts. - * @module @deepseek-ai/dsh-host-api-gateway/types + * @module @deepseek-ai/dsh-api-gateway/types */ /** One Remote method request after a carrier has decoded its envelope. */ diff --git a/packages/host/api-gateway/tests/client.spec.ts b/packages/api/gateway/tests/client.spec.ts similarity index 100% rename from packages/host/api-gateway/tests/client.spec.ts rename to packages/api/gateway/tests/client.spec.ts diff --git a/packages/host/api-gateway/tests/gateway.spec.ts b/packages/api/gateway/tests/gateway.spec.ts similarity index 99% rename from packages/host/api-gateway/tests/gateway.spec.ts rename to packages/api/gateway/tests/gateway.spec.ts index 0871dc2761..d784a1ac2f 100644 --- a/packages/host/api-gateway/tests/gateway.spec.ts +++ b/packages/api/gateway/tests/gateway.spec.ts @@ -16,7 +16,7 @@ import { type TypeRTLookupProvider, } from '@deepseek-ai/dsh-type-meta' import TypertRegistry, { type TypertContribution } from '@deepseek-ai/dsh-typert-registry' -import TypertGatewayService, { TypertGatewayError } from '@deepseek-ai/dsh-host-api-gateway' +import TypertGatewayService, { TypertGatewayError } from '@deepseek-ai/dsh-api-gateway' interface FixtureAgent { readonly id: string diff --git a/packages/host/api-gateway/tsconfig.json b/packages/api/gateway/tsconfig.json similarity index 100% rename from packages/host/api-gateway/tsconfig.json rename to packages/api/gateway/tsconfig.json diff --git a/packages/api/gateway/tsdown.config.ts b/packages/api/gateway/tsdown.config.ts new file mode 100644 index 0000000000..f9049b6067 --- /dev/null +++ b/packages/api/gateway/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../../client/tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-api-gateway', ['lib/types/index.js', 'lib/types/invariant.js']) diff --git a/packages/api/remotes/README.i18n.yaml b/packages/api/remotes/README.i18n.yaml new file mode 100644 index 0000000000..c3c13a8049 --- /dev/null +++ b/packages/api/remotes/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/api/remotes/README.md +README.md: cf54a56a849246d4efdca09cadd42e157064bdee +README.zh.md: 5cd7ef21c926440ca4df6d88ee4adfe87defcc3f diff --git a/packages/api/remotes/README.md b/packages/api/remotes/README.md new file mode 100644 index 0000000000..cf54a56a84 --- /dev/null +++ b/packages/api/remotes/README.md @@ -0,0 +1,25 @@ +# @deepseek-ai/dsh-api-remotes + +English | [中文](README.zh.md) + +Two-sided BFF for Host Remote capabilities selected by this application. The Host entry owns Agent/Session identity policy; the Client entry imports generated `/remote` artifacts as runtime values, mounts each contribution through `ctx.api`, and re-exports their declaration merges. Client business packages depend on this facade rather than the Gateway implementation or individual Remote runtime entries. + +`createApiRemoteAgentResolver()` reuses live Agents, resumes ordinary cold sessions, deduplicates concurrent resumes, preserves the subagent ownership fence, and configures the same resolver for TypeRT `agent` and `session` lookups. The standard Web API Proxy supplies its Agent defaults and scope setup, then uses the returned resolver for legacy methods, so migrated and unmigrated methods share one policy implementation. + +The current Client assembly mounts only the Goal Remote contribution. Cordis effect ownership withdraws every contribution when this assembly unloads, while `@deepseek-ai/dsh-api-gateway/client` owns descriptor validation, concrete root and scoped methods, invocation, and cancellation. The Client entry consumes the shared `TypeRTClientApi` interface through Cordis and does not import the concrete Gateway. + +This package contains no transport or Host service discovery logic. Its Client face can be reused by Web or a future TUI that provides the same React-free `ctx.api` contract. + +## Model Experience + +None, as this BFF selects Remote application methods and identity policy but registers no model surface. + +#### KV Cache effect + +No direct effect; mounted Host capabilities own any model-visible behavior they trigger. + +## Known Limitations and Deferred Work + +- The capability set is fixed by explicit build-time value imports; the Client does not discover the Host's active Services or Remote definitions at runtime. +- Additional capabilities require an explicit `/remote` value import and mount in this assembly. +- The standard Web Host supplies resume defaults and Agent-scope setup from the legacy API Proxy until that remaining BFF configuration moves into `api-remotes`. diff --git a/packages/api/remotes/README.zh.md b/packages/api/remotes/README.zh.md new file mode 100644 index 0000000000..5cd7ef21c9 --- /dev/null +++ b/packages/api/remotes/README.zh.md @@ -0,0 +1,25 @@ +# @deepseek-ai/dsh-api-remotes + +[English](README.md) | 中文 + +为本应用选定的 Host Remote 能力提供双侧 BFF。Host 入口负责 Agent/Session 身份策略;Client 入口以运行时值形式导入生成的 `/remote` 产物,通过 `ctx.api` 挂载每项贡献,并重新导出对应的声明合并。Client 业务包依赖该外观,而不依赖 Gateway 实现或单独的 Remote 运行时入口。 + +`createApiRemoteAgentResolver()` 会复用 live Agent、恢复普通冷会话、对并发恢复去重、保留 subagent ownership fence,并为 TypeRT `agent` 和 `session` lookup 配置同一个 resolver。标准 Web API Proxy 提供 Agent 默认值和 scope 设置,再将返回的 resolver 用于旧方法,使已迁移与未迁移方法共用同一份策略实现。 + +当前 Client 组合仅挂载 Goal Remote 贡献。该组合卸载时,Cordis effect 的所有权机制会撤回所有贡献;`@deepseek-ai/dsh-api-gateway/client` 负责描述符校验、具体的根级方法和作用域方法、调用与取消。Client 入口通过 Cordis 消费共享的 `TypeRTClientApi` 接口,不导入具体 Gateway。 + +本包不包含传输逻辑或 Host 服务发现逻辑。Web 或未来的 TUI 只要提供同一份不依赖 React 的 `ctx.api` 契约,均可复用其 Client face。 + +## 模型体验 + +无,因为该 BFF 只选择 Remote 应用方法和身份策略,不注册任何模型接口。 + +#### KV Cache 影响 + +无直接影响;其触发的任何模型可见行为均由已挂载的 Host 能力负责。 + +## 已知限制与暂缓事项 + +- 能力集合由构建时显式导入的值固定确定;Client 不会在运行时发现 Host 中已启用的服务或 Remote 定义。 +- 若要增加能力,必须显式导入相应的 `/remote` 值并在此组合中挂载。 +- 在剩余 BFF 配置迁移到 `api-remotes` 之前,标准 Web Host 仍从旧 API Proxy 提供恢复默认值与 Agent scope 设置。 diff --git a/packages/client/remotes/package.json b/packages/api/remotes/package.json similarity index 64% rename from packages/client/remotes/package.json rename to packages/api/remotes/package.json index ba4e7b6a01..0a1e3ec71d 100644 --- a/packages/client/remotes/package.json +++ b/packages/api/remotes/package.json @@ -1,6 +1,6 @@ { - "name": "@deepseek-ai/dsh-client-remotes", - "description": "Platform-neutral assembly of explicitly selected Host Remote contributions", + "name": "@deepseek-ai/dsh-api-remotes", + "description": "Remote BFF assembly and Host Agent/Session lookup policy", "version": "0.0.1", "private": true, "type": "module", @@ -24,7 +24,7 @@ }, "dshClient": { "inject": [ - "@deepseek-ai/dsh-host-api-gateway" + "@deepseek-ai/dsh-api-gateway" ], "platform": "web", "immediately": true @@ -40,16 +40,25 @@ "lib/client.js", "lib/types/**/*.d.ts" ], + "dependencies": { + "@deepseek-ai/dsh-type-meta": "workspace:^" + }, "peerDependencies": { - "@deepseek-ai/dsh-host-api-gateway": "^0.0.1", + "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-goal": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-session-persistence": "^0.0.1", + "@deepseek-ai/dsh-typert-registry": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { - "@deepseek-ai/dsh-host-api-gateway": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-persistence": "workspace:^", + "@deepseek-ai/dsh-typert-registry": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/api/remotes/src/agent-lookup.ts b/packages/api/remotes/src/agent-lookup.ts new file mode 100644 index 0000000000..e3a5b27df8 --- /dev/null +++ b/packages/api/remotes/src/agent-lookup.ts @@ -0,0 +1,193 @@ +/** Host BFF policy for resolving Remote Agent and Session identities. */ + +import type { Context } from 'cordis' +import type { Agent, AgentOptions, AgentSetup } from '@deepseek-ai/dsh-agent' +import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' +import type {} from '@deepseek-ai/dsh-session-persistence' +import { TypeRTLookupFailure } from '@deepseek-ai/dsh-type-meta' +import type {} from '@deepseek-ai/dsh-typert-registry' + +/** Caller-facing failures preserved by the Gateway's RPC adapter. */ +export type ApiRemoteLookupError = + | { readonly code: 'agent-busy'; readonly message: string; readonly details: { readonly reason: string } } + | { readonly code: 'session-not-found'; readonly message: string; readonly details: { readonly sessionId: SessionId } } + | { readonly code: 'internal'; readonly message: string; readonly details: Record } + +/** Result of resolving one session identity to its live Agent. */ +export type ApiRemoteAgentResult = + | { readonly agent: Agent } + | { readonly error: ApiRemoteLookupError } + +/** Resume configuration supplied by the owning Host composition. */ +export interface ApiRemoteAgentOptions { + /** Per-Agent defaults used when a cold identity must resume. */ + readonly agentOptions?: AgentOptions + /** Host-specific Agent-scope composition completed before publication. */ + readonly setup?: AgentSetup +} + +/** Cold identity absent from the durable session store. */ +export class ApiRemoteSessionNotFound extends Error {} + +/** Session identity whose lifecycle belongs to subagent routing. */ +export class ApiRemoteSubagentSessionOwnership extends Error { + /** + * Construct the ownership fence. + * @param sessionId - identity reserved to subagent routing. + */ + constructor(readonly sessionId: SessionId) { + super(`session "${sessionId}" is a subagent session; use subagent delivery`) + } +} + +/** + * Test whether generic Host routing must leave an identity to subagent routing. + * @param ctx - Host Context carrying the live Agent registry. + * @param session - attached or live Session metadata. + * @param agent - live Agent when one is registered. + * @returns whether generic Remote and legacy API calls must reject the identity. + */ +export function hasApiRemoteSubagentOwner( + ctx: Context, + session: Pick, + agent: Agent | undefined, +): boolean { + if (session.header.origin === 'subagent') return true + const parentId = session.header.parentSession + if (parentId === undefined || agent === undefined) return false + const parent = ctx.agents.get(parentId) + return parent !== undefined && ctx.agents.isOwnedBy(agent.id, parent) +} + +/** + * Build the stable caller-facing ownership rejection. + * @param sessionId - identity reserved to subagent routing. + * @returns the existing `agent-busy` RPC shape. + */ +export function apiRemoteSubagentOwnershipError(sessionId: SessionId): ApiRemoteLookupError { + return { + code: 'agent-busy', + message: `session "${sessionId}" is owned by subagent routing`, + details: { reason: 'use subagent delivery for this child session' }, + } +} + +/** + * Inspect one cold served session without repairing, resuming, or publishing it. + * @param ctx - Host Context carrying the optional persistence provider. + * @param sessionId - durable identity to inspect. + * @returns detached metadata and events for a servable session. + * @throws {@link ApiRemoteSessionNotFound} when the identity has no project-backed session. + */ +export async function inspectApiRemoteSession( + ctx: Context, + sessionId: SessionId, +): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { + const persistence = ctx.get('sessionPersistence') + if (persistence === undefined) { + throw new Error('session persistence is not configured (load a dsh-session-persistence backend)') + } + const meta = (await persistence.list()).find(candidate => candidate.id === sessionId) + if (meta === undefined || meta.cwd === undefined) { + throw new ApiRemoteSessionNotFound(`session "${sessionId}" not found`) + } + const inspected = await persistence.inspect(sessionId) + if (inspected.meta.cwd === undefined) { + throw new ApiRemoteSessionNotFound(`session "${sessionId}" not found`) + } + return { meta: inspected.meta, events: [...inspected.events] } +} + +/** + * Create the Host's shared Agent resolver and configure Agent/Session TypeRT lookups. + * Live Agents are reused, ordinary cold sessions resume once per identity, and + * subagent-owned identities retain the legacy `agent-busy` fence. + * @param ctx - owning Host Context. + * @param options - defaults and Agent-scope setup used only for cold resume. + * @returns resolver shared by legacy API Proxy methods and TypeRT lookups. + */ +export function createApiRemoteAgentResolver( + ctx: Context, + options: ApiRemoteAgentOptions, +): (sessionId: SessionId) => Promise { + const resumes = new Map>() + + const fencedLiveAgent = (sessionId: SessionId): ApiRemoteAgentResult | undefined => { + const live = ctx.agents.get(sessionId) + if (live === undefined) return undefined + if (hasApiRemoteSubagentOwner(ctx, live.session, live)) { + return { error: apiRemoteSubagentOwnershipError(sessionId) } + } + return { agent: live } + } + + const agentFor = async (sessionId: SessionId): Promise => { + const fenced = fencedLiveAgent(sessionId) + if (fenced !== undefined) return fenced + const attached = ctx.sessions.get(sessionId) + if (attached !== undefined && hasApiRemoteSubagentOwner(ctx, attached, undefined)) { + return { error: apiRemoteSubagentOwnershipError(sessionId) } + } + let resume = resumes.get(sessionId) + if (resume === undefined) { + resume = (async () => { + try { + const inspected = await inspectApiRemoteSession(ctx, sessionId) + if (hasApiRemoteSubagentOwner(ctx, { header: inspected.meta }, undefined)) { + throw new ApiRemoteSubagentSessionOwnership(sessionId) + } + const publishedSession = ctx.sessions.get(sessionId) + const publishedAgent = ctx.agents.get(sessionId) + if (publishedSession !== undefined + && hasApiRemoteSubagentOwner(ctx, publishedSession, publishedAgent)) { + throw new ApiRemoteSubagentSessionOwnership(sessionId) + } + const handle = await ctx.agents.resume({ + resumeSessionId: sessionId, + ...options.agentOptions === undefined ? {} : { agentOptions: options.agentOptions }, + ...options.setup === undefined ? {} : { setup: options.setup }, + }) + return handle.agent + } finally { + resumes.delete(sessionId) + } + })() + resumes.set(sessionId, resume) + } + try { + return { agent: await resume } + } catch (error: unknown) { + if (error instanceof ApiRemoteSessionNotFound) { + return { error: { code: 'session-not-found', message: error.message, details: { sessionId } } } + } + if (error instanceof ApiRemoteSubagentSessionOwnership) { + return { error: apiRemoteSubagentOwnershipError(error.sessionId) } + } + const fenced = fencedLiveAgent(sessionId) + if (fenced !== undefined) return fenced + const attached = ctx.sessions.get(sessionId) + if (attached !== undefined && hasApiRemoteSubagentOwner(ctx, attached, undefined)) { + return { error: apiRemoteSubagentOwnershipError(sessionId) } + } + return { + error: { + code: 'internal', + message: `resume failed for session "${sessionId}": ${String(error)}`, + details: {}, + }, + } + } + } + + ctx.inject(['typert'], (typeCtx) => { + const resolveAgent = async (sessionId: SessionId): Promise => { + const found = await agentFor(sessionId) + if ('error' in found) throw new TypeRTLookupFailure(found.error) + return found.agent + } + typeCtx.typert.lookups.configure('agent', resolveAgent) + typeCtx.typert.lookups.configure('session', async sessionId => (await resolveAgent(sessionId)).session) + }) + + return agentFor +} diff --git a/packages/client/remotes/src/client/index.ts b/packages/api/remotes/src/client/index.ts similarity index 64% rename from packages/client/remotes/src/client/index.ts rename to packages/api/remotes/src/client/index.ts index 09757b5e9e..1bc36b62ee 100644 --- a/packages/client/remotes/src/client/index.ts +++ b/packages/api/remotes/src/client/index.ts @@ -1,12 +1,19 @@ /** Platform-neutral assembly of generated Host Remote contributions. */ import type { Context } from 'cordis' -import type {} from '@deepseek-ai/dsh-host-api-gateway/client' import goalsRemote from '@deepseek-ai/dsh-goal/remote' +import type { TypeRTClientApi } from '@deepseek-ai/dsh-type-meta' -export type { ClientApi } from '@deepseek-ai/dsh-host-api-gateway/client' +export type { TypeRTClientApi as ClientApi } from '@deepseek-ai/dsh-type-meta' export type {} from '@deepseek-ai/dsh-goal/remote' +declare module 'cordis' { + interface Context { + /** Generated direct Remote namespaces selected by this Client assembly. */ + api: TypeRTClientApi + } +} + /** Required service: the typed Client API contribution mount. */ export const inject = ['api'] diff --git a/packages/api/remotes/src/index.ts b/packages/api/remotes/src/index.ts new file mode 100644 index 0000000000..4cd70f4a78 --- /dev/null +++ b/packages/api/remotes/src/index.ts @@ -0,0 +1,18 @@ +/** Host BFF entry and Loader shell for the Remote contribution assembly. */ + +export { + ApiRemoteSessionNotFound, + ApiRemoteSubagentSessionOwnership, + apiRemoteSubagentOwnershipError, + createApiRemoteAgentResolver, + hasApiRemoteSubagentOwner, + inspectApiRemoteSession, +} from './agent-lookup.ts' +export type { + ApiRemoteAgentOptions, + ApiRemoteAgentResult, + ApiRemoteLookupError, +} from './agent-lookup.ts' + +/** Host plugin body; the selected contributions mount only in Client environments. */ +export function apply(): void {} diff --git a/packages/client/remotes/src/invariant.ts b/packages/api/remotes/src/invariant.ts similarity index 70% rename from packages/client/remotes/src/invariant.ts rename to packages/api/remotes/src/invariant.ts index 1a6b0ba237..3310bed11f 100644 --- a/packages/client/remotes/src/invariant.ts +++ b/packages/api/remotes/src/invariant.ts @@ -1,17 +1,17 @@ -/** Package-owned invariant companion for `@deepseek-ai/dsh-client-remotes`. */ +/** Package-owned invariant companion for `@deepseek-ai/dsh-api-remotes`. */ /* jscpd:ignore-start */ import type { Context } from 'cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' -const PACKAGE_NAME = '@deepseek-ai/dsh-client-remotes' +const PACKAGE_NAME = '@deepseek-ai/dsh-api-remotes' /** Cordis companion plugin name. */ -export const name = 'client-remotes-invariant' +export const name = 'api-remotes-invariant' /** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** No runtime invariant: the API service owns contribution and method lifecycle atomically. */ +/** No runtime invariant: TypeRT and the Agent/Session registries own the observed relationships. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/client/remotes/tests/built-lib.e2e.ts b/packages/api/remotes/tests/built-lib.e2e.ts similarity index 95% rename from packages/client/remotes/tests/built-lib.e2e.ts rename to packages/api/remotes/tests/built-lib.e2e.ts index 0cee3eb245..b8f6c81e98 100644 --- a/packages/client/remotes/tests/built-lib.e2e.ts +++ b/packages/api/remotes/tests/built-lib.e2e.ts @@ -17,13 +17,13 @@ const artifactUrl = (path: string): string => pathToFileURL(artifact(path)).href const requiredArtifacts = [ 'packages/client/connection/lib/client.js', 'packages/client/connection/lib/index.js', - 'packages/client/remotes/lib/client.js', + 'packages/api/remotes/lib/client.js', 'packages/core/agent/lib/index.js', 'packages/core/session/lib/index.js', 'packages/goal/goal/lib/index.js', 'packages/goal/goal/lib/typert.host.js', - 'packages/host/api-gateway/lib/client.js', - 'packages/host/api-gateway/lib/index.js', + 'packages/api/gateway/lib/client.js', + 'packages/api/gateway/lib/index.js', 'packages/typert/registry/lib/client.js', 'packages/typert/registry/lib/index.js', ].every(path => existsSync(artifact(path))) @@ -32,15 +32,15 @@ describe.skipIf(!requiredArtifacts)('Goal Remote built LIB chain', () => { it('runs root and Agent-scoped calls through generated bundles and real HTTP', async () => { const urls = Object.fromEntries(Object.entries({ agent: 'packages/core/agent/lib/index.js', - apiGatewayClient: 'packages/host/api-gateway/lib/client.js', - apiGatewayHost: 'packages/host/api-gateway/lib/index.js', + apiGatewayClient: 'packages/api/gateway/lib/client.js', + apiGatewayHost: 'packages/api/gateway/lib/index.js', connectionClient: 'packages/client/connection/lib/client.js', connectionHost: 'packages/client/connection/lib/index.js', goal: 'packages/goal/goal/lib/index.js', goalTypert: 'packages/goal/goal/lib/typert.host.js', registryClient: 'packages/typert/registry/lib/client.js', registryHost: 'packages/typert/registry/lib/index.js', - remotesClient: 'packages/client/remotes/lib/client.js', + remotesClient: 'packages/api/remotes/lib/client.js', session: 'packages/core/session/lib/index.js', }).map(([key, path]) => [key, artifactUrl(path)])) const script = ` @@ -131,8 +131,8 @@ describe.skipIf(!requiredArtifacts)('Goal Remote built LIB chain', () => { for (const id of [ '@deepseek-ai/dsh-typert-registry', '@deepseek-ai/dsh-client-connection', - '@deepseek-ai/dsh-host-api-gateway', - '@deepseek-ai/dsh-client-remotes', + '@deepseek-ai/dsh-api-gateway', + '@deepseek-ai/dsh-api-remotes', ]) { const plugin = instantiate(id) await client.plugin({ inject: plugin.inject, apply: plugin.apply }) diff --git a/packages/client/remotes/tsconfig.json b/packages/api/remotes/tsconfig.json similarity index 63% rename from packages/client/remotes/tsconfig.json rename to packages/api/remotes/tsconfig.json index c99a5fce19..148804dc0f 100644 --- a/packages/client/remotes/tsconfig.json +++ b/packages/api/remotes/tsconfig.json @@ -12,7 +12,19 @@ "path": "../../../vendor/cordis" }, { - "path": "../../host/api-gateway" + "path": "../../core/agent" + }, + { + "path": "../../core/session" + }, + { + "path": "../../session-persistence/session-persistence" + }, + { + "path": "../../typert/type-meta" + }, + { + "path": "../../typert/registry" }, { "path": "../../ui/commands" diff --git a/packages/api/remotes/tsdown.config.ts b/packages/api/remotes/tsdown.config.ts new file mode 100644 index 0000000000..287b2c7975 --- /dev/null +++ b/packages/api/remotes/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../../client/tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-api-remotes', ['lib/types/index.js', 'lib/types/invariant.js']) diff --git a/packages/bundle/base/cordis.patch.yml b/packages/bundle/base/cordis.patch.yml index 0b1cc43a50..11b23c27be 100644 --- a/packages/bundle/base/cordis.patch.yml +++ b/packages/bundle/base/cordis.patch.yml @@ -41,7 +41,7 @@ name: '@deepseek-ai/dsh-typert-loader' - id: typert-gateway - name: '@deepseek-ai/dsh-host-api-gateway' + name: '@deepseek-ai/dsh-api-gateway' - id: session-title name: '@deepseek-ai/dsh-session-title' diff --git a/packages/bundle/base/package.json b/packages/bundle/base/package.json index 2ec17d9c66..9895d8834c 100644 --- a/packages/bundle/base/package.json +++ b/packages/bundle/base/package.json @@ -49,7 +49,7 @@ "@deepseek-ai/dsh-fs-sandbox": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-goal-session": "workspace:^", - "@deepseek-ai/dsh-host-api-gateway": "workspace:^", + "@deepseek-ai/dsh-api-gateway": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", "@deepseek-ai/dsh-llm-pi-ai": "workspace:^", diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 001c43948d..dc3212a36c 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -124,8 +124,8 @@ - id: connection name: '@deepseek-ai/dsh-client-connection' - - id: client-remotes - name: '@deepseek-ai/dsh-client-remotes' + - id: api-remotes + name: '@deepseek-ai/dsh-api-remotes' - id: client-runtime name: '@deepseek-ai/dsh-client-runtime' diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index 89b5e8e2a7..4e2d9cf70b 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -36,7 +36,7 @@ "@deepseek-ai/dsh-client-hmr": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-modules": "workspace:^", - "@deepseek-ai/dsh-client-remotes": "workspace:^", + "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-command": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", diff --git a/packages/client/remotes/README.md b/packages/client/remotes/README.md deleted file mode 100644 index e29188b8e3..0000000000 --- a/packages/client/remotes/README.md +++ /dev/null @@ -1,22 +0,0 @@ -# @deepseek-ai/dsh-client-remotes - -English | [中文](README.zh.md) - -Platform-neutral Client facade for Host Remote capabilities selected by this application. Its Client entry imports generated `/remote` artifacts as runtime values, mounts each contribution through `ctx.api`, and re-exports their declaration merges. Client business packages depend on this facade rather than the Host API Gateway or individual Remote runtime entries. - -The current assembly mounts only the Goal Remote contribution. Cordis effect ownership withdraws every contribution when this assembly unloads, while the Client face of `@deepseek-ai/dsh-host-api-gateway` owns descriptor validation, concrete root and scoped methods, invocation, and cancellation. - -This package contains no transport or Host discovery logic. It can be reused by Web or a future TUI Client that provides the same React-free `ctx.api` contract. - -## Model Experience - -None, as this Client assembly selects Remote application methods and registers no model surface. - -#### KV Cache effect - -No direct effect; mounted Host capabilities own any model-visible behavior they trigger. - -## Known Limitations and Deferred Work - -- The capability set is fixed by explicit build-time value imports; the Client does not discover the Host's active Services or Remote definitions at runtime. -- Additional capabilities require an explicit `/remote` value import and mount in this assembly. diff --git a/packages/client/remotes/README.zh.md b/packages/client/remotes/README.zh.md deleted file mode 100644 index e6425ab190..0000000000 --- a/packages/client/remotes/README.zh.md +++ /dev/null @@ -1,22 +0,0 @@ -# @deepseek-ai/dsh-client-remotes - -[English](README.md) | 中文 - -为本应用选定的 Host Remote 能力提供平台无关的 Client 外观。其 Client 入口以运行时值形式导入生成的 `/remote` 产物,通过 `ctx.api` 挂载每项贡献,并重新导出对应的声明合并。Client 业务包依赖此外观,而不依赖 Host API Gateway 或单独的 Remote 运行时入口。 - -当前组合仅挂载 Goal Remote 贡献。该组合卸载时,Cordis effect 的所有权机制会撤回所有贡献;`@deepseek-ai/dsh-host-api-gateway` 的 Client 侧负责描述符校验、具体的根级方法和作用域方法、调用与取消。 - -本包不包含传输逻辑或 Host 发现逻辑。Web 和未来的 TUI Client 只要提供同一份不依赖 React 的 `ctx.api` 契约,均可复用本包。 - -## 模型体验 - -无,因为此 Client 组合只选择应用的 Remote 方法,不注册任何模型接口。 - -#### KV Cache 影响 - -无直接影响;其触发的任何模型可见行为均由已挂载的 Host 能力负责。 - -## 已知限制与暂缓事项 - -- 能力集合由构建时显式导入的值固定确定;Client 不会在运行时发现 Host 中已启用的服务或 Remote 定义。 -- 若要增加能力,必须显式导入相应的 `/remote` 值并在此组合中挂载。 diff --git a/packages/client/remotes/src/index.ts b/packages/client/remotes/src/index.ts deleted file mode 100644 index c8c4ff20be..0000000000 --- a/packages/client/remotes/src/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -/** Host Loader entry for the Client Remote contribution assembly. */ - -/** Host plugin body; the selected contributions mount only in Client environments. */ -export function apply(): void {} diff --git a/packages/client/remotes/tsdown.config.ts b/packages/client/remotes/tsdown.config.ts deleted file mode 100644 index 20fa098462..0000000000 --- a/packages/client/remotes/tsdown.config.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { clientBundle } from '../tsdown.client.ts' - -export default clientBundle('@deepseek-ai/dsh-client-remotes', ['lib/types/index.js', 'lib/types/invariant.js']) diff --git a/packages/client/runtime/package.json b/packages/client/runtime/package.json index cc51aa772d..711510b705 100644 --- a/packages/client/runtime/package.json +++ b/packages/client/runtime/package.json @@ -25,7 +25,7 @@ "dshClient": { "inject": [ "@deepseek-ai/dsh-client-connection", - "@deepseek-ai/dsh-client-remotes", + "@deepseek-ai/dsh-api-remotes", "@deepseek-ai/dsh-typert-registry" ], "platform": "web", @@ -49,14 +49,14 @@ }, "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-client-remotes": "^0.0.1", + "@deepseek-ai/dsh-api-remotes": "^0.0.1", "@deepseek-ai/dsh-type-meta": "^0.0.1", "@deepseek-ai/dsh-typert-registry": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-client-remotes": "workspace:^", + "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", "@deepseek-ai/dsh-type-meta": "workspace:^", "@deepseek-ai/dsh-typert-registry": "workspace:^", diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index f1efd6a65d..a9d2bb0d7d 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -1,7 +1,7 @@ /** Browser runtime services for slots, sessions, workspaces, and connection-stream delivery. */ import type { Context } from 'cordis' import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-client-connection/client' -import type {} from '@deepseek-ai/dsh-client-remotes/client' +import type {} from '@deepseek-ai/dsh-api-remotes/client' import type { TypeRTContext } from '@deepseek-ai/dsh-type-meta' import type { MaybeSnapshotSelectorHook, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' import { SlotsService } from './slots.ts' diff --git a/packages/client/runtime/tsconfig.json b/packages/client/runtime/tsconfig.json index 85ba61d41a..efbf7c26d7 100644 --- a/packages/client/runtime/tsconfig.json +++ b/packages/client/runtime/tsconfig.json @@ -21,7 +21,7 @@ "path": "../connection" }, { - "path": "../remotes" + "path": "../../api/remotes" }, { "path": "../../host/apiproxy" diff --git a/packages/client/ui-goal/package.json b/packages/client/ui-goal/package.json index 4c26405bd8..63410707a0 100644 --- a/packages/client/ui-goal/package.json +++ b/packages/client/ui-goal/package.json @@ -25,7 +25,7 @@ "dshClient": { "inject": [ "@deepseek-ai/dsh-client-runtime", - "@deepseek-ai/dsh-client-remotes", + "@deepseek-ai/dsh-api-remotes", "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-ui-conversation" ], @@ -38,7 +38,7 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-client-locale": "^0.0.1", - "@deepseek-ai/dsh-client-remotes": "^0.0.1", + "@deepseek-ai/dsh-api-remotes": "^0.0.1", "@deepseek-ai/dsh-client-runtime": "^0.0.1", "@deepseek-ai/dsh-client-ui-conversation": "^0.0.1", "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", @@ -50,7 +50,7 @@ }, "devDependencies": { "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-remotes": "workspace:^", + "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", diff --git a/packages/client/ui-goal/src/client/index.ts b/packages/client/ui-goal/src/client/index.ts index 8fcfd292d2..2e49d5b6b8 100644 --- a/packages/client/ui-goal/src/client/index.ts +++ b/packages/client/ui-goal/src/client/index.ts @@ -10,7 +10,7 @@ */ import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client' // Type-only: pulls the generated Remote API and ctx.api merge through the Client assembly boundary. -import type {} from '@deepseek-ai/dsh-client-remotes/client' +import type {} from '@deepseek-ai/dsh-api-remotes/client' // Type-only: pulls the ui-conversation SlotMap merge (the input.dock entry). import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' // Type-only: pulls the locale plugin's Context merge (ctx.locale). diff --git a/packages/client/ui-goal/tsconfig.json b/packages/client/ui-goal/tsconfig.json index 2bb4070b18..263dfceb26 100644 --- a/packages/client/ui-goal/tsconfig.json +++ b/packages/client/ui-goal/tsconfig.json @@ -15,7 +15,7 @@ "path": "../locale" }, { - "path": "../remotes" + "path": "../../api/remotes" }, { "path": "../runtime" diff --git a/packages/host/api-gateway/tsdown.config.ts b/packages/host/api-gateway/tsdown.config.ts deleted file mode 100644 index 1f95a1f2c5..0000000000 --- a/packages/host/api-gateway/tsdown.config.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { clientBundle } from '../../client/tsdown.client.ts' - -export default clientBundle('@deepseek-ai/dsh-host-api-gateway', ['lib/types/index.js', 'lib/types/invariant.js']) diff --git a/packages/host/apiproxy/package.json b/packages/host/apiproxy/package.json index ce740a025f..91d03b4448 100644 --- a/packages/host/apiproxy/package.json +++ b/packages/host/apiproxy/package.json @@ -39,6 +39,7 @@ "license": "BSD-3-Clause", "dependencies": { "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^", @@ -56,8 +57,6 @@ "@deepseek-ai/dsh-skill": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/dsh-type-meta": "workspace:^", - "@deepseek-ai/dsh-typert-registry": "workspace:^", "@deepseek-ai/dsh-user-approval": "workspace:^", "@deepseek-ai/dsh-user-interaction": "workspace:^", "@deepseek-ai/dsh-workspace": "workspace:^", @@ -71,6 +70,8 @@ "devDependencies": { "@deepseek-ai/dsh-storage": "workspace:^", "@deepseek-ai/dsh-storage-domain": "workspace:^", + "@deepseek-ai/dsh-type-meta": "workspace:^", + "@deepseek-ai/dsh-typert-registry": "workspace:^", "cordis": "^4.0.0-rc.7", "@deepseek-ai/dsh-invariants": "workspace:^" } diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index f2c199feca..e4b715a0c4 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -19,9 +19,6 @@ import { SessionQueryError, type SessionSearchCursor } from '@deepseek-ai/dsh-se import { SubagentError } from '@deepseek-ai/dsh-subagent' import type { SubagentListEntry as CatalogSubagentListEntry } from '@deepseek-ai/dsh-subagent' import type { Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace' -import { TypeRTLookupFailure } from '@deepseek-ai/dsh-type-meta' -// Type-only: resolves the optional `ctx.typert` lookup-policy composition. -import type {} from '@deepseek-ai/dsh-typert-registry' import { workspaceDomainState, workspaceRecord, WorkspaceId as brandWorkspaceId, WorkspaceMoveInvalidError, WorkspaceUnknownSessionError, @@ -72,6 +69,14 @@ import type { } from '@deepseek-ai/dsh-user-interaction' import { UserInteractionError } from '@deepseek-ai/dsh-user-interaction' import { DirectoryPickerError } from '@deepseek-ai/dsh-host-directory-picker' +import { + ApiRemoteSessionNotFound as SessionNotFound, + ApiRemoteSubagentSessionOwnership as SubagentSessionOwnership, + apiRemoteSubagentOwnershipError, + createApiRemoteAgentResolver, + hasApiRemoteSubagentOwner, + inspectApiRemoteSession, +} from '@deepseek-ai/dsh-api-remotes' import { openNativePath, openNativeTextFile } from './native-path-opener.ts' /** Page size when history is called without maxMessages. */ @@ -666,19 +671,6 @@ async function catalogChild( } } -/** - * Thrown by the cold-resume path when the id names no servable session - * (absent from the store, or a pre-project legacy log without a cwd). - */ -class SessionNotFound extends Error {} - -/** Session identity whose lifecycle belongs to subagent routing, not generic Host resume. */ -class SubagentSessionOwnership extends Error { - constructor(readonly sessionId: SessionId) { - super(`session "${sessionId}" is a subagent session; use subagent delivery`) - } -} - /** Requested identity already belongs to a session with another project cwd. */ class SessionCwdConflict extends Error { constructor( @@ -752,8 +744,6 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro } type WebLlmTargetRef = AgentLlmTargetRef & { current: AgentLlmTarget } const targets = new WeakMap() - /** Implicit resume of cold sessions, deduplicating concurrent calls (follows the jsonrpc sessionCreations precedent). */ - const resumes = new Map>() /** Client-chosen identity creation/resume, deduplicated across concurrent retries. */ const sessionCreations = new Map>() /** Serializes path ownership and explicit title checks with Workspace mutations. */ @@ -811,6 +801,16 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro targetFor(agent) } + const hasSubagentOwner = ( + session: Pick, + agent: Agent | undefined, + ): boolean => hasApiRemoteSubagentOwner(ctx, session, agent) + const subagentOwnershipError = (sessionId: SessionId): RpcError => + apiRemoteSubagentOwnershipError(sessionId) + const inspectServable = (sessionId: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> => + inspectApiRemoteSession(ctx, sessionId) + const agentFor = createApiRemoteAgentResolver(ctx, { agentOptions, setup: installTarget }) + /** Send one transient frame to every connected mux consumer. */ function broadcast(payload: MuxFrame): void { const envelope = frame(payload) @@ -992,131 +992,6 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro }) } - /** - * Generic Host interaction cannot claim a durably classified subagent - * (`origin: 'subagent'` in the header) or an Agent runtime-owned by its - * live parent. - */ - function hasSubagentOwner( - session: Pick, - agent: Agent | undefined, - ): boolean { - if (session.header.origin === 'subagent') return true - const parentId = session.header.parentSession - if (parentId === undefined || agent === undefined) return false - const parent = ctx.agents.get(parentId) - return parent !== undefined && ctx.agents.isOwnedBy(agent.id, parent) - } - - /** Stable generic-Host error for an identity reserved to subagent routing. */ - function subagentOwnershipError(sessionId: SessionId): RpcError { - return { - code: 'agent-busy', - message: `session "${sessionId}" is owned by subagent routing`, - details: { reason: 'use subagent delivery for this child session' }, - } - } - - /** Inspect one cold served session without repairing, resuming, or publishing it. */ - async function inspectServable(sessionId: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { - const persistence = ctx.get('sessionPersistence') - if (persistence === undefined) { - throw new Error('session persistence is not configured (load a dsh-session-persistence backend)') - } - const meta = (await persistence.list()).find(m => m.id === sessionId) - if (meta === undefined || meta.cwd === undefined) throw new SessionNotFound(`session "${sessionId}" not found`) - const inspected = await persistence.inspect(sessionId) - if (inspected.meta.cwd === undefined) throw new SessionNotFound(`session "${sessionId}" not found`) - return { meta: inspected.meta, events: [...inspected.events] } - } - - /** - * Resolve one live registered identity through the subagent-ownership - * fence: subagent-owned agents answer `agent-busy`, plain agents pass. - * Fences the live agent's own session rather than trusting a - * "registered ⇒ attached-store" invariant — a registered subagent whose - * session is ever absent from the attached store must still not be handed - * out through generic Host routing. `undefined` means no live agent. - */ - function fencedLiveAgent(sessionId: SessionId): { agent: Agent } | { error: RpcError } | undefined { - const live = ctx.agents.get(sessionId) - if (live === undefined) return undefined - if (hasSubagentOwner(live.session, live)) return { error: subagentOwnershipError(sessionId) } - return { agent: live } - } - - async function agentFor(sessionId: SessionId): Promise<{ agent: Agent } | { error: RpcError }> { - const fenced = fencedLiveAgent(sessionId) - if (fenced !== undefined) return fenced - const attached = ctx.sessions.get(sessionId) - if (attached !== undefined && hasSubagentOwner(attached, undefined)) { - return { error: subagentOwnershipError(sessionId) } - } - let resume = resumes.get(sessionId) - if (resume === undefined) { - resume = (async () => { - try { - const inspected = await inspectServable(sessionId) - if (hasSubagentOwner({ header: inspected.meta }, undefined)) { - throw new SubagentSessionOwnership(sessionId) - } - const publishedSession = ctx.sessions.get(sessionId) - const publishedAgent = ctx.agents.get(sessionId) - if (publishedSession !== undefined && hasSubagentOwner(publishedSession, publishedAgent)) { - throw new SubagentSessionOwnership(sessionId) - } - const handle = await ctx.agents.resume({ - resumeSessionId: sessionId, - agentOptions: agentOptions(), - setup: installTarget, - }) - return handle.agent - } finally { - resumes.delete(sessionId) - } - })() - resumes.set(sessionId, resume) - } - try { - return { agent: await resume } - } catch (error: unknown) { - if (error instanceof SessionNotFound) { - return { error: { code: 'session-not-found', message: error.message, details: { sessionId } } } - } - if (error instanceof SubagentSessionOwnership) { - return { error: subagentOwnershipError(error.sessionId) } - } - // A concurrent publish can win the identity between the pre-resume - // re-check and `ctx.agents.resume` publication; the ID-collision - // rejection falls through here. Mirror ensureSession's `.catch` in - // full: classify a subagent-owned winner into the stable ownership - // error, and hand a clean plain-agent winner straight back. - const fenced = fencedLiveAgent(sessionId) - if (fenced !== undefined) return fenced - const attached = ctx.sessions.get(sessionId) - if (attached !== undefined && hasSubagentOwner(attached, undefined)) { - return { error: subagentOwnershipError(sessionId) } - } - // The internal details slot is contractually {}; the reason rides the message. - return { error: { code: 'internal', message: `resume failed for session "${sessionId}": ${String(error)}`, details: {} } } - } - } - - // Remote object parameters use the same identity policy as API Proxy methods: - // ordinary cold sessions resume once, while subagent-owned identities retain - // their stable caller-facing rejection. The provider packages continue to - // own wire declarations and live-only defaults; this Host composition owns - // the broader lookup policy. - ctx.inject(['typert'], (typeCtx) => { - const resolveAgent = async (sessionId: SessionId): Promise => { - const found = await agentFor(sessionId) - if ('error' in found) throw new TypeRTLookupFailure(found.error) - return found.agent - } - typeCtx.typert.lookups.configure('agent', resolveAgent) - typeCtx.typert.lookups.configure('session', async sessionId => (await resolveAgent(sessionId)).session) - }) - type SessionReadState = { id: SessionId header: SessionHeader diff --git a/packages/host/apiproxy/tsconfig.json b/packages/host/apiproxy/tsconfig.json index 23c170f4fd..912f2cd794 100644 --- a/packages/host/apiproxy/tsconfig.json +++ b/packages/host/apiproxy/tsconfig.json @@ -23,6 +23,9 @@ { "path": "../../../vendor/schemastery" }, + { + "path": "../../api/remotes" + }, { "path": "../../util/brand" }, @@ -38,12 +41,6 @@ { "path": "../../core/tools" }, - { - "path": "../../typert/type-meta" - }, - { - "path": "../../typert/registry" - }, { "path": "../../session-persistence/session-persistence" }, diff --git a/packages/typert/type-meta/src/index.ts b/packages/typert/type-meta/src/index.ts index 7ded29fa4a..1418c9d7f2 100644 --- a/packages/typert/type-meta/src/index.ts +++ b/packages/typert/type-meta/src/index.ts @@ -41,6 +41,7 @@ export type { InvocationDescriptor, InvocationParameterDescriptor, InvocationSourceLocation, + TypeRTClientApi, TypeRTClientContextBinder, TypeRTCodec, TypeRTContext, diff --git a/packages/typert/type-meta/src/types.ts b/packages/typert/type-meta/src/types.ts index 7831c08e37..b65690115f 100644 --- a/packages/typert/type-meta/src/types.ts +++ b/packages/typert/type-meta/src/types.ts @@ -176,6 +176,16 @@ export interface TypeRTRemoteContribution { readonly descriptors: readonly InvocationDescriptor[] } +/** Client API capability implemented by the Gateway and consumed by Remote assemblies. */ +export interface TypeRTClientApi extends TypeRTRemoteNamespaceMap { + /** + * Mount one generated Host-for-Client contribution in the caller's fiber. + * @param contribution - explicitly selected Remote package artifact. + * @returns disposer withdrawing descriptors and concrete methods together. + */ + mount(contribution: TypeRTRemoteContribution): TypeRTDisposer +} + /** * Resolve one validated wire identity, synchronously or asynchronously. * @param id - validated wire identity. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e0adfb22c1..bd4ff1ea14 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -621,6 +621,59 @@ importers: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis + packages/api/gateway: + dependencies: + '@deepseek-ai/dsh-type-meta': + specifier: workspace:^ + version: link:../../typert/type-meta + devDependencies: + '@deepseek-ai/dsh-client-connection': + specifier: workspace:^ + version: link:../../client/connection + '@deepseek-ai/dsh-host-webserver': + specifier: workspace:^ + version: link:../../host/webserver + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-typert-registry': + specifier: workspace:^ + version: link:../../typert/registry + cordis: + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + zod: + specifier: ^4.4.3 + version: 4.4.3 + + packages/api/remotes: + dependencies: + '@deepseek-ai/dsh-type-meta': + specifier: workspace:^ + version: link:../../typert/type-meta + devDependencies: + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-goal': + specifier: workspace:^ + version: link:../../goal/goal + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-session-persistence': + specifier: workspace:^ + version: link:../../session-persistence/session-persistence + '@deepseek-ai/dsh-typert-registry': + specifier: workspace:^ + version: link:../../typert/registry + cordis: + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + packages/bash/bash: devDependencies: '@deepseek-ai/dsh-invariants': @@ -877,6 +930,9 @@ importers: '@deepseek-ai/dsh-agent-loop': specifier: workspace:^ version: link:../../core/agent-loop + '@deepseek-ai/dsh-api-gateway': + specifier: workspace:^ + version: link:../../api/gateway '@deepseek-ai/dsh-bash-env': specifier: workspace:^ version: link:../../bash/bash-env @@ -916,9 +972,6 @@ importers: '@deepseek-ai/dsh-goal-session': specifier: workspace:^ version: link:../../goal/goal-session - '@deepseek-ai/dsh-host-api-gateway': - specifier: workspace:^ - version: link:../../host/api-gateway '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -1119,6 +1172,9 @@ importers: packages/bundle/web-app: dependencies: + '@deepseek-ai/dsh-api-remotes': + specifier: workspace:^ + version: link:../../api/remotes '@deepseek-ai/dsh-client-connection': specifier: workspace:^ version: link:../../client/connection @@ -1131,9 +1187,6 @@ importers: '@deepseek-ai/dsh-client-modules': specifier: workspace:^ version: link:../../client/modules - '@deepseek-ai/dsh-client-remotes': - specifier: workspace:^ - version: link:../../client/remotes '@deepseek-ai/dsh-client-runtime': specifier: workspace:^ version: link:../../client/runtime @@ -1348,21 +1401,6 @@ importers: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis - packages/client/remotes: - devDependencies: - '@deepseek-ai/dsh-goal': - specifier: workspace:^ - version: link:../../goal/goal - '@deepseek-ai/dsh-host-api-gateway': - specifier: workspace:^ - version: link:../../host/api-gateway - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../support/invariants - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis - packages/client/runtime: dependencies: '@deepseek-ai/dsh-client-connection': @@ -1405,9 +1443,9 @@ importers: specifier: ~4.4.7 version: 4.4.7(@types/react@18.3.31)(immer@10.2.0)(react@18.3.1) devDependencies: - '@deepseek-ai/dsh-client-remotes': + '@deepseek-ai/dsh-api-remotes': specifier: workspace:^ - version: link:../remotes + version: link:../../api/remotes '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -1617,12 +1655,12 @@ importers: packages/client/ui-goal: devDependencies: + '@deepseek-ai/dsh-api-remotes': + specifier: workspace:^ + version: link:../../api/remotes '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale - '@deepseek-ai/dsh-client-remotes': - specifier: workspace:^ - version: link:../remotes '@deepseek-ai/dsh-client-runtime': specifier: workspace:^ version: link:../runtime @@ -3757,36 +3795,14 @@ importers: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis - packages/host/api-gateway: - dependencies: - '@deepseek-ai/dsh-type-meta': - specifier: workspace:^ - version: link:../../typert/type-meta - devDependencies: - '@deepseek-ai/dsh-client-connection': - specifier: workspace:^ - version: link:../../client/connection - '@deepseek-ai/dsh-host-webserver': - specifier: workspace:^ - version: link:../webserver - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../support/invariants - '@deepseek-ai/dsh-typert-registry': - specifier: workspace:^ - version: link:../../typert/registry - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis - zod: - specifier: ^4.4.3 - version: 4.4.3 - packages/host/apiproxy: dependencies: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-api-remotes': + specifier: workspace:^ + version: link:../../api/remotes '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand @@ -3838,12 +3854,6 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools - '@deepseek-ai/dsh-type-meta': - specifier: workspace:^ - version: link:../../typert/type-meta - '@deepseek-ai/dsh-typert-registry': - specifier: workspace:^ - version: link:../../typert/registry '@deepseek-ai/dsh-user-approval': specifier: workspace:^ version: link:../../ui/user-approval @@ -3869,6 +3879,12 @@ importers: '@deepseek-ai/dsh-storage-domain': specifier: workspace:^ version: link:../../storage/storage-domain + '@deepseek-ai/dsh-type-meta': + specifier: workspace:^ + version: link:../../typert/type-meta + '@deepseek-ai/dsh-typert-registry': + specifier: workspace:^ + version: link:../../typert/registry cordis: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 088329e83d..54581a8605 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -289,7 +289,7 @@ export const TYPE_LINK_EXEMPTIONS: Readonly> = { ThemeTokens: 'service-local token dictionary is owned by packages/client/ui-theme/src/index.ts', Translate: 'service-local bound translator is owned by packages/client/i18n/src/index.ts', InvariantRegistration: 'service-local lifecycle handle is owned by packages/support/invariants/README.md', - InvokeRemoteRequest: 'gateway invocation contract is owned by packages/host/api-gateway/README.md', + InvokeRemoteRequest: 'gateway invocation contract is owned by packages/api/gateway/README.md', PresetOption: 'deployment menu metadata is owned by packages/ui/permission/README.md', PresetSpec: 'deployment preset composition is owned by packages/ui/permission/README.md', KnobState: 'projection unit state shape is owned by packages/ui/permission/README.md', diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 6d6a76e476..9adc3d9768 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -601,7 +601,7 @@ function builtBinSmokeGate(needs: string[] = ['build']): Gate { 'packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts', 'packages/subagent/subagent-codex/tests/loader-composition.e2e.ts', 'packages/subagent/subagent-claude-code/tests/loader-composition.e2e.ts', - 'packages/client/remotes/tests/built-lib.e2e.ts', + 'packages/api/remotes/tests/built-lib.e2e.ts', // The worker-entry packages' built bundles: the only automated proof // that lib/index.js resolves its sibling lib/worker.cjs under plain node // (the e2e lane runs unbuilt, so these files self-skip there). diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index edadc3f134..ecb13f167c 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -1538,22 +1538,22 @@ { "doc": "docs/core-data-structures/typert.md", "symbol": "InvokeRemoteRequest", - "source": "packages/host/api-gateway/src/types.ts" + "source": "packages/api/gateway/src/types.ts" }, { "doc": "docs/core-data-structures/typert.md", "symbol": "TypertGatewayErrorCode", - "source": "packages/host/api-gateway/src/types.ts" + "source": "packages/api/gateway/src/types.ts" }, { "doc": "docs/core-data-structures/typert.md", "symbol": "TypertGateway", - "source": "packages/host/api-gateway/src/types.ts" + "source": "packages/api/gateway/src/types.ts" }, { "doc": "docs/core-data-structures/typert.md", - "symbol": "ClientApi", - "source": "packages/host/api-gateway/src/client/index.ts" + "symbol": "TypeRTClientApi", + "source": "packages/typert/type-meta/src/types.ts" } ] } diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 78745dbed1..e131d6c403 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -57,7 +57,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/client/web-react': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, 'packages/client/schema-form': { kind: 'none', reason: 'Browser-side form-rendering library; registers no model surface.' }, 'packages/client/connection': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, - 'packages/client/remotes': { kind: 'none', reason: 'Client-side Remote assembly; selected business methods own any model-visible effect.' }, + 'packages/api/remotes': { kind: 'none', reason: 'The Remote BFF selects business methods and identity policy; selected services own any model-visible effect.' }, 'packages/client/runtime': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, 'packages/client/ui-layout': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, 'packages/client/ui-sidebar': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, @@ -126,7 +126,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/support/loader-smoke': { kind: 'none', reason: 'The test harness observes child-process streams without changing live requests.' }, 'packages/support/llm-mock-server': { kind: 'none', reason: 'The test server substitutes provider wire behavior without invoking a real model.' }, 'packages/support/llm-replay': { kind: 'none', reason: 'The keyless adapter invokes no provider model.' }, - 'packages/host/api-gateway': { kind: 'none', reason: 'Remote dispatch infrastructure; invoked business methods own any model-visible effect.' }, + 'packages/api/gateway': { kind: 'none', reason: 'Remote dispatch infrastructure; invoked business methods own any model-visible effect.' }, 'packages/typert/type-meta': { kind: 'none', reason: 'Compiler-independent Remote protocol declarations; registers no model surface.' }, 'packages/typert/generator': { kind: 'none', reason: 'The build-time generator runs outside any agent runtime and touches no model request.' }, 'packages/tasks/tasks': { kind: 'indirect', reason: 'Producer and control-surface plugins own all model rendering over the task registry.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index ce4fca35f9..b9907348fa 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -41,10 +41,10 @@ "@deepseek-ai/dsh-invariants": ["./packages/support/invariants/src/index.ts"], "@deepseek-ai/dsh-typert-registry": ["./packages/typert/registry/src/index.ts"], "@deepseek-ai/dsh-typert-registry/client": ["./packages/typert/registry/src/client/index.ts"], - "@deepseek-ai/dsh-host-api-gateway": ["./packages/host/api-gateway/src/index.ts"], - "@deepseek-ai/dsh-host-api-gateway/client": ["./packages/host/api-gateway/src/client/index.ts"], - "@deepseek-ai/dsh-host-api-gateway/invariant": ["./packages/host/api-gateway/src/invariant.ts"], - "@deepseek-ai/dsh-host-api-gateway/types": ["./packages/host/api-gateway/src/types.ts"], + "@deepseek-ai/dsh-api-gateway": ["./packages/api/gateway/src/index.ts"], + "@deepseek-ai/dsh-api-gateway/client": ["./packages/api/gateway/src/client/index.ts"], + "@deepseek-ai/dsh-api-gateway/invariant": ["./packages/api/gateway/src/invariant.ts"], + "@deepseek-ai/dsh-api-gateway/types": ["./packages/api/gateway/src/types.ts"], "@deepseek-ai/dsh-type-meta": ["./packages/typert/type-meta/src/index.ts"], "@deepseek-ai/dsh-type-meta/types": ["./packages/typert/type-meta/src/types.ts"], "@deepseek-ai/dsh-typert-loader": ["./packages/typert/loader/src/index.ts"], @@ -151,8 +151,8 @@ "@deepseek-ai/dsh-client-schema-form/invariant": ["./packages/client/schema-form/src/invariant.ts"], "@deepseek-ai/dsh-client-web-react": ["./packages/client/web-react/src"], "@deepseek-ai/dsh-client-connection": ["./packages/client/connection/src"], - "@deepseek-ai/dsh-client-remotes": ["./packages/client/remotes/src"], - "@deepseek-ai/dsh-client-remotes/client": ["./packages/client/remotes/src/client/index.ts"], + "@deepseek-ai/dsh-api-remotes": ["./packages/api/remotes/src"], + "@deepseek-ai/dsh-api-remotes/client": ["./packages/api/remotes/src/client/index.ts"], "@deepseek-ai/dsh-client-hmr": ["./packages/client/hmr/src"], "@deepseek-ai/dsh-client-modules": ["./packages/client/modules/src"], "@deepseek-ai/dsh-client-runtime": ["./packages/client/runtime/src"], diff --git a/tsconfig.client.json b/tsconfig.client.json index 327b337963..9821c0e41b 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -52,8 +52,8 @@ { "path": "./packages/client/hmr" }, { "path": "./packages/client/connection" }, { "path": "./packages/typert/registry" }, - { "path": "./packages/host/api-gateway" }, - { "path": "./packages/client/remotes" }, + { "path": "./packages/api/gateway" }, + { "path": "./packages/api/remotes" }, { "path": "./packages/client/runtime" }, { "path": "./packages/client/test-runtime" }, { "path": "./packages/client/ui-layout" }, diff --git a/tsconfig.host.json b/tsconfig.host.json index 37c20c0d5c..6884839536 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -102,7 +102,7 @@ { "path": "./packages/core/scope" }, { "path": "./packages/typert/type-meta" }, { "path": "./packages/typert/registry" }, - { "path": "./packages/host/api-gateway" }, + { "path": "./packages/api/gateway" }, { "path": "./packages/typert/loader" }, { "path": "./packages/session-persistence/session-persistence" }, { "path": "./packages/session-persistence/session-checkpoint-policy" }, diff --git a/vitest.config.ts b/vitest.config.ts index 56a5a1575b..f5a86ac7a9 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -181,8 +181,8 @@ export default defineConfig({ 'packages/client/connection/src/http-bridge.ts', // This assembly imports generated Host-for-Client code that exists // only in lib; the post-build built-bin smoke executes both entries. - 'packages/client/remotes/src/index.ts', - 'packages/client/remotes/src/client/index.ts', + 'packages/api/remotes/src/index.ts', + 'packages/api/remotes/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/connection/src/client/fixture.ts', From 502bd2b6f736d8baafb115d512589003eba4c41c Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:00:35 +0800 Subject: [PATCH 42/63] fix: docs --- docs/module-graph.md | 111 ++++++++----------------------------------- 1 file changed, 19 insertions(+), 92 deletions(-) diff --git a/docs/module-graph.md b/docs/module-graph.md index 43923e0865..9cf6f4c895 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -421,13 +421,6 @@ flowchart TD pkg_app_boot --> pkg_invariants pkg_app_boot --> pkg_paths pkg_app_boot --> pkg_system_prompt -<<<<<<< HEAD - pkg_client_ui_layout --> pkg_client_runtime - pkg_client_ui_layout --> pkg_client_ui_slots - pkg_client_ui_layout --> pkg_client_ui_theme - pkg_client_ui_layout --> pkg_invariants -======= ->>>>>>> d6e02ebbcc (feat(goal): add TypeRT gateway example) pkg_code_runtime_worker --> pkg_code_runtime pkg_code_runtime_worker --> pkg_invariants pkg_code_runtime_worker --> pkg_session @@ -616,31 +609,15 @@ flowchart TD pkg_permission --> pkg_session_projection pkg_permission --> pkg_settings pkg_permission --> pkg_user_approval -<<<<<<< HEAD -<<<<<<< HEAD - pkg_client_ui_conversation --> pkg_client_locale - pkg_client_ui_conversation --> pkg_client_runtime - pkg_client_ui_conversation --> pkg_client_ui_primitives - pkg_client_ui_conversation --> pkg_client_ui_slash - pkg_client_ui_conversation --> pkg_client_ui_slots - pkg_client_ui_conversation --> pkg_invariants - pkg_client_ui_conversation --> pkg_token_meter - pkg_command_feedback --> pkg_commands - pkg_command_feedback --> pkg_invariants - pkg_command_feedback --> pkg_session -======= - pkg_client_remotes --> pkg_goal - pkg_client_remotes --> pkg_host_api_gateway - pkg_client_remotes --> pkg_invariants ->>>>>>> d6e02ebbcc (feat(goal): add TypeRT gateway example) -======= pkg_api_remotes --> pkg_agent pkg_api_remotes --> pkg_goal pkg_api_remotes --> pkg_invariants pkg_api_remotes --> pkg_session pkg_api_remotes --> pkg_session_persistence pkg_api_remotes --> pkg_typert_registry ->>>>>>> c671d9e48c (refactor(api): colocate gateway and remote assembly) + pkg_command_feedback --> pkg_commands + pkg_command_feedback --> pkg_invariants + pkg_command_feedback --> pkg_session pkg_pty_local --> pkg_agent pkg_pty_local --> pkg_invariants pkg_pty_local --> pkg_pty @@ -794,46 +771,10 @@ flowchart TD pkg_tool_ask_user --> pkg_invariants pkg_tool_ask_user --> pkg_tools pkg_tool_ask_user --> pkg_user_interaction -<<<<<<< HEAD -<<<<<<< HEAD - pkg_client_ui_command --> pkg_client_connection - pkg_client_ui_command --> pkg_client_locale - pkg_client_ui_command --> pkg_client_runtime - pkg_client_ui_command --> pkg_client_ui_conversation - pkg_client_ui_command --> pkg_client_ui_primitives - pkg_client_ui_command --> pkg_client_ui_slash - pkg_client_ui_command --> pkg_client_ui_slots - pkg_client_ui_command --> pkg_invariants - pkg_client_ui_deliverables --> pkg_client_locale - pkg_client_ui_deliverables --> pkg_client_runtime - pkg_client_ui_deliverables --> pkg_client_ui_conversation - pkg_client_ui_deliverables --> pkg_client_ui_slots - pkg_client_ui_deliverables --> pkg_invariants - pkg_client_ui_goal --> pkg_client_connection - pkg_client_ui_goal --> pkg_client_locale - pkg_client_ui_goal --> pkg_client_runtime - pkg_client_ui_goal --> pkg_client_ui_conversation - pkg_client_ui_goal --> pkg_client_ui_primitives - pkg_client_ui_goal --> pkg_client_ui_slots - pkg_client_ui_goal --> pkg_goal - pkg_client_ui_goal --> pkg_invariants - pkg_client_ui_skill --> pkg_client_connection - pkg_client_ui_skill --> pkg_client_locale - pkg_client_ui_skill --> pkg_client_runtime - pkg_client_ui_skill --> pkg_client_ui_conversation - pkg_client_ui_skill --> pkg_client_ui_primitives - pkg_client_ui_skill --> pkg_client_ui_slash - pkg_client_ui_skill --> pkg_client_ui_slots - pkg_client_ui_skill --> pkg_invariants -======= - pkg_client_runtime --> pkg_client_remotes -======= pkg_client_runtime --> pkg_api_remotes ->>>>>>> c671d9e48c (refactor(api): colocate gateway and remote assembly) pkg_client_runtime --> pkg_invariants pkg_client_runtime --> pkg_type_meta pkg_client_runtime --> pkg_typert_registry ->>>>>>> d6e02ebbcc (feat(goal): add TypeRT gateway example) pkg_session_reference --> pkg_agent pkg_session_reference --> pkg_compact pkg_session_reference --> pkg_invariants @@ -1104,11 +1045,6 @@ flowchart TD pkg_client_ui_layout --> pkg_client_ui_slots pkg_client_ui_layout --> pkg_client_ui_theme pkg_client_ui_layout --> pkg_invariants - pkg_client_ui_skill --> pkg_client_connection - pkg_client_ui_skill --> pkg_client_runtime - pkg_client_ui_skill --> pkg_client_ui_slash - pkg_client_ui_skill --> pkg_client_ui_slots - pkg_client_ui_skill --> pkg_invariants pkg_acp_demo --> pkg_acp pkg_acp_demo --> pkg_agent_spine_demo pkg_acp_demo --> pkg_app_boot @@ -1147,6 +1083,11 @@ flowchart TD pkg_client_ui_command --> pkg_client_ui_slash pkg_client_ui_command --> pkg_client_ui_slots pkg_client_ui_command --> pkg_invariants + pkg_client_ui_deliverables --> pkg_client_locale + pkg_client_ui_deliverables --> pkg_client_runtime + pkg_client_ui_deliverables --> pkg_client_ui_conversation + pkg_client_ui_deliverables --> pkg_client_ui_slots + pkg_client_ui_deliverables --> pkg_invariants pkg_client_ui_goal --> pkg_api_remotes pkg_client_ui_goal --> pkg_client_locale pkg_client_ui_goal --> pkg_client_runtime @@ -1163,6 +1104,14 @@ flowchart TD pkg_client_ui_plan --> pkg_client_ui_slots pkg_client_ui_plan --> pkg_invariants pkg_client_ui_plan --> pkg_plan_mode + pkg_client_ui_skill --> pkg_client_connection + pkg_client_ui_skill --> pkg_client_locale + pkg_client_ui_skill --> pkg_client_runtime + pkg_client_ui_skill --> pkg_client_ui_conversation + pkg_client_ui_skill --> pkg_client_ui_primitives + pkg_client_ui_skill --> pkg_client_ui_slash + pkg_client_ui_skill --> pkg_client_ui_slots + pkg_client_ui_skill --> pkg_invariants pkg_client_ui_subagent --> pkg_client_locale pkg_client_ui_subagent --> pkg_client_runtime pkg_client_ui_subagent --> pkg_client_ui_conversation @@ -1262,10 +1211,6 @@ flowchart TD | [`session-persistence`](../packages/session-persistence/session-persistence) | `session-persistence` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`llm-replay`](../packages/support/llm-replay) | `support` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`app-boot`](../packages/ui/app-boot) | `ui` | [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`system-prompt`](../packages/core/system-prompt) | -<<<<<<< HEAD -| [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/support/invariants) | -======= ->>>>>>> d6e02ebbcc (feat(goal): add TypeRT gateway example) | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | @@ -1309,16 +1254,8 @@ flowchart TD | [`session-title-llm`](../packages/session-title/session-title-llm) | `session-title` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`timeout`](../packages/util/timeout) | | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) | | [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection), [`settings`](../packages/settings/settings), [`user-approval`](../packages/ui/user-approval) | -<<<<<<< HEAD -<<<<<<< HEAD -| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`token-meter`](../packages/llm/token-meter) | -| [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | -======= -| [`client-remotes`](../packages/client/remotes) | `client` | [`goal`](../packages/goal/goal), [`host-api-gateway`](../packages/host/api-gateway), [`invariants`](../packages/support/invariants) | ->>>>>>> d6e02ebbcc (feat(goal): add TypeRT gateway example) -======= | [`api-remotes`](../packages/api/remotes) | `api` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`typert-registry`](../packages/typert/registry) | ->>>>>>> c671d9e48c (refactor(api): colocate gateway and remote assembly) +| [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess) | | [`tasks-local`](../packages/tasks/tasks-local) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tasks`](../packages/tasks/tasks), [`timeout`](../packages/util/timeout) | | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | @@ -1344,18 +1281,7 @@ flowchart TD | [`session-title-first-message-llm`](../packages/session-title/session-title-first-message-llm) | `session-title` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`session-title-llm`](../packages/session-title/session-title-llm) | | [`agent-loop-testkit`](../packages/support/agent-loop-testkit) | `support` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-ask-user`](../packages/ui/tool-ask-user) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | -<<<<<<< HEAD -<<<<<<< HEAD -| [`client-ui-command`](../packages/client/ui-command) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-deliverables`](../packages/client/ui-deliverables) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | -| [`client-ui-skill`](../packages/client/ui-skill) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -======= -| [`client-runtime`](../packages/client/runtime) | `client` | [`client-remotes`](../packages/client/remotes), [`invariants`](../packages/support/invariants), [`type-meta`](../packages/typert/type-meta), [`typert-registry`](../packages/typert/registry) | ->>>>>>> d6e02ebbcc (feat(goal): add TypeRT gateway example) -======= | [`client-runtime`](../packages/client/runtime) | `client` | [`api-remotes`](../packages/api/remotes), [`invariants`](../packages/support/invariants), [`type-meta`](../packages/typert/type-meta), [`typert-registry`](../packages/typert/registry) | ->>>>>>> c671d9e48c (refactor(api): colocate gateway and remote assembly) | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) | | [`workspace-context`](../packages/context/workspace-context) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | `guard` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) | @@ -1400,14 +1326,15 @@ flowchart TD | [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/sdk-client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | | [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`token-meter`](../packages/llm/token-meter) | | [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/support/invariants) | -| [`client-ui-skill`](../packages/client/ui-skill) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`acp-demo`](../packages/examples/acp-demo) | `examples` | [`acp`](../packages/acp/acp), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | | [`cli-demo`](../packages/examples/cli-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | | [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | | [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | | [`client-ui-command`](../packages/client/ui-command) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-deliverables`](../packages/client/ui-deliverables) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | | [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`plan-mode`](../packages/plan/plan-mode) | +| [`client-ui-skill`](../packages/client/ui-skill) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) | | [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`client-ui-model`](../packages/client/ui-model) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-command`](../packages/client/ui-command), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | From ef8660076b05ed909065387e139592ffbf79329a Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:04:25 +0800 Subject: [PATCH 43/63] fix: docs budget --- scripts/doc-budgets.manifest.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/doc-budgets.manifest.json b/scripts/doc-budgets.manifest.json index a6ad066add..b5c000a714 100644 --- a/scripts/doc-budgets.manifest.json +++ b/scripts/doc-budgets.manifest.json @@ -1,5 +1,5 @@ { - "AGENTS.md": 1775, + "AGENTS.md": 1782, "docs/AGENTS.md": 1320, "docs/architecture.md": 2160, "docs/cordis-primer.md": 600, @@ -7,5 +7,5 @@ "docs/testing.md": 1150, "examples/AGENTS.md": 310, "packages/AGENTS.md": 675, - "packages/README.md": 920 + "packages/README.md": 936 } From d2596a0d74ed1729f687d2f2a224405be7f813a1 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:07:17 +0800 Subject: [PATCH 44/63] test(api-remotes): cover lookup publication races --- .../api/remotes/tests/agent-lookup.spec.ts | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 packages/api/remotes/tests/agent-lookup.spec.ts diff --git a/packages/api/remotes/tests/agent-lookup.spec.ts b/packages/api/remotes/tests/agent-lookup.spec.ts new file mode 100644 index 0000000000..c9110b8f3f --- /dev/null +++ b/packages/api/remotes/tests/agent-lookup.spec.ts @@ -0,0 +1,110 @@ +import { describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' +import SessionStore from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' +import { createApiRemoteAgentResolver } from '@deepseek-ai/dsh-api-remotes' + +const sid = (value: string): SessionId => value as SessionId + +function header(id: SessionId): SessionHeader { + return { version: 0, id, createdAt: 1, cwd: '/proj' } +} + +async function createContext(): Promise { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + return ctx +} + +function provideSession( + ctx: Context, + meta: SessionHeader, + inspect: () => Promise<{ meta: SessionHeader; events: SessionEvent[] }>, +): void { + ctx.provide('sessionPersistence', { + list: () => Promise.resolve([meta]), + inspect, + locate: () => undefined, + } as never) +} + +function stubAgent(ctx: Context, session: Session): Agent { + return { id: session.id, session, status: 'idle', ctx } as Agent +} + +describe('API Remote Agent resolver races', () => { + it('maps an inspected session without a cwd to session-not-found', async () => { + const ctx = await createContext() + const sessionId = sid('missing-after-inspect') + const meta = header(sessionId) + provideSession(ctx, meta, () => Promise.resolve({ + meta: { ...meta, cwd: undefined } as unknown as SessionHeader, + events: [], + })) + + const result = await createApiRemoteAgentResolver(ctx, {})(sessionId) + + expect(result).toMatchObject({ error: { code: 'session-not-found', details: { sessionId } } }) + await ctx.fiber.dispose() + }) + + it('resumes through a concurrently attached ordinary Session without optional defaults', async () => { + const ctx = await createContext() + const sessionId = sid('ordinary-attach-race') + const meta = header(sessionId) + let published: Session | undefined + provideSession(ctx, meta, () => { + published = ctx.sessions.create(sessionId, { meta: { cwd: '/proj' } }) + return Promise.resolve({ meta, events: [] }) + }) + const resume = vi.spyOn(ctx.agents, 'resume').mockImplementation(async () => { + if (published === undefined) throw new Error('Session was not published') + return { agent: stubAgent(ctx, published), dispose: () => Promise.resolve() } + }) + + const result = await createApiRemoteAgentResolver(ctx, {})(sessionId) + + expect(result).toMatchObject({ agent: { id: sessionId } }) + expect(resume).toHaveBeenCalledWith({ resumeSessionId: sessionId }) + await ctx.fiber.dispose() + }) + + it('rejects a subagent Session published after durable inspection', async () => { + const ctx = await createContext() + const sessionId = sid('owned-attach-race') + const meta = header(sessionId) + provideSession(ctx, meta, () => { + ctx.sessions.create(sessionId, { meta: { cwd: '/proj', origin: 'subagent' } }) + return Promise.resolve({ meta, events: [] }) + }) + const resume = vi.spyOn(ctx.agents, 'resume') + + const result = await createApiRemoteAgentResolver(ctx, {})(sessionId) + + expect(result).toMatchObject({ error: { code: 'agent-busy' } }) + expect(resume).not.toHaveBeenCalled() + await ctx.fiber.dispose() + }) + + it('reclassifies failed resumes after a live or attached subagent wins publication', async () => { + for (const winner of ['agent', 'session'] as const) { + const ctx = await createContext() + const sessionId = sid(`owned-${winner}-resume-race`) + const meta = header(sessionId) + provideSession(ctx, meta, () => Promise.resolve({ meta, events: [] })) + vi.spyOn(ctx.agents, 'resume').mockImplementationOnce(async () => { + const session = ctx.sessions.create(sessionId, { meta: { cwd: '/proj', origin: 'subagent' } }) + if (winner === 'agent') ctx.agents.register(stubAgent(ctx, session)) + throw new Error('session id already published') + }) + + const result = await createApiRemoteAgentResolver(ctx, {})(sessionId) + + expect(result).toMatchObject({ error: { code: 'agent-busy' } }) + await ctx.fiber.dispose() + } + }) +}) From d49028ff5daf083dab533fde27b039842d6da879 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:14:27 +0800 Subject: [PATCH 45/63] fix: docs --- docs/event-producer-consumer.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 92bf908613..9d486c46f6 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -30,10 +30,10 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) | | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/index.ts:73`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | `apiproxy`, [`llm`](../packages/llm/llm) | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:62`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) | -| `session/created` | `emit` | [`packages/core/session/src/index.ts:73`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | -| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:83`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:95`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-telemetry-otel`](../packages/telemetry/session-telemetry-otel), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | -| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:104`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry) | +| `session/created` | `emit` | [`packages/core/session/src/index.ts:74`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | +| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:84`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:96`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-telemetry-otel`](../packages/telemetry/session-telemetry-otel), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | +| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:105`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry) | | `settings/document-updated` | `emit` | [`packages/settings/settings/src/index.ts:170`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `apiproxy` | | `settings/updated` | `emit` | [`packages/settings/settings/src/index.ts:157`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) | | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:188`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - | From e89d078819de825aa0fa1f40983daed1b76275e4 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:25:00 +0800 Subject: [PATCH 46/63] fix: test snapshot --- .../translation-prompt-v4/request-response.expected.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/snapshots/translation-prompt-v4/request-response.expected.json b/scripts/snapshots/translation-prompt-v4/request-response.expected.json index e796de8a8a..794cf18f49 100644 --- a/scripts/snapshots/translation-prompt-v4/request-response.expected.json +++ b/scripts/snapshots/translation-prompt-v4/request-response.expected.json @@ -16,11 +16,11 @@ }, { "role": "user", - "content": "# Development guide\n\nEnglish | [中文](development.zh.md)\n\nThe setup tutorial takes a new contributor from prerequisites to a checked checkout. The contributor reference that follows covers repository layout, daily workflow, and CI shape. Design rationale and implementation details belong to the linked Agent Notes and scripts.\n\n## Setup tutorial\n\n### Prerequisites\n\n- Node.js supports 22.19+ and 24+. CI covers 22.19, 24, and 26; see the [Node engine floor Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md).\n- Corepack-enabled pnpm. The repo pins `pnpm@11.7.0` in `package.json`; run `corepack enable` if `pnpm --version` does not resolve through Corepack.\n- Git 2.26 or newer; hook setup enables Git's worktree-specific configuration extension.\n- Optional: a DeepSeek API key for the Web, headless, and ACP automation demos and real-API e2e tests.\n\n### First-time setup\n\nInstall dependencies from the repo root:\n\n```sh\npnpm install\n```\n\nThe install also configures worktree-local lefthook hooks through `scripts/install-lefthook.mjs`. The [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) owns the safety and migration contract.\n\nIf hooks are missing because dependencies were restored from cache or `postinstall` was skipped, install them manually:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\nIf the wrapper rejects existing Git configuration or reports a stale lock, follow its diagnostic and the linked Agent Note rather than editing worktree metadata speculatively. After moving a checkout, rerun the wrapper to regenerate the owned path.\n\nRun typecheck once after a fresh clone:\n\n```sh\npnpm run typecheck\n```\n\nSetup is complete when `pnpm run typecheck` exits successfully.\n\n## Contributor reference\n\n### TypeScript project layout\n\nThe repository typecheck runs the whole-repo `tsc -b` graph: it emits every package/vendor `lib/types` and checks examples, tests, and scripts through two no-emit aggregates.\n\nThe repository's TypeScript configuration has exactly three roles; every tsconfig file plays one of them.\n\n| File | Role | Forms a program? |\n|---|---|---|\n| `tsconfig.json` | Solution root: `extends` base, `files: []`, references to the two aggregates. The whole-repo `tsc -b tsconfig.json` graph, the tsserver discovery entry, and — through the inherited `paths` — the resolution config for tsx running `examples/` and `scripts/` (their nearest tsconfig is this file). | No |\n| `tsconfig.host.json` | Host aggregate: host-side packages (via references), examples, tests, scripts, website. Excludes `packages/client`. | Yes |\n| `tsconfig.client.json` | Client aggregate: `packages/client/*` packages and their tests, `apps/web`. | Yes |\n| `tsconfig.base.json` | Shared compilerOptions and the source `paths` map. Also the resolution facade the vitest configs point vite-tsconfig-paths at: it has no `include`, so its `paths` apply to every importer. | No |\n| `tsconfig.base.client.json` | Browser compiler shape (`jsx`, DOM libs, `types: []`) extended by the client aggregate and every `packages/client/*` package. | No |\n\nHost and client stay two aggregate programs because both sides declaration-merge the cordis `Context` interface under the same keys with different services; one program seeing both merges reports a collision. The collision exists only inside a `ts.Program` — module resolution never triggers it — which is why the solution may reference both aggregates and one paths facade may span both sides. Two disciplines follow:\n\n- `tsconfig.base.json` never gains `include` or `files`: they would leak into every extending package project and narrow the facade's match-all scope.\n- A script that builds a repo-wide `ts.Program` seeds `tsconfig.host.json` or `tsconfig.client.json` explicitly — never the root solution, because flattening both aggregates into one program collides the `Context` merges. Program-backed generators and gates (`scripts/ts-project.ts` consumers, doc-typecheck standalone mode) are host-only by decision; the client side gains program-backed tooling only with a concrete need.\n\nStatic analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. Decision record: [solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md); the tsc-first emit pipeline is the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md).\n\nBusiness services declare callable methods on the Host with `@Remote` or `@RemoteContext`; the Host build generates Host-for-Client types and runtime contributions, and the Client's `client-remotes` composition loads those contributions and mounts concrete methods on `ctx.api` or the corresponding scoped Context. See [API Gateway](api-gateway.md) for the generated artifacts on both sides, their assembly relationships, the SRC development fallback, and the Web build order.\n\nIf a relevant local check consumes built package output, build once first:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` includes `publint`, which validates package entrypoints against the built `lib/*.js` files, and `verify-node-next-types`, which validates built declarations against a temporary NodeNext consumer. A fresh worktree has no bundled JS or declarations until `pnpm run build` runs; ordinary commits and pushes do not require that build unless their selected checks consume it.\n\n### Environment variables\n\nThe real DeepSeek adapter and key-backed agent demos read credentials from the environment or from a gitignored `.env` at the repo root:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` is optional and defaults to the public API. Never commit real credentials. The real-API e2e suites self-skip when `DEEPSEEK_API_KEY` is not set.\n\n### Git hooks\n\nlefthook is configured in `lefthook.yml` as a fast local checkpoint:\n\n- `pre-commit` applies formatting-only ESLint fixes, validates the staged files with Oxlint and applies its native fixes, regenerates `THIRD_PARTY_NOTICES.md` when a staged file is one of its inputs, checks the staged diff for whitespace errors, and runs the vendor manifest guard.\n- `pre-push` runs only the incremental repository typecheck (`tsc -b` over the root solution, covering both the host and client aggregates).\n\nThe vendor manifest guard checks that changes under `vendor/*/src` are staged with the matching `vendor/README.md` manifest update. See `vendor/README.md` before editing vendored code.\n\nThe hooks intentionally do not run tests, snapshots, documentation checks, builds, or hygiene. Contributors run the [checks relevant to the changed behavior](../AGENTS.md#run-relevant-checks-locally) once; CI owns exhaustive coverage, built-artifact smokes, and the Node 22.19, 24, and 26 compatibility matrix.\n\nContributors can opt into the comprehensive local gate set with `pnpm run check:all`. The command is independent of both Git hooks and is not an agent instruction.\n\n### CI gates\n\nThe keyless [CI workflow](../.github/workflows/ci.yml) groups independent gates into broad lanes and runs a smaller compatibility signal across supported Node versions. Artifact consumers wait for one build within their lane. The separate real-API workflow runs `pnpm run test:e2e` with its configured worker bound. See [scripts/run-gates.ts](../scripts/run-gates.ts) and the workflow files for the current gate and job inventory.\n\n### Daily commands\n\nThe root [contributor instructions](../AGENTS.md#commands) summarize common commands, while [`package.json`](../package.json) and [scripts/run-gates.ts](../scripts/run-gates.ts) own the current script and gate inventories. Select the smallest checks that cover the changed surface. Documentation changes use `pnpm run doc-sync`; package-public behavior changes also update the owning README or JSDoc, and built-artifact checks require `pnpm run build` first.\n\n### Demos\n\nThe one-shot Headless coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\nThe self-referential cordis demo can inspect and modify its live plugin runtime and needs the same credentials (`web` by default, or `acp`):\n\n```sh\npnpm run demo:cordis\n```\n\nThe ACP automation server exposes fresh agent sessions over JSON-RPC stdio and also needs `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n### TODO markers\n\nUse one of three comment tags to flag known issues in the code, ordered by urgency:\n\n- `FIXME` — an issue that should block a new release. A release should not ship with an open `FIXME` unless reviewers explicitly agree the change can be merged anyway.\n- `TODO` — an issue that should be fixed soon, once we have the resources.\n- `XXX` — an issue that we may fix someday; lowest priority, no commitment.\n\nPick the tag that matches the urgency so anyone scanning the code can tell a release blocker from a someday-maybe.\n\n### Documenting types verbatim (`ts type-equiv`)\n\nThe [core data structures](core-data-structures/core.md) docs paste source-equivalent declarations together with their original JSDoc so a reader sees the exact shape and source contract. To keep a paste from drifting when source changes, fence it as ` ```ts type-equiv ` (instead of ` ```ts `) and register it in `scripts/type-equiv.manifest.json` with the source file and symbol it mirrors:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration and attached JSDoc from source via the TypeScript parser and asserts the block matches both. For a class whose implementation bodies do not belong in the catalog, use ` ```ts public-api ` and set `\"projection\": \"public-api\"`; the checked projection retains the public fields, constructor, accessors, methods, and original class/member JSDoc while omitting bodies and private or protected members. Comparison ignores whitespace and non-JSDoc comments but requires every original JSDoc comment, including member documentation, so readers see the source contract beside the exact shape. The gate enforces a 1:1 correspondence by document, symbol, and projection between primary blocks and manifest entries; a paired `.zh.md` block reuses its unsuffixed sibling's entry only when the whole tracked fence sequence is byte-identical and ordered identically. `doc-typecheck` applies the same derivative rule to compilable fences, while skipping both source-equivalence fence kinds from compilation and its opt-out ratio. When you change a documented declaration or its JSDoc, the gate fails until you update the paste; when you add or remove a primary block, update the manifest in the same change.\n" + "content": "# Development guide\n\nEnglish | [中文](development.zh.md)\n\nThe setup tutorial takes a new contributor from prerequisites to a checked checkout. The contributor reference that follows covers repository layout, daily workflow, and CI shape. Design rationale and implementation details belong to the linked Agent Notes and scripts.\n\n## Setup tutorial\n\n### Prerequisites\n\n- Node.js supports 22.19+ and 24+. CI covers 22.19, 24, and 26; see the [Node engine floor Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md).\n- Corepack-enabled pnpm. The repo pins `pnpm@11.7.0` in `package.json`; run `corepack enable` if `pnpm --version` does not resolve through Corepack.\n- Git 2.26 or newer; hook setup enables Git's worktree-specific configuration extension.\n- Optional: a DeepSeek API key for the Web, headless, and ACP automation demos and real-API e2e tests.\n\n### First-time setup\n\nInstall dependencies from the repo root:\n\n```sh\npnpm install\n```\n\nThe install also configures worktree-local lefthook hooks through `scripts/install-lefthook.mjs`. The [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) owns the safety and migration contract.\n\nIf hooks are missing because dependencies were restored from cache or `postinstall` was skipped, install them manually:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\nIf the wrapper rejects existing Git configuration or reports a stale lock, follow its diagnostic and the linked Agent Note rather than editing worktree metadata speculatively. After moving a checkout, rerun the wrapper to regenerate the owned path.\n\nRun typecheck once after a fresh clone:\n\n```sh\npnpm run typecheck\n```\n\nSetup is complete when `pnpm run typecheck` exits successfully.\n\n## Contributor reference\n\n### TypeScript project layout\n\nThe repository typecheck runs the whole-repo `tsc -b` graph: it emits every package/vendor `lib/types` and checks examples, tests, and scripts through two no-emit aggregates.\n\nThe repository's TypeScript configuration has exactly three roles; every tsconfig file plays one of them.\n\n| File | Role | Forms a program? |\n|---|---|---|\n| `tsconfig.json` | Solution root: `extends` base, `files: []`, references to the two aggregates. The whole-repo `tsc -b tsconfig.json` graph, the tsserver discovery entry, and — through the inherited `paths` — the resolution config for tsx running `examples/` and `scripts/` (their nearest tsconfig is this file). | No |\n| `tsconfig.host.json` | Host aggregate: host-side packages (via references), examples, tests, scripts, website. Excludes `packages/client`. | Yes |\n| `tsconfig.client.json` | Client aggregate: `packages/client/*` packages and their tests, `apps/web`. | Yes |\n| `tsconfig.base.json` | Shared compilerOptions and the source `paths` map. Also the resolution facade the vitest configs point vite-tsconfig-paths at: it has no `include`, so its `paths` apply to every importer. | No |\n| `tsconfig.base.client.json` | Browser compiler shape (`jsx`, DOM libs, `types: []`) extended by the client aggregate and every `packages/client/*` package. | No |\n\nHost and client stay two aggregate programs because both sides declaration-merge the cordis `Context` interface under the same keys with different services; one program seeing both merges reports a collision. The collision exists only inside a `ts.Program` — module resolution never triggers it — which is why the solution may reference both aggregates and one paths facade may span both sides. Two disciplines follow:\n\n- `tsconfig.base.json` never gains `include` or `files`: they would leak into every extending package project and narrow the facade's match-all scope.\n- A script that builds a repo-wide `ts.Program` seeds `tsconfig.host.json` or `tsconfig.client.json` explicitly — never the root solution, because flattening both aggregates into one program collides the `Context` merges. Program-backed generators and gates (`scripts/ts-project.ts` consumers, doc-typecheck standalone mode) are host-only by decision; the client side gains program-backed tooling only with a concrete need.\n\nStatic analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. Decision record: [solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md); the tsc-first emit pipeline is the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md).\n\nBusiness services declare callable methods on the Host with `@Remote` or `@RemoteContext`; the Host build generates Host-for-Client types and runtime contributions, and the Client's `api-remotes` composition loads those contributions and mounts concrete methods on `ctx.api` or the corresponding scoped Context. See [API Gateway](api-gateway.md) for the generated artifacts on both sides, their assembly relationships, the SRC development fallback, and the Web build order.\n\nIf a relevant local check consumes built package output, build once first:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` includes `publint`, which validates package entrypoints against the built `lib/*.js` files, and `verify-node-next-types`, which validates built declarations against a temporary NodeNext consumer. A fresh worktree has no bundled JS or declarations until `pnpm run build` runs; ordinary commits and pushes do not require that build unless their selected checks consume it.\n\n### Environment variables\n\nThe real DeepSeek adapter and key-backed agent demos read credentials from the environment or from a gitignored `.env` at the repo root:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` is optional and defaults to the public API. Never commit real credentials. The real-API e2e suites self-skip when `DEEPSEEK_API_KEY` is not set.\n\n### Git hooks\n\nlefthook is configured in `lefthook.yml` as a fast local checkpoint:\n\n- `pre-commit` applies formatting-only ESLint fixes, validates the staged files with Oxlint and applies its native fixes, regenerates `THIRD_PARTY_NOTICES.md` when a staged file is one of its inputs, checks the staged diff for whitespace errors, and runs the vendor manifest guard.\n- `pre-push` runs only the incremental repository typecheck (`tsc -b` over the root solution, covering both the host and client aggregates).\n\nThe vendor manifest guard checks that changes under `vendor/*/src` are staged with the matching `vendor/README.md` manifest update. See `vendor/README.md` before editing vendored code.\n\nThe hooks intentionally do not run tests, snapshots, documentation checks, builds, or hygiene. Contributors run the [checks relevant to the changed behavior](../AGENTS.md#run-relevant-checks-locally) once; CI owns exhaustive coverage, built-artifact smokes, and the Node 22.19, 24, and 26 compatibility matrix.\n\nContributors can opt into the comprehensive local gate set with `pnpm run check:all`. The command is independent of both Git hooks and is not an agent instruction.\n\n### CI gates\n\nThe keyless [CI workflow](../.github/workflows/ci.yml) groups independent gates into broad lanes and runs a smaller compatibility signal across supported Node versions. Artifact consumers wait for one build within their lane. The separate real-API workflow runs `pnpm run test:e2e` with its configured worker bound. See [scripts/run-gates.ts](../scripts/run-gates.ts) and the workflow files for the current gate and job inventory.\n\n### Daily commands\n\nThe root [contributor instructions](../AGENTS.md#commands) summarize common commands, while [`package.json`](../package.json) and [scripts/run-gates.ts](../scripts/run-gates.ts) own the current script and gate inventories. Select the smallest checks that cover the changed surface. Documentation changes use `pnpm run doc-sync`; package-public behavior changes also update the owning README or JSDoc, and built-artifact checks require `pnpm run build` first.\n\n### Demos\n\nThe one-shot Headless coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\nThe self-referential cordis demo can inspect and modify its live plugin runtime and needs the same credentials (`web` by default, or `acp`):\n\n```sh\npnpm run demo:cordis\n```\n\nThe ACP automation server exposes fresh agent sessions over JSON-RPC stdio and also needs `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n### TODO markers\n\nUse one of three comment tags to flag known issues in the code, ordered by urgency:\n\n- `FIXME` — an issue that should block a new release. A release should not ship with an open `FIXME` unless reviewers explicitly agree the change can be merged anyway.\n- `TODO` — an issue that should be fixed soon, once we have the resources.\n- `XXX` — an issue that we may fix someday; lowest priority, no commitment.\n\nPick the tag that matches the urgency so anyone scanning the code can tell a release blocker from a someday-maybe.\n\n### Documenting types verbatim (`ts type-equiv`)\n\nThe [core data structures](core-data-structures/core.md) docs paste source-equivalent declarations together with their original JSDoc so a reader sees the exact shape and source contract. To keep a paste from drifting when source changes, fence it as ` ```ts type-equiv ` (instead of ` ```ts `) and register it in `scripts/type-equiv.manifest.json` with the source file and symbol it mirrors:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration and attached JSDoc from source via the TypeScript parser and asserts the block matches both. For a class whose implementation bodies do not belong in the catalog, use ` ```ts public-api ` and set `\"projection\": \"public-api\"`; the checked projection retains the public fields, constructor, accessors, methods, and original class/member JSDoc while omitting bodies and private or protected members. Comparison ignores whitespace and non-JSDoc comments but requires every original JSDoc comment, including member documentation, so readers see the source contract beside the exact shape. The gate enforces a 1:1 correspondence by document, symbol, and projection between primary blocks and manifest entries; a paired `.zh.md` block reuses its unsuffixed sibling's entry only when the whole tracked fence sequence is byte-identical and ordered identically. `doc-typecheck` applies the same derivative rule to compilable fences, while skipping both source-equivalence fence kinds from compilation and its opt-out ratio. When you change a documented declaration or its JSDoc, the gate fails until you update the paste; when you add or remove a primary block, update the manifest in the same change.\n" }, { "role": "assistant", - "content": "# 开发指南\n\n[English](development.md) | 中文\n\n搭建教程引导新贡献者从准备前置条件开始,直到检出通过检查。后面的贡献者参考介绍仓库布局、日常工作流和 CI 形态。设计依据与实现细节属于链接的 Agent Note 和脚本。\n\n## 搭建教程\n\n### 前置条件\n\n- Node.js 支持 22.19+ 与 24+。CI 覆盖 22.19、24 和 26;见 [Node 引擎下限 Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md)。\n- 启用了 Corepack 的 pnpm。仓库在 `package.json` 中固定使用 `pnpm@11.7.0`;如果 `pnpm --version` 无法通过 Corepack 解析,请先运行 `corepack enable`。\n- Git 2.26 或更高版本;钩子设置会启用 Git 的 worktree 专属配置扩展。\n- 可选:一个 DeepSeek API key,用于 Web、headless 和 ACP(Agent Client Protocol)自动化 agent(智能体)演示以及真实 API 的 e2e 测试。\n\n### 首次搭建\n\n在仓库根目录安装依赖:\n\n```sh\npnpm install\n```\n\n安装过程还会通过 `scripts/install-lefthook.mjs` 配置 worktree 本地的 lefthook 钩子。其安全与迁移契约由 [worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) 负责。\n\n如果依赖是从缓存恢复或 `postinstall` 被跳过而导致缺少钩子,请手动安装:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\n如果包装脚本拒绝现有 Git 配置或报告陈旧锁,请遵循其诊断和所链接的 Agent Note,不要凭猜测编辑 worktree 元数据。移动检出目录后,请重新运行包装脚本以重新生成自有路径。\n\n新克隆后请先运行一次类型检查:\n\n```sh\npnpm run typecheck\n```\n\n`pnpm run typecheck` 成功退出即表示搭建完成。\n\n## 贡献者参考\n\n### TypeScript 项目布局\n\n仓库类型检查会执行全仓 `tsc -b` 图:它会发射每个 package/vendor 的 `lib/types`,并通过两个 no-emit 聚合检查示例、测试和脚本。\n\n仓库的 TypeScript 配置只有三种角色;每个 tsconfig 文件恰好扮演其中一种。\n\n| 文件 | 角色 | 是否构成 program? |\n|---|---|---|\n| `tsconfig.json` | solution 根:`extends` base、`files: []`、引用两个聚合。全仓 `tsc -b tsconfig.json` 图、tsserver 发现入口,并经继承的 `paths` 充当 tsx 运行 `examples/` 与 `scripts/` 时的解析配置(它们最近的 tsconfig 就是此文件)。 | 否 |\n| `tsconfig.host.json` | host 聚合:host 侧各包(经 references)、示例、测试、脚本、website。排除 `packages/client`。 | 是 |\n| `tsconfig.client.json` | client 聚合:`packages/client/*` 各包及其测试、`apps/web`。 | 是 |\n| `tsconfig.base.json` | 共享 compilerOptions 与源码 `paths` 映射。同时是各 vitest 配置让 vite-tsconfig-paths 指向的解析门面:它没有 `include`,因此其 `paths` 适用于任何 importer。 | 否 |\n| `tsconfig.base.client.json` | 浏览器编译形状(`jsx`、DOM lib、`types: []`),由 client 聚合和每个 `packages/client/*` 包 extends。 | 否 |\n\nhost 与 client 保持两个聚合 program,是因为两侧在相同键下以不同服务对 cordis `Context` 接口做声明合并;单一 program 同时看到两份合并会报冲突。这种冲突只存在于 `ts.Program` 内部——模块解析永远不会触发它——所以 solution 可以同时引用两个聚合,一个 paths 门面也可以横跨两侧。由此推出两条纪律:\n\n- `tsconfig.base.json` 永不添加 `include` 或 `files`:它们会泄漏进每个 extends 它的包项目,并收窄门面的全匹配范围。\n- 构造全仓 `ts.Program` 的脚本显式种子 `tsconfig.host.json` 或 `tsconfig.client.json`——永不种子根 solution,因为把两个聚合展平进一个 program 会撞上 `Context` 合并冲突。基于 program 的生成器与门禁(`scripts/ts-project.ts` 的消费者、doc-typecheck standalone 模式)按决策仅覆盖 host 侧;client 侧只在出现真实需求时再获得基于 program 的工具。\n\n静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。决策记录:[solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md);tsc-first 发射管线见 [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md)。\n\n业务 Service 在 Host 使用 `@Remote` 或 `@RemoteContext` 声明可调用方法;Host 构建生成 Host-for-Client 类型与运行时贡献,Client 的 `client-remotes` 组合加载这些贡献并将具体方法挂载到 `ctx.api` 或对应的作用域 Context。两侧的生成产物、装配关系、SRC 开发回退和 Web 构建顺序见 [API Gateway](api-gateway.md)。\n\n如果相关的本地检查需要使用构建后的包产物,请先构建一次:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` 包含 `publint`(用构建出的 `lib/*.js` 文件校验 package 入口点)和 `verify-node-next-types`(用一个临时的 NodeNext 消费方校验构建出的声明文件)。新 worktree 在 `pnpm run build` 运行之前没有打包的 JS 和声明文件;普通提交和推送无需构建,除非所选检查会使用这些产物。\n\n### 环境变量\n\n真实的 DeepSeek 适配器和需要密钥的 agent 演示从环境变量或仓库根目录一个被 gitignore 的 `.env` 文件读取凭证:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` 可选,默认为公开 API。请勿提交真实凭证。未设置 `DEEPSEEK_API_KEY` 时,真实 API 的 e2e 套件会自动跳过。\n\n### Git 钩子\n\nlefthook 在 `lefthook.yml` 中配置,作为快速的本地检查点:\n\n- `pre-commit` 应用仅用于格式化的 ESLint 修复,使用 Oxlint 验证暂存文件并应用其原生修复,在暂存文件属于 `THIRD_PARTY_NOTICES.md` 的输入时重新生成该文件,然后检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫;\n- `pre-push` 只运行仓库增量类型检查(对根 solution 执行 `tsc -b`,覆盖 host 与 client 两个聚合)。\n\nvendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。请在编辑 vendor 代码前先阅读 `vendor/README.md`。\n\n这些钩子有意不运行测试、快照、文档检查、构建或 `hygiene`。贡献者只运行一次[与改动行为相关的检查](../AGENTS.md#run-relevant-checks-locally);CI 负责全量覆盖率门禁、构建产物冒烟测试,以及 Node 22.19、24 和 26 兼容性矩阵。\n\n贡献者可以选择运行 `pnpm run check:all`,执行全面的本地门禁集。该命令独立于两个 Git 钩子,也不是对 agent 的指令。\n\n### CI 门禁\n\nkeyless [CI 工作流](../.github/workflows/ci.yml) 将独立门禁分组到若干宽粒度 lane,并在受支持的 Node 版本上运行一组较小的兼容性检查。产物消费方在各自 lane 内等待一次 build。单独的真实 API 工作流按其配置的 worker 上限运行 `pnpm run test:e2e`。当前门禁和 job 清单以 [scripts/run-gates.ts](../scripts/run-gates.ts) 和工作流文件为准。\n\n### 日常命令\n\n根目录的[贡献者说明](../AGENTS.md#commands)概述常用命令,[`package.json`](../package.json) 与 [scripts/run-gates.ts](../scripts/run-gates.ts) 则负责当前脚本和门禁清单。请选择覆盖变更表面的最小检查集。文档变更使用 `pnpm run doc-sync`;package 公开行为变更还需更新所属 README 或 JSDoc,而基于构建产物的检查需要先运行 `pnpm run build`。\n\n### 演示\n\n单次运行的 Headless coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\n自指的 cordis 演示可以检查并修改其实时插件运行时,并需要相同的凭证(默认 `web`,也可用 `acp`):\n\n```sh\npnpm run demo:cordis\n```\n\nACP 自动化服务器通过 JSON-RPC stdio 提供全新 agent 会话,同样需要 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n### TODO 标记\n\n请使用以下三种注释标签之一标记代码中的已知问题,按紧急程度排序:\n\n- `FIXME`:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 `FIXME`;\n- `TODO`:应当尽快修复的问题,等资源到位即可处理;\n- `XXX`:也许某天会修复的问题,优先级最低,不作承诺。\n\n请选择与紧急程度匹配的标签,让浏览代码的人一眼分清「发布阻塞」和「有空再说」。\n\n### 逐字记录类型(`ts type-equiv`)\n\n[核心数据结构](core-data-structures/core.md)文档会把与源码等价的声明及其原始 JSDoc 一并粘贴,让读者看到确切形状和源码契约。为防止粘贴内容在源码变化时漂移,请将其围栏为 ` ```ts type-equiv `(而不是 ` ```ts `),并在 `scripts/type-equiv.manifest.json` 中登记它镜像的源文件和符号:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明及其附带的 JSDoc,并断言代码块同时匹配两者。对于不应把实现体写进目录的类,请使用 ` ```ts public-api ` 并设置 `\"projection\": \"public-api\"`;门禁检查的投影会保留公共字段、构造函数、访问器、方法以及类和成员的原始 JSDoc,同时省略实现体和私有或受保护成员。比对会忽略空白和非 JSDoc 注释,但要求保留每条原始 JSDoc(包括成员文档),让读者同时看到源码契约和确切形状。该门禁按文档、符号和投影,在主块与 manifest 条目之间强制 1:1 对应;只有当配对 `.zh.md` 块的完整受跟踪围栏序列与其无后缀兄弟文件按字节一致且顺序相同时,才会复用后者的条目。`doc-typecheck` 对可编译围栏应用同一派生规则,同时跳过两种源码等价围栏的编译,并将其排除在 opt-out 比例之外。当你改动一个已记录的类型声明或其 JSDoc 时,门禁会失败直到你更新粘贴内容;当你增删一个主块时,请在同一个变更里更新 manifest。\n" + "content": "# 开发指南\n\n[English](development.md) | 中文\n\n搭建教程引导新贡献者从准备前置条件开始,直到检出通过检查。后面的贡献者参考介绍仓库布局、日常工作流和 CI 形态。设计依据与实现细节属于链接的 Agent Note 和脚本。\n\n## 搭建教程\n\n### 前置条件\n\n- Node.js 支持 22.19+ 与 24+。CI 覆盖 22.19、24 和 26;见 [Node 引擎下限 Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md)。\n- 启用了 Corepack 的 pnpm。仓库在 `package.json` 中固定使用 `pnpm@11.7.0`;如果 `pnpm --version` 无法通过 Corepack 解析,请先运行 `corepack enable`。\n- Git 2.26 或更高版本;钩子设置会启用 Git 的 worktree 专属配置扩展。\n- 可选:一个 DeepSeek API key,用于 Web、headless 和 ACP(Agent Client Protocol)自动化 agent(智能体)演示以及真实 API 的 e2e 测试。\n\n### 首次搭建\n\n在仓库根目录安装依赖:\n\n```sh\npnpm install\n```\n\n安装过程还会通过 `scripts/install-lefthook.mjs` 配置 worktree 本地的 lefthook 钩子。其安全与迁移契约由 [worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) 负责。\n\n如果依赖是从缓存恢复或 `postinstall` 被跳过而导致缺少钩子,请手动安装:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\n如果包装脚本拒绝现有 Git 配置或报告陈旧锁,请遵循其诊断和所链接的 Agent Note,不要凭猜测编辑 worktree 元数据。移动检出目录后,请重新运行包装脚本以重新生成自有路径。\n\n新克隆后请先运行一次类型检查:\n\n```sh\npnpm run typecheck\n```\n\n`pnpm run typecheck` 成功退出即表示搭建完成。\n\n## 贡献者参考\n\n### TypeScript 项目布局\n\n仓库类型检查会执行全仓 `tsc -b` 图:它会发射每个 package/vendor 的 `lib/types`,并通过两个 no-emit 聚合检查示例、测试和脚本。\n\n仓库的 TypeScript 配置只有三种角色;每个 tsconfig 文件恰好扮演其中一种。\n\n| 文件 | 角色 | 是否构成 program? |\n|---|---|---|\n| `tsconfig.json` | solution 根:`extends` base、`files: []`、引用两个聚合。全仓 `tsc -b tsconfig.json` 图、tsserver 发现入口,并经继承的 `paths` 充当 tsx 运行 `examples/` 与 `scripts/` 时的解析配置(它们最近的 tsconfig 就是此文件)。 | 否 |\n| `tsconfig.host.json` | host 聚合:host 侧各包(经 references)、示例、测试、脚本、website。排除 `packages/client`。 | 是 |\n| `tsconfig.client.json` | client 聚合:`packages/client/*` 各包及其测试、`apps/web`。 | 是 |\n| `tsconfig.base.json` | 共享 compilerOptions 与源码 `paths` 映射。同时是各 vitest 配置让 vite-tsconfig-paths 指向的解析门面:它没有 `include`,因此其 `paths` 适用于任何 importer。 | 否 |\n| `tsconfig.base.client.json` | 浏览器编译形状(`jsx`、DOM lib、`types: []`),由 client 聚合和每个 `packages/client/*` 包 extends。 | 否 |\n\nhost 与 client 保持两个聚合 program,是因为两侧在相同键下以不同服务对 cordis `Context` 接口做声明合并;单一 program 同时看到两份合并会报冲突。这种冲突只存在于 `ts.Program` 内部——模块解析永远不会触发它——所以 solution 可以同时引用两个聚合,一个 paths 门面也可以横跨两侧。由此推出两条纪律:\n\n- `tsconfig.base.json` 永不添加 `include` 或 `files`:它们会泄漏进每个 extends 它的包项目,并收窄门面的全匹配范围。\n- 构造全仓 `ts.Program` 的脚本显式种子 `tsconfig.host.json` 或 `tsconfig.client.json`——永不种子根 solution,因为把两个聚合展平进一个 program 会撞上 `Context` 合并冲突。基于 program 的生成器与门禁(`scripts/ts-project.ts` 的消费者、doc-typecheck standalone 模式)按决策仅覆盖 host 侧;client 侧只在出现真实需求时再获得基于 program 的工具。\n\n静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。决策记录:[solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md);tsc-first 发射管线见 [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md)。\n\n业务 Service 在 Host 使用 `@Remote` 或 `@RemoteContext` 声明可调用方法;Host 构建生成 Host-for-Client 类型与运行时贡献,Client 的 `api-remotes` 组合加载这些贡献并将具体方法挂载到 `ctx.api` 或对应的作用域 Context。两侧的生成产物、装配关系、SRC 开发回退和 Web 构建顺序见 [API Gateway](api-gateway.md)。\n\n如果相关的本地检查需要使用构建后的包产物,请先构建一次:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` 包含 `publint`(用构建出的 `lib/*.js` 文件校验 package 入口点)和 `verify-node-next-types`(用一个临时的 NodeNext 消费方校验构建出的声明文件)。新 worktree 在 `pnpm run build` 运行之前没有打包的 JS 和声明文件;普通提交和推送无需构建,除非所选检查会使用这些产物。\n\n### 环境变量\n\n真实的 DeepSeek 适配器和需要密钥的 agent 演示从环境变量或仓库根目录一个被 gitignore 的 `.env` 文件读取凭证:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` 可选,默认为公开 API。请勿提交真实凭证。未设置 `DEEPSEEK_API_KEY` 时,真实 API 的 e2e 套件会自动跳过。\n\n### Git 钩子\n\nlefthook 在 `lefthook.yml` 中配置,作为快速的本地检查点:\n\n- `pre-commit` 应用仅用于格式化的 ESLint 修复,使用 Oxlint 验证暂存文件并应用其原生修复,在暂存文件属于 `THIRD_PARTY_NOTICES.md` 的输入时重新生成该文件,然后检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫;\n- `pre-push` 只运行仓库增量类型检查(对根 solution 执行 `tsc -b`,覆盖 host 与 client 两个聚合)。\n\nvendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。请在编辑 vendor 代码前先阅读 `vendor/README.md`。\n\n这些钩子有意不运行测试、快照、文档检查、构建或 `hygiene`。贡献者只运行一次[与改动行为相关的检查](../AGENTS.md#run-relevant-checks-locally);CI 负责全量覆盖率门禁、构建产物冒烟测试,以及 Node 22.19、24 和 26 兼容性矩阵。\n\n贡献者可以选择运行 `pnpm run check:all`,执行全面的本地门禁集。该命令独立于两个 Git 钩子,也不是对 agent 的指令。\n\n### CI 门禁\n\nkeyless [CI 工作流](../.github/workflows/ci.yml) 将独立门禁分组到若干宽粒度 lane,并在受支持的 Node 版本上运行一组较小的兼容性检查。产物消费方在各自 lane 内等待一次 build。单独的真实 API 工作流按其配置的 worker 上限运行 `pnpm run test:e2e`。当前门禁和 job 清单以 [scripts/run-gates.ts](../scripts/run-gates.ts) 和工作流文件为准。\n\n### 日常命令\n\n根目录的[贡献者说明](../AGENTS.md#commands)概述常用命令,[`package.json`](../package.json) 与 [scripts/run-gates.ts](../scripts/run-gates.ts) 则负责当前脚本和门禁清单。请选择覆盖变更表面的最小检查集。文档变更使用 `pnpm run doc-sync`;package 公开行为变更还需更新所属 README 或 JSDoc,而基于构建产物的检查需要先运行 `pnpm run build`。\n\n### 演示\n\n单次运行的 Headless coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\n自指的 cordis 演示可以检查并修改其实时插件运行时,并需要相同的凭证(默认 `web`,也可用 `acp`):\n\n```sh\npnpm run demo:cordis\n```\n\nACP 自动化服务器通过 JSON-RPC stdio 提供全新 agent 会话,同样需要 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n### TODO 标记\n\n请使用以下三种注释标签之一标记代码中的已知问题,按紧急程度排序:\n\n- `FIXME`:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 `FIXME`;\n- `TODO`:应当尽快修复的问题,等资源到位即可处理;\n- `XXX`:也许某天会修复的问题,优先级最低,不作承诺。\n\n请选择与紧急程度匹配的标签,让浏览代码的人一眼分清「发布阻塞」和「有空再说」。\n\n### 逐字记录类型(`ts type-equiv`)\n\n[核心数据结构](core-data-structures/core.md)文档会把与源码等价的声明及其原始 JSDoc 一并粘贴,让读者看到确切形状和源码契约。为防止粘贴内容在源码变化时漂移,请将其围栏为 ` ```ts type-equiv `(而不是 ` ```ts `),并在 `scripts/type-equiv.manifest.json` 中登记它镜像的源文件和符号:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明及其附带的 JSDoc,并断言代码块同时匹配两者。对于不应把实现体写进目录的类,请使用 ` ```ts public-api ` 并设置 `\"projection\": \"public-api\"`;门禁检查的投影会保留公共字段、构造函数、访问器、方法以及类和成员的原始 JSDoc,同时省略实现体和私有或受保护成员。比对会忽略空白和非 JSDoc 注释,但要求保留每条原始 JSDoc(包括成员文档),让读者同时看到源码契约和确切形状。该门禁按文档、符号和投影,在主块与 manifest 条目之间强制 1:1 对应;只有当配对 `.zh.md` 块的完整受跟踪围栏序列与其无后缀兄弟文件按字节一致且顺序相同时,才会复用后者的条目。`doc-typecheck` 对可编译围栏应用同一派生规则,同时跳过两种源码等价围栏的编译,并将其排除在 opt-out 比例之外。当你改动一个已记录的类型声明或其 JSDoc 时,门禁会失败直到你更新粘贴内容;当你增删一个主块时,请在同一个变更里更新 manifest。\n" }, { "role": "user", From 686ee5b3f6824b4bcdda4e334d59bf55ea0aec3d Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:53:04 +0800 Subject: [PATCH 47/63] fix(api-gateway): harden remote lifecycle and recovery --- ...08-02-typert-remote-method-calls.i18n.yaml | 4 +- .../2026-08-02-typert-remote-method-calls.md | 2 +- ...026-08-02-typert-remote-method-calls.zh.md | 2 +- packages/api/gateway/src/client/index.ts | 20 ++++---- packages/api/gateway/src/index.ts | 9 ++-- packages/api/gateway/tests/client.spec.ts | 14 ++++++ packages/api/gateway/tests/gateway.spec.ts | 16 +++++++ packages/api/remotes/src/agent-lookup.ts | 1 + .../api/remotes/tests/agent-lookup.spec.ts | 44 +++++++++++++++++ packages/client/ui-goal/src/client/index.ts | 14 +++--- .../ui-goal/tests/browser-plugin.spec.tsx | 15 +++++- packages/typert/registry/README.i18n.yaml | 4 +- packages/typert/registry/README.md | 1 + packages/typert/registry/README.zh.md | 1 + packages/typert/registry/src/service.ts | 48 ++++++++++++++++++- packages/typert/registry/tests/typert.spec.ts | 30 ++++++++++++ packages/typert/type-meta/README.i18n.yaml | 4 +- packages/typert/type-meta/README.md | 2 +- packages/typert/type-meta/README.zh.md | 2 +- packages/typert/type-meta/src/index.ts | 1 + packages/typert/type-meta/src/types.ts | 18 ++++++- 21 files changed, 218 insertions(+), 34 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml index 9ba0cf8dc1..1e4aeaabd7 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md -2026-08-02-typert-remote-method-calls.md: c4f3a5b94bf25b4581b9430cfcb4f02f707e0749 -2026-08-02-typert-remote-method-calls.zh.md: e11d8ebe42d44cc9805e942a31f13f7ae847815a +2026-08-02-typert-remote-method-calls.md: 3d5a79fd4a26f7d232dcc7635625899e2eb9df6b +2026-08-02-typert-remote-method-calls.zh.md: 3d6ec680ba97a532f18219670e8dba799a94ed7b diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md index c4f3a5b94b..3d5a79fd4a 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md @@ -164,7 +164,7 @@ Every registration returns a disposer owned by the caller's Cordis fiber. Client The lookup registry retains the stable wire declaration after its live resolver unloads. SRC parsing continues to classify the parameter as a lookup, while invocation fails with `lookup-unavailable`; it never reclassifies the incoming ID as an ordinary JSON business object. Re-registering the same key with different parameter, wire, or canonical type symbols fails for the lifetime of that TypeRT Service. -Business-object packages own stable declarations and default resolvers through `register()`; Host composition supplies an effect-scoped asynchronous policy for the same key through `configure()`. Configuration may precede provider registration, but does not by itself make a lookup available without a live provider; unloading the configuration restores the provider's default resolver. API Remotes creates the shared `agentFor()` resolver for `agent` and `session`: live Agents are reused, ordinary cold sessions are resumed automatically, concurrent resumes are deduplicated by Session ID, and the subagent ownership fence returns the existing `agent-busy`. The standard Web API Proxy supplies its Agent defaults and scope setup and consumes that resolver for legacy methods. The `session` resolver returns the resolved Agent's Session, so the two parameter kinds do not create separate resume lifecycles. +Business-object and scoped-Context packages own stable declarations and default resolvers through `lookups.register()` and `contexts.registerHost()`; Host composition supplies effect-scoped asynchronous policies through `lookups.configure()` and `contexts.configureHost()`. Configuration may precede provider registration, but does not by itself make an identity available without a live provider; unloading the configuration restores the provider's default resolver. API Remotes creates the shared `agentFor()` resolver for `agent` and `session` lookups and the `agent` Host Context: live Agents are reused, ordinary cold sessions are resumed automatically, concurrent resumes are deduplicated by Session ID, and the subagent ownership fence returns the existing `agent-busy`. The standard Web API Proxy supplies its Agent defaults and scope setup and consumes that resolver for legacy methods. The `session` lookup returns the resolved Agent's Session, while the `agent` Host Context returns its Context, so all three projections share one resume lifecycle. The registry's Host root entry has the complete `TypeRTService` interface merge. The registry implementation shared by Host and Client lives in a separate module without environment declarations. The registry's `/client` entry imports only that shared implementation and does not pass through the Host root entry, so it cannot bring Host Cordis declarations into the Client Program. diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md index e11d8ebe42..3d6ec680ba 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md @@ -164,7 +164,7 @@ ctx.typert.contexts Host Context resolver 与 Client Context binder lookup 注册表会在活 resolver 卸载后保留稳定的 wire 声明。SRC 解析仍会把该参数归类为 lookup,而调用会以 `lookup-unavailable` 失败;系统绝不会把传入的 ID 重新归类为普通 JSON 业务对象。在同一个 TypeRT Service 的生命周期内,以不同参数、wire 或规范类型 symbol 重新注册同一 key 会直接失败。 -业务对象包通过 `register()` 拥有稳定声明和默认 resolver;Host 组合通过 `configure()` 为同一个 key 提供 effect-scoped 异步策略。配置可以先于 provider 注册,但没有活 provider 时不会单独形成可用 lookup;配置卸载后恢复 provider 默认 resolver。API Remotes 为 `agent` 和 `session` 创建共享的 `agentFor()` resolver:live Agent 直接复用,普通冷会话自动恢复,并发恢复按 Session ID 去重,subagent ownership fence 则返回既有 `agent-busy`。标准 Web API Proxy 提供 Agent 默认值和 scope 设置,并让旧方法使用该 resolver。`session` resolver 返回解析所得 Agent 的 Session,因而两种参数不会产生两套恢复生命周期。 +业务对象包和 scoped Context 包通过 `lookups.register()` 与 `contexts.registerHost()` 拥有稳定声明和默认 resolver;Host 组合通过 `lookups.configure()` 与 `contexts.configureHost()` 提供 effect-scoped 异步策略。配置可以先于 provider 注册,但没有活 provider 时不会单独形成可用身份;配置卸载后恢复 provider 默认 resolver。API Remotes 为 `agent`、`session` lookup 和 `agent` Host Context 创建共享的 `agentFor()` resolver:live Agent 直接复用,普通冷会话自动恢复,并发恢复按 Session ID 去重,subagent ownership fence 则返回既有 `agent-busy`。标准 Web API Proxy 提供 Agent 默认值和 scope 设置,并让旧方法使用该 resolver。`session` lookup 返回解析所得 Agent 的 Session,`agent` Host Context 返回其 Context,因此三种投影共用一个恢复生命周期。 Registry 的 Host 根入口拥有完整 `TypeRTService` interface merge;Host 与 Client 共用的 registry 实现位于无环境声明的独立模块。Registry `/client` 入口只引用该共享实现,不经过 Host 根入口,因此不会把 Host Cordis 声明带入 Client Program。 diff --git a/packages/api/gateway/src/client/index.ts b/packages/api/gateway/src/client/index.ts index bafffa80f7..ddef36e288 100644 --- a/packages/api/gateway/src/client/index.ts +++ b/packages/api/gateway/src/client/index.ts @@ -134,7 +134,8 @@ class ClientApiService extends Service implements TypeRTClientApi { for (const method of methods) record.service.assertMethodAvailable(method) } else { for (const method of methods) ScopedRemoteNamespace.assertMethodAvailable(namespace, method) - if (this.ownerCtx.reflect.props[namespace] !== undefined) { + const property = this.ownerCtx.reflect.props[namespace] + if (property?.type === 'accessor' || this.ownerCtx.get(namespace) !== undefined) { throw new Error(`client api: scoped namespace ${JSON.stringify(namespace)} conflicts with an existing Context property`) } } @@ -224,6 +225,7 @@ class ClientApiService extends Service implements TypeRTClientApi { if (namespace.tokens.get(descriptor.method) !== token) return namespace.service.remove(descriptor.method) namespace.tokens.delete(descriptor.method) + if (namespace.tokens.size === 0) this.scoped.delete(descriptor.namespace) } } @@ -289,7 +291,7 @@ class ScopedRemoteNamespace { private readonly ctx: Context private readonly ownerCtx: Context private readonly methods = new Set() - private provided = false + private disposeService: (() => void) | undefined readonly name: string static assertMethodAvailable(namespace: string, method: string): void { @@ -331,12 +333,7 @@ class ScopedRemoteNamespace { }, }) if (activate) { - if (this.provided) { - this.ownerCtx.set(this.name, this) - } else { - this.ownerCtx.reflect.provide(this.name, this) - this.provided = true - } + this.disposeService = this.ownerCtx.reflect.provide(this.name, this) } } catch (error) { Reflect.deleteProperty(this, method) @@ -348,11 +345,14 @@ class ScopedRemoteNamespace { remove(method: string): void { Reflect.deleteProperty(this, method) this.methods.delete(method) - if (this.methods.size === 0) this.ownerCtx.set(this.name, undefined) + if (this.methods.size !== 0) return + const disposeService = this.disposeService + this.disposeService = undefined + disposeService?.() } } -const SCOPED_NAMESPACE_FIELDS = new Set(['ctx', 'invokeRemote', 'methods', 'name', 'ownerCtx', 'provided']) +const SCOPED_NAMESPACE_FIELDS = new Set(['ctx', 'disposeService', 'invokeRemote', 'methods', 'name', 'ownerCtx']) function endpointOf(descriptor: Pick): string { return `${descriptor.namespace}/${descriptor.method}` diff --git a/packages/api/gateway/src/index.ts b/packages/api/gateway/src/index.ts index 13cf460f4d..5899f5d560 100644 --- a/packages/api/gateway/src/index.ts +++ b/packages/api/gateway/src/index.ts @@ -134,7 +134,7 @@ export class TypertGatewayService extends Service implements TypertGateway { const endpoint = endpointOf(request.namespace, request.method) const descriptor = this.resolveDescriptor(request.namespace, request.method, endpoint) assertExactArguments(request.args, descriptor, endpoint) - const receiverContext = this.resolveReceiverContext(descriptor, request.args, endpoint) + const receiverContext = await this.resolveReceiverContext(descriptor, request.args, endpoint) const receiver = receiverContext.get(descriptor.service) as unknown if (!isObject(receiver)) { throw new TypertGatewayError( @@ -331,11 +331,11 @@ export class TypertGatewayService extends Service implements TypertGateway { } } - private resolveReceiverContext( + private async resolveReceiverContext( descriptor: InvocationDescriptor, args: Readonly>, endpoint: string, - ): Context { + ): Promise { if (descriptor.invocation.kind === 'direct') return this.ctx const invocation = descriptor.invocation const provider = this.ctx.typert.contexts.getHost(invocation.context) @@ -358,8 +358,9 @@ export class TypertGatewayService extends Service implements TypertGateway { const identity = decode(invocation.codec, args[invocation.wire], 'input-invalid', endpoint, invocation.wire) let context: Context | undefined try { - context = provider.resolve(identity) + context = await provider.resolve(identity) } catch (cause) { + if (cause instanceof TypeRTLookupFailure) throw cause throw new TypertGatewayError( 'context-failed', endpoint, diff --git a/packages/api/gateway/tests/client.spec.ts b/packages/api/gateway/tests/client.spec.ts index 2fbcbb9280..feae3056c9 100644 --- a/packages/api/gateway/tests/client.spec.ts +++ b/packages/api/gateway/tests/client.spec.ts @@ -526,6 +526,20 @@ describe('Client TypeRT API', () => { await retry() }) + it('unregisters an empty scoped namespace so another provider can claim its name', async () => { + const ctx = await bench(vi.fn()) + const dispose = ctx.api.mount({ package: '@fixture/scoped', descriptors: [contextDescriptor()] }) + expect(ctx.get('goals')).toBeDefined() + + await dispose() + + expect(ctx.get('goals')).toBeUndefined() + const replacement = { owner: 'replacement' } + const disposeReplacement = ctx.reflect.provide('goals', replacement) + expect(ctx.get('goals')).toBe(replacement) + await disposeReplacement() + }) + it('throws RPC failures with the structured error as its cause', async () => { const rpcError = { code: 'internal' as const, message: 'host failed', details: {} } const ctx = await bench(vi.fn().mockResolvedValue({ ok: false, error: rpcError })) diff --git a/packages/api/gateway/tests/gateway.spec.ts b/packages/api/gateway/tests/gateway.spec.ts index d784a1ac2f..d298116b82 100644 --- a/packages/api/gateway/tests/gateway.spec.ts +++ b/packages/api/gateway/tests/gateway.spec.ts @@ -538,6 +538,22 @@ describe('TypertGatewayService', () => { expect(error.cause).toEqual(new Error('provider failed')) }) + it('preserves a Host Context policy rejection for the active RPC adapter', async () => { + const { ctx } = await setup() + const rejection = new TypeRTLookupFailure({ code: 'agent-busy', message: 'owned', details: { reason: 'subagent' } }) + ctx.typert.contexts.registerHost('gatewayFixture', { + ...contextProvider(ctx.extend()), + resolve: async () => { throw rejection }, + }) + registerStrict(ctx, [renameDescriptor()]) + + await expect(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'rename', + args: { agentId: 'agent-1', request: { title: 'land' } }, + })).rejects.toBe(rejection) + }) + it('reports Context provider metadata mismatch and unresolved identities', async () => { const { ctx } = await setup() registerStrict(ctx, [renameDescriptor()]) diff --git a/packages/api/remotes/src/agent-lookup.ts b/packages/api/remotes/src/agent-lookup.ts index e3a5b27df8..eb54ea9b0b 100644 --- a/packages/api/remotes/src/agent-lookup.ts +++ b/packages/api/remotes/src/agent-lookup.ts @@ -187,6 +187,7 @@ export function createApiRemoteAgentResolver( } typeCtx.typert.lookups.configure('agent', resolveAgent) typeCtx.typert.lookups.configure('session', async sessionId => (await resolveAgent(sessionId)).session) + typeCtx.typert.contexts.configureHost('agent', async sessionId => (await resolveAgent(sessionId)).ctx) }) return agentFor diff --git a/packages/api/remotes/tests/agent-lookup.spec.ts b/packages/api/remotes/tests/agent-lookup.spec.ts index c9110b8f3f..7179f5b2b2 100644 --- a/packages/api/remotes/tests/agent-lookup.spec.ts +++ b/packages/api/remotes/tests/agent-lookup.spec.ts @@ -5,6 +5,8 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import SessionStore from '@deepseek-ai/dsh-session' import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import { createApiRemoteAgentResolver } from '@deepseek-ai/dsh-api-remotes' +import { TypeRTLookupFailure } from '@deepseek-ai/dsh-type-meta' +import TypertRegistry from '@deepseek-ai/dsh-typert-registry' const sid = (value: string): SessionId => value as SessionId @@ -14,6 +16,7 @@ function header(id: SessionId): SessionHeader { async function createContext(): Promise { const ctx = new Context() + await ctx.plugin(TypertRegistry) await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) return ctx @@ -107,4 +110,45 @@ describe('API Remote Agent resolver races', () => { await ctx.fiber.dispose() } }) + + it('uses the shared cold-resume policy for the Agent Host Context', async () => { + const ctx = await createContext() + const sessionId = sid('context-cold-resume') + const meta = header(sessionId) + let published: Session | undefined + provideSession(ctx, meta, () => { + published = ctx.sessions.create(sessionId, { meta: { cwd: '/proj' } }) + return Promise.resolve({ meta, events: [] }) + }) + const agentCtx = ctx.extend() + vi.spyOn(ctx.agents, 'resume').mockImplementation(async () => { + if (published === undefined) throw new Error('Session was not published') + return { agent: stubAgent(agentCtx, published), dispose: () => Promise.resolve() } + }) + const defaultProvider = ctx.typert.contexts.getHost('agent') + createApiRemoteAgentResolver(ctx, {}) + await vi.waitFor(() => { expect(ctx.typert.contexts.getHost('agent')).not.toBe(defaultProvider) }) + const provider = ctx.typert.contexts.getHost('agent') + if (provider === undefined) throw new Error('Agent Host Context provider was not mounted') + + await expect(provider.resolve(sessionId)).resolves.toBe(agentCtx) + await ctx.fiber.dispose() + }) + + it('applies the subagent ownership fence to the Agent Host Context', async () => { + const ctx = await createContext() + const sessionId = sid('context-owned-subagent') + const session = ctx.sessions.create(sessionId, { meta: { cwd: '/proj', origin: 'subagent' } }) + ctx.agents.register(stubAgent(ctx.extend(), session)) + const defaultProvider = ctx.typert.contexts.getHost('agent') + createApiRemoteAgentResolver(ctx, {}) + await vi.waitFor(() => { expect(ctx.typert.contexts.getHost('agent')).not.toBe(defaultProvider) }) + const provider = ctx.typert.contexts.getHost('agent') + if (provider === undefined) throw new Error('Agent Host Context provider was not mounted') + + const resolution = provider.resolve(sessionId) + await expect(resolution).rejects.toBeInstanceOf(TypeRTLookupFailure) + await expect(resolution).rejects.toMatchObject({ failure: { code: 'agent-busy' } }) + await ctx.fiber.dispose() + }) }) diff --git a/packages/client/ui-goal/src/client/index.ts b/packages/client/ui-goal/src/client/index.ts index 2e49d5b6b8..bea4f67df2 100644 --- a/packages/client/ui-goal/src/client/index.ts +++ b/packages/client/ui-goal/src/client/index.ts @@ -38,10 +38,10 @@ const NS = 'goal' /** Required services: slots for the dock entry, sessions for the projected ref, API for Remote mutations, locale for the copy. */ export const inject = ['slots', 'sessions', 'api', 'locale'] -/** Map one generated Remote call onto the strip's inline-render shape. */ -async function settle(result: Promise): Promise { +/** Map one generated Remote call, including synchronous namespace lookup failures, onto the strip's inline-render shape. */ +async function settle(invoke: () => Promise): Promise { try { - await result + await invoke() return { ok: true } } catch (error) { const cause = error instanceof Error ? error.cause : undefined @@ -94,22 +94,22 @@ export function apply(ctx: ClientContext): void { onEdit: async (objective) => { const ref = refOf(sessionId) if (ref === undefined) return noCurrentGoal - return settle(ctx.api.goals.edit(sessionId, ref, { objective })) + return settle(() => ctx.api.goals.edit(sessionId, ref, { objective })) }, onPause: async () => { const ref = refOf(sessionId) if (ref === undefined) return noCurrentGoal - return settle(ctx.api.goals.pause(sessionId, ref)) + return settle(() => ctx.api.goals.pause(sessionId, ref)) }, onResume: async () => { const ref = refOf(sessionId) if (ref === undefined) return noCurrentGoal - return settle(ctx.api.goals.resume(sessionId, ref)) + return settle(() => ctx.api.goals.resume(sessionId, ref)) }, onClear: async () => { const ref = refOf(sessionId) if (ref === undefined) return noCurrentGoal - return settle(ctx.api.goals.clear(sessionId, ref)) + return settle(() => ctx.api.goals.clear(sessionId, ref)) }, }), }, GoalDock)) diff --git a/packages/client/ui-goal/tests/browser-plugin.spec.tsx b/packages/client/ui-goal/tests/browser-plugin.spec.tsx index 11c95e27d9..f900682712 100644 --- a/packages/client/ui-goal/tests/browser-plugin.spec.tsx +++ b/packages/client/ui-goal/tests/browser-plugin.spec.tsx @@ -70,7 +70,7 @@ async function bench(options: { resume: answer(`${prefix}/resume`, { ref }), clear: answer(`${prefix}/clear`, ref), }) - let activeGoals = goals('goals') + let activeGoals: ReturnType | undefined = goals('goals') ctx.provide('api', { get goals() { return activeGoals }, }) @@ -95,6 +95,7 @@ async function bench(options: { fiber, calls, remountGoals: () => { activeGoals = goals('remounted-goals') }, + unmountGoals: () => { activeGoals = undefined }, entry: () => { const entry = ctx.slots.entries('conversation.input.dock')[0] if (entry === undefined) return undefined @@ -141,6 +142,18 @@ describe('ui-goal browser plugin', () => { expect(b.calls).toMatchObject([{ method: 'remounted-goals/pause' }]) }) + it('settles every verb when the Remote namespace is temporarily absent', async () => { + const b = await bench({ projection: makeProjection() }) + await b.fiber.await() + const verbs = b.entry()!.inject!(sid('s1')) + b.unmountGoals() + + for (const result of [await verbs.onEdit('x'), await verbs.onPause(), await verbs.onResume(), await verbs.onClear()]) { + expect(result).toMatchObject({ ok: false, error: { code: 'internal' } }) + } + expect(b.calls).toHaveLength(0) + }) + it('a null or absent projection short-circuits every verb without touching the wire', async () => { for (const projection of [null, undefined]) { const b = await bench({ projection }) diff --git a/packages/typert/registry/README.i18n.yaml b/packages/typert/registry/README.i18n.yaml index a6180c6bfc..011834c52d 100644 --- a/packages/typert/registry/README.i18n.yaml +++ b/packages/typert/registry/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/typert/registry/README.md -README.md: dae8c3ed124fd6e2d61eb47964e2c07dda762b48 -README.zh.md: aea74b3753feccd88ee132363dc60ade02161498 +README.md: fa227b1c8faf1abd5a6492d4b8fe7d0c51ceeef1 +README.zh.md: 343e43aaca6ddaa0bb5e8d3130c85f37e4b4cb93 diff --git a/packages/typert/registry/README.md b/packages/typert/registry/README.md index dae8c3ed12..fa227b1c8f 100644 --- a/packages/typert/registry/README.md +++ b/packages/typert/registry/README.md @@ -10,6 +10,7 @@ Package reflection is keyed by `#`. Schemas are keyed by `>() + private readonly hostResolvers = new Map>() private readonly clients = new Map>() private readonly changes: ChangeSource @@ -347,16 +349,56 @@ class ContextStore { key: K, provider: TypeRTHostContextProvider>, ) => this.registerHost(ctx, key, provider), + configureHost: >( + key: K, + resolver: TypeRTHostContextResolver>, + ) => this.configureHost(ctx, key, resolver), registerClient: >( key: K, binder: TypeRTClientContextBinder>, ) => this.registerClient(ctx, key, binder), - getHost: key => this.hosts.get(key)?.provider, + getHost: key => this.getHost(key), getClient: key => this.clients.get(key)?.provider, subscribe: listener => this.changes.subscribe(ctx, listener), } } + private getHost(key: string): TypeRTHostContextProvider | undefined { + const provider = this.hosts.get(key)?.provider + if (provider === undefined) return undefined + const resolver = this.hostResolvers.get(key)?.provider + if (resolver === undefined) return provider + return { + wire: provider.wire, + wireTypeSymbol: provider.wireTypeSymbol, + resolve: id => resolver.resolve(id), + } + } + + private configureHost( + ctx: Context, + key: string, + resolver: TypeRTHostContextResolver, + ): TypeRTDisposer { + validateSegment('Context key', key) + if (this.hostResolvers.has(key)) throw new Error(`typert: host-context "${key}" resolver is already configured`) + const entry: ProviderEntry = { + provider: { resolve: async id => resolver(id as Wire) }, + owner: {}, + } + const { hostResolvers, changes } = this + return ctx.effect(function* () { + hostResolvers.set(key, entry) + changes.emit({ kind: 'host-context', key }) + yield () => { + /* v8 ignore next -- duplicate configuration is rejected, so this effect remains the key's unique owner. */ + if (hostResolvers.get(key) !== entry) return + hostResolvers.delete(key) + changes.emit({ kind: 'host-context', key }) + } + }, `typert.contexts.configureHost(${JSON.stringify(key)})`) + } + private registerHost(ctx: Context, key: string, provider: TypeRTHostContextProvider): TypeRTDisposer { validateSegment('Context key', key) validateWireName('Context wire field', provider.wire) @@ -392,6 +434,10 @@ class ContextStore { } } +interface HostContextResolverEntry { + resolve(id: unknown): Promise +} + /** * Registry of generated schemas, package reflection, invocations, and Remote * dependency providers. diff --git a/packages/typert/registry/tests/typert.spec.ts b/packages/typert/registry/tests/typert.spec.ts index 087cf00fc4..92b81803c7 100644 --- a/packages/typert/registry/tests/typert.spec.ts +++ b/packages/typert/registry/tests/typert.spec.ts @@ -389,6 +389,36 @@ describe('TypertRegistry', () => { await disposeReloadedProvider() }) + it('configures an asynchronous Host Context resolver independently of provider load order', async () => { + const ctx = await makeCtx() + const fallback = ctx.extend() + const configured = ctx.extend() + const disposeResolver = ctx.typert.contexts.configureHost('registryFixture', async id => + id === 'configured' ? configured : undefined) + + expect(ctx.typert.contexts.getHost('registryFixture')).toBeUndefined() + const disposeProvider = ctx.typert.contexts.registerHost('registryFixture', { + wire: 'agentId', + wireTypeSymbol: '@fixture/session#SessionId', + resolve: id => id === 'fallback' ? fallback : undefined, + }) + await expect(ctx.typert.contexts.getHost('registryFixture')?.resolve('configured')).resolves.toBe(configured) + expect(() => ctx.typert.contexts.configureHost('registryFixture', () => undefined)).toThrow('already configured') + + await disposeProvider() + expect(ctx.typert.contexts.getHost('registryFixture')).toBeUndefined() + const disposeReloadedProvider = ctx.typert.contexts.registerHost('registryFixture', { + wire: 'agentId', + wireTypeSymbol: '@fixture/session#SessionId', + resolve: id => id === 'fallback' ? fallback : undefined, + }) + await expect(ctx.typert.contexts.getHost('registryFixture')?.resolve('configured')).resolves.toBe(configured) + + await disposeResolver() + expect(ctx.typert.contexts.getHost('registryFixture')?.resolve('fallback')).toBe(fallback) + await disposeReloadedProvider() + }) + it('publishes provider changes, rejects duplicate providers, and disposes subscriptions', async () => { const ctx = await makeCtx() const changes: string[] = [] diff --git a/packages/typert/type-meta/README.i18n.yaml b/packages/typert/type-meta/README.i18n.yaml index 510b8d3854..6c21127e54 100644 --- a/packages/typert/type-meta/README.i18n.yaml +++ b/packages/typert/type-meta/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/typert/type-meta/README.md -README.md: b394c843409e840b75bbb08b128614379e528001 -README.zh.md: 5bd9bb18289a0320e0603d8b373e60d7f1e3c7e5 +README.md: a76169742cb78d0d19814bcd0f978c71036a5a1c +README.zh.md: 6f2d2fd6e241441fae8102c0639608e9b27b9bec diff --git a/packages/typert/type-meta/README.md b/packages/typert/type-meta/README.md index b394c84340..a76169742c 100644 --- a/packages/typert/type-meta/README.md +++ b/packages/typert/type-meta/README.md @@ -20,7 +20,7 @@ Decorator initializers retain markers in a module-private `WeakMap` keyed by the Business packages extend `TypeRTLookupMap` and `TypeRTContextMap` to associate Host objects or scoped Contexts with their wire identities. Generated artifacts extend `TypeRTRemoteMap`, `TypeRTRemoteContextMap`, and `TypeRTRemoteNamespaceMap` so Client imports expose only selected Remote methods. `InvocationDescriptor` is the shared runtime form consumed by the registry, Gateway, and Client API. -Lookup and Context packages own both sides of their contract: declaration merging supplies the static association, while runtime providers register identity resolution with `ctx.typert`. A lookup provider supplies the stable declaration and default resolver, while Host composition may separately configure a synchronous or asynchronous resolver; policy rejections may use `TypeRTLookupFailure` to carry a failure value owned by the boundary adapter. Strict codecs carry generated schemas; `src-json` codecs identify the weaker source-launch path. +Lookup and Context packages own both sides of their contract: declaration merging supplies the static association, while runtime providers register identity resolution with `ctx.typert`. A lookup or Host Context provider supplies the stable declaration and default resolver, while Host composition may separately configure a synchronous or asynchronous resolver; policy rejections may use `TypeRTLookupFailure` to carry a failure value owned by the boundary adapter. Strict codecs carry generated schemas; `src-json` codecs identify the weaker source-launch path. ## Model Experience diff --git a/packages/typert/type-meta/README.zh.md b/packages/typert/type-meta/README.zh.md index 5bd9bb1828..6f2d2fd6e2 100644 --- a/packages/typert/type-meta/README.zh.md +++ b/packages/typert/type-meta/README.zh.md @@ -20,7 +20,7 @@ Host 方法通过将 `signal: AbortSignal` 声明为最后一个参数来启用 业务包扩展 `TypeRTLookupMap` 和 `TypeRTContextMap`,以关联 Host 对象或作用域 Context 与其协议身份。生成的产物扩展 `TypeRTRemoteMap`、`TypeRTRemoteContextMap` 和 `TypeRTRemoteNamespaceMap`,使 Client 导入后仅暴露选定的 Remote 方法。`InvocationDescriptor` 是供注册表、Gateway 和 Client API 使用的共享运行时形式。 -查找包与 Context 包同时负责其契约的两侧:声明合并提供静态关联,运行时提供方则向 `ctx.typert` 注册身份解析。lookup provider 提供稳定声明与默认 resolver,Host 组合可以另行配置同步或异步 resolver;策略拒绝可用 `TypeRTLookupFailure` 携带由边界适配器拥有的失败值。严格编解码器携带生成的 schema;`src-json` 编解码器标识约束更弱的源码启动路径。 +查找包与 Context 包同时负责其契约的两侧:声明合并提供静态关联,运行时提供方则向 `ctx.typert` 注册身份解析。lookup 或 Host Context provider 提供稳定声明与默认 resolver,Host 组合可以另行配置同步或异步 resolver;策略拒绝可用 `TypeRTLookupFailure` 携带由边界适配器拥有的失败值。严格编解码器携带生成的 schema;`src-json` 编解码器标识约束更弱的源码启动路径。 ## 模型体验 diff --git a/packages/typert/type-meta/src/index.ts b/packages/typert/type-meta/src/index.ts index 1418c9d7f2..2f687f985f 100644 --- a/packages/typert/type-meta/src/index.ts +++ b/packages/typert/type-meta/src/index.ts @@ -50,6 +50,7 @@ export type { TypeRTContextWire, TypeRTDisposer, TypeRTHostContextProvider, + TypeRTHostContextResolver, TypeRTLocalRegistry, TypeRTLookup, TypeRTLookupDefinition, diff --git a/packages/typert/type-meta/src/types.ts b/packages/typert/type-meta/src/types.ts index b65690115f..ed309b7857 100644 --- a/packages/typert/type-meta/src/types.ts +++ b/packages/typert/type-meta/src/types.ts @@ -238,9 +238,14 @@ export interface TypeRTHostContextProvider { * @param id - validated wire identity. * @returns the scoped Context, or `undefined` when unavailable. */ - resolve(id: Wire): Context | undefined + resolve(id: Wire): Context | undefined | Promise } +/** Composition-owned resolver replacing one Host Context provider's default lookup policy. */ +export type TypeRTHostContextResolver = ( + id: Wire, +) => Context | undefined | Promise + /** Client resolver for the identity carried by the calling scoped Context. */ export interface TypeRTClientContextBinder { /** @@ -367,6 +372,17 @@ export interface TypeRTContextRegistry { key: K, provider: TypeRTHostContextProvider>, ): TypeRTDisposer + /** + * Override one Host Context key's identity policy for the calling fiber. + * Configuration may precede provider registration and restores the provider's default resolver on disposal. + * @param key - merge-declared Context key. + * @param resolver - composition-owned resolver used by every Host Context lookup of this key. + * @returns disposer restoring the provider's default resolver. + */ + configureHost>( + key: K, + resolver: TypeRTHostContextResolver>, + ): TypeRTDisposer /** * Register a Client Context identity binder. * @param key - merge-declared Context key. From d3b7ff17f005096031a5e25c255b0010d1fca13c Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:58:43 +0800 Subject: [PATCH 48/63] fix: ci --- docs/cordis-catalog/services.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index cf763dca98..4a73dc06ad 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -2593,7 +2593,7 @@ listPackages(filter: TypertPackageFilter = {}): TypertPackageRecord[] toJSONSchema(key: string, params?: z.core.ToJSONSchemaParams): z.core.JSONSchema.BaseSchema ``` -Source: [`packages/typert/registry/src/service.ts:400`](../../packages/typert/registry/src/service.ts) +Source: [`packages/typert/registry/src/service.ts:446`](../../packages/typert/registry/src/service.ts) ## `ctx.typertGateway` — `TypertGatewayService` From 146097368b4e2398db7b1a866144ab6d363f2803 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 7 Aug 2026 17:21:37 +0800 Subject: [PATCH 49/63] fix(api-gateway): type async service disposer --- packages/api/gateway/src/client/index.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/api/gateway/src/client/index.ts b/packages/api/gateway/src/client/index.ts index ddef36e288..a9343823ff 100644 --- a/packages/api/gateway/src/client/index.ts +++ b/packages/api/gateway/src/client/index.ts @@ -11,6 +11,7 @@ import type { InvocationDescriptor, TypeRTClientApi, TypeRTCodec, + TypeRTDisposer, TypeRTRemoteContribution, } from '@deepseek-ai/dsh-type-meta' @@ -291,7 +292,7 @@ class ScopedRemoteNamespace { private readonly ctx: Context private readonly ownerCtx: Context private readonly methods = new Set() - private disposeService: (() => void) | undefined + private disposeService: TypeRTDisposer | undefined readonly name: string static assertMethodAvailable(namespace: string, method: string): void { @@ -348,7 +349,7 @@ class ScopedRemoteNamespace { if (this.methods.size !== 0) return const disposeService = this.disposeService this.disposeService = undefined - disposeService?.() + void disposeService?.() } } From d6ffd87c5f1b193d698620e716a261743b9324dc Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 7 Aug 2026 18:28:30 +0800 Subject: [PATCH 50/63] refactor(api): expose traced remote namespaces --- ...08-02-typert-remote-method-calls.i18n.yaml | 4 +- .../2026-08-02-typert-remote-method-calls.md | 72 +-- ...026-08-02-typert-remote-method-calls.zh.md | 72 +-- docs/api-gateway.i18n.yaml | 4 +- docs/api-gateway.md | 22 +- docs/api-gateway.zh.md | 22 +- docs/core-data-structures/typert.i18n.yaml | 4 +- docs/core-data-structures/typert.md | 16 +- docs/core-data-structures/typert.zh.md | 16 +- docs/development.i18n.yaml | 4 +- docs/development.md | 2 +- docs/development.zh.md | 2 +- packages/api/README.i18n.yaml | 4 +- packages/api/README.md | 6 +- packages/api/README.zh.md | 6 +- packages/api/gateway/README.i18n.yaml | 4 +- packages/api/gateway/README.md | 8 +- packages/api/gateway/README.zh.md | 8 +- packages/api/gateway/src/client/index.ts | 414 +++++++++++------- packages/api/gateway/tests/client.spec.ts | 245 +++++------ packages/api/remotes/README.i18n.yaml | 4 +- packages/api/remotes/README.md | 6 +- packages/api/remotes/README.zh.md | 6 +- packages/api/remotes/src/client/index.ts | 16 +- packages/api/remotes/tests/built-lib.e2e.ts | 8 +- .../client/runtime/src/client/agents/scope.ts | 11 +- .../runtime/src/client/contract/sessions.ts | 5 +- packages/client/runtime/src/client/index.ts | 4 +- .../client/runtime/tests/client-apply.spec.ts | 3 +- .../client/runtime/tests/wire-events.spec.ts | 3 +- packages/client/ui-goal/README.i18n.yaml | 4 +- packages/client/ui-goal/README.md | 2 +- packages/client/ui-goal/README.zh.md | 2 +- packages/client/ui-goal/src/client/index.ts | 12 +- .../ui-goal/tests/browser-plugin.spec.tsx | 15 +- .../generator/tests/remote-model.spec.ts | 10 +- packages/typert/type-meta/src/index.ts | 2 +- packages/typert/type-meta/src/types.ts | 10 +- 38 files changed, 566 insertions(+), 492 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml index 1e4aeaabd7..341bf44923 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md -2026-08-02-typert-remote-method-calls.md: 3d5a79fd4a26f7d232dcc7635625899e2eb9df6b -2026-08-02-typert-remote-method-calls.zh.md: 3d6ec680ba97a532f18219670e8dba799a94ed7b +2026-08-02-typert-remote-method-calls.md: a8254090e042e4b359ae74fc5c19bad8abc5ef89 +2026-08-02-typert-remote-method-calls.zh.md: f1b7e5f9c61b474379962ce007e5d6bb966e5ebd diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md index 3d5a79fd4a..a8254090e0 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md @@ -18,11 +18,11 @@ The Host and Browser Client use separate TypeScript Programs because each side a A business Service extends `GatewayService` and declares callable methods with `@Remote` or `@RemoteContext()`. A Service that already has another base class may instead expose the same binding through `bindTypeRTGateway()`. TypeRT generates the Host-local reflection artifact and a platform-independent Remote consumer projection from the Host Program. The Client Program continues to generate its own local reflection artifact independently. -The Remote consumer projection contains `.d.ts`, `.d.ts.map`, and `.js` files. The `.d.ts` exposes only methods marked with a Remote decorator and refers to the business package's single public type symbols. The `.d.ts.map` navigates consumer API methods back to their Host business method implementations. The `.js` carries endpoint, parameter, Context, and Zod information for the same contract. At the assembly layer, the Browser Client mounts the required Remote JS contributions onto the Client API Service. The projection and API abstraction remain platform-independent so that a future TUI can reuse them. +The Remote consumer projection contains `.d.ts`, `.d.ts.map`, and `.js` files. The `.d.ts` exposes only methods marked with a Remote decorator and refers to the business package's single public type symbols. The `.d.ts.map` navigates consumer API methods back to their Host business method implementations. The `.js` carries endpoint, parameter, Context, and Zod information for the same contract. At the assembly layer, the Browser Client mounts the required Remote JS contributions onto the Client Remote Service. The projection and Remote abstraction remain platform-independent so that a future TUI can reuse them. -`@deepseek-ai/dsh-api-gateway`, located at `packages/api/gateway`, provides two symmetric faces: its default entry provides Host `ctx.typertGateway`, while its `/client` entry provides consumer-side `ctx.api`. Each side consumes a locally generated `InvocationDescriptor` from the same model; descriptors are not sent over the wire. The Remote data protocol runs over Connection's shared `/api` RPC channel. The business calling interface does not change when Connection migrates from HTTP to WebSocket. +`@deepseek-ai/dsh-api-gateway`, located at `packages/api/gateway`, provides two symmetric faces: its default entry provides Host `ctx.typertGateway`, while its `/client` entry provides consumer-side `ctx.remote`. Each side consumes a locally generated `InvocationDescriptor` from the same model; descriptors are not sent over the wire. The Remote data protocol runs over Connection's shared `/api` RPC channel. The business calling interface does not change when Connection migrates from HTTP to WebSocket. -`@deepseek-ai/dsh-api-remotes`, located at `packages/api/remotes`, is the BFF layer above the Gateway. Its Host entry owns Agent/Session identity resolution and TypeRT lookup configuration; its `/client` entry selects the generated Remote contributions exposed by the application. The Client entry consumes the shared `TypeRTClientApi` contract through Cordis rather than importing the concrete Gateway implementation. +`@deepseek-ai/dsh-api-remotes`, located at `packages/api/remotes`, is the BFF layer above the Gateway. Its Host entry owns Agent/Session identity resolution and TypeRT lookup configuration; its `/client` entry selects the generated Remote contributions exposed by the application. The Client entry consumes the shared `TypeRTClientRemote` contract through Cordis rather than importing the concrete Gateway implementation. ## Components and Cordis services @@ -33,12 +33,12 @@ The Remote consumer projection contains `.d.ts`, `.d.ts.map`, and `.js` files. T | TypeRT generator/loader | No new business service | Generates three kinds of `lib` artifacts from the Host/Client Programs and registers the current environment's artifacts with `ctx.typert` | | API Gateway's Host face | `ctx.typertGateway` | Associates Host definitions with live Services, decodes parameters, resolves receivers, invokes methods, and encodes results | | Connection | `ctx.connection` | Exclusively owns the HTTP Server/future WebSocket, the shared `/api` route, RPC envelope, rpcId, serialization, trust, error transport, TypeRT interception, and legacy API Proxy fallback | -| API Gateway's Client face | `ctx.api` | Mounts Remote contributions, materializes root and scoped APIs, and delegates canonical calls to `ctx.connection.rpc` | +| API Gateway's Client face | `ctx.remote`, `ctx.remote.` | Mounts Remote contributions, materializes each namespace as a traced `remote.` child Service, and delegates canonical calls to `ctx.connection.rpc` | | API Remotes | No new service | Owns Host Agent/Session lookup policy and serves as the only Client business facade, selecting and mounting `/remote` contributions while exposing the selected API declarations | | Agent/Session owning packages | Existing domain services | Provide both static interface merges and runtime lookup/Context providers | | Business packages such as Goal | Existing business Services | Declare only bindings, Remote methods, and canonical DTOs, and export the generated `/remote` subpath | -The Host Gateway does not depend on concrete implementations of `ctx.agents`, `ctx.sessions`, `ctx.goals`, or `ctx.httpServer`. The Client API does not understand the physical carrier, and Connection does not understand Goal, Agent, lookup, `InvocationDescriptor`, or Client API namespaces. +The Host Gateway does not depend on concrete implementations of `ctx.agents`, `ctx.sessions`, `ctx.goals`, or `ctx.httpServer`. The Client Remote does not understand the physical carrier, and Connection does not understand Goal, Agent, lookup, `InvocationDescriptor`, or Remote namespaces. ## Business declarations @@ -121,7 +121,7 @@ The Client also registers an `agent` Context binder. The binder only retrieves a ## InvocationDescriptor -TypeRT, the permissive SRC parser, Host Gateway, and Client API exchange one canonical description: +TypeRT, the permissive SRC parser, Host Gateway, and Client Remote exchange one canonical description: ```text InvocationDescriptor { @@ -141,7 +141,7 @@ InvocationDescriptor { } ``` -`method` is the external short name used by the endpoint and Client API; `implementation` is the actual member name on the Host receiver. `implementation` may be omitted when the two names match. A `direct` descriptor retains the original Service instance as the receiver. A Context descriptor first uses the corresponding Context provider to find the scoped Context, then resolves the receiver by the descriptor's service key. +`method` is the external short name used by the endpoint and Client Remote; `implementation` is the actual member name on the Host receiver. `implementation` may be omitted when the two names match. A `direct` descriptor retains the original Service instance as the receiver. A Context descriptor first uses the corresponding Context provider to find the scoped Context, then resolves the receiver by the descriptor's service key. The strict generator writes `scope` only when a direct method has exactly one lookup parameter, a `TypeRTContextMap` declaration with the same name exists, and both use the same wire type symbol. `scope.wire` must identify that lookup parameter. It declares that a consumer may fill this parameter from the Context in which the call occurs, without changing the Host receiver or endpoint. No scoped projection is generated when there are multiple lookups, no Context declaration, or mismatched wire types; a type mismatch is a build error. @@ -179,9 +179,9 @@ import type { CreateGoalRequest, CreateGoalResult } from '@deepseek-ai/dsh-goal/ Consequently, `SessionId`, the Agent wire ID, the request, and the result all refer to the same TypeScript declaration in the Host and Browser Client. A future TUI can reuse them without a second set of types. Go to Definition, renames, and Find References for a DTO return to the one source location for the business type instead of stopping at a copy in a generated file. -Remote API methods themselves use declaration-map navigation. TypeRT anchors `InvocationModel.location` to the decorated Host method-name token and emits a source-map segment on the corresponding property of the namespace interface. For an adapter-backed endpoint, after the TypeScript editor resolves `ctx.api.models.list` to its generated declaration, `typert.remote-client.d.ts.map` takes it to the Host Service's `remoteExportList` entry point. That entry point explicitly calls the existing, unrenamed `list()` method; the map does not misidentify the decorator, class, or full signature as the method definition. +Remote methods themselves use declaration-map navigation. TypeRT anchors `InvocationModel.location` to the decorated Host method-name token and emits a source-map segment on the corresponding property of the namespace interface. For an adapter-backed endpoint, after the TypeScript editor resolves `ctx.remote.models.list` to its generated declaration, `typert.remote-client.d.ts.map` takes it to the Host Service's `remoteExportList` entry point. That entry point explicitly calls the existing, unrenamed `list()` method; the map does not misidentify the decorator, class, or full signature as the method definition. -TypeRT generates a wire Zod codec for the same symbol key. The Host Gateway uses it to validate input and encode results, while the Client API may use it to encode arguments and validate responses. If a complex type cannot produce a strict codec, the LIB build fails instead of degrading to `unknown` or unchecked JSON. +TypeRT generates a wire Zod codec for the same symbol key. The Host Gateway uses it to validate input and encode results, while the Client Remote uses it to encode arguments and validate responses. If a complex type cannot produce a strict codec, the LIB build fails instead of degrading to `unknown` or unchecked JSON. Named business types referenced by Remote methods must be exported from public, type-only subpaths. If the only reachable entry also imports Host Services, Cordis `Context` merges, or Host-only implementations, the build fails and requires the business package to provide a safe type entry. Primitives, literals, and simple compositions explicitly supported by TypeRT need no additional names. @@ -238,7 +238,7 @@ This import brings the `.d.ts` map augmentation into the current TypeScript proj The business package's published files must include both `lib/typert.remote-client.d.ts.map` and the `src` file referenced by that map. The generated DTS refers to its adjacent map with `//# sourceMappingURL=typert.remote-client.d.ts.map`; the map source points from `lib` to the business source by a relative path such as `../src/index.ts`. The `/remote` export does not list the map separately; the package `files` field publishes it together with the source. -Code that needs only static types may use `import type {} from '@deepseek-ai/dsh-goal/remote'`. This import is erased at runtime, loads no JS, and cannot trigger runtime registration. An environment that makes real calls must pass the contribution from a normal value import to the API Service. +Code that needs only static types may use `import type {} from '@deepseek-ai/dsh-goal/remote'`. This import is erased at runtime, loads no JS, and cannot trigger runtime registration. An environment that makes real calls must pass the contribution from a normal value import to the Client Remote Service. Workspace resolution for `/remote` must explicitly target generated `lib` artifacts and must not let a general package-to-`src` paths rule redirect it to Host source. Ordinary business imports may continue resolving to SRC or LIB according to each environment's existing rules. @@ -275,18 +275,18 @@ interface TypeRTRemoteContextMap { } ``` -`TypeRTRemoteMap` preserves canonical endpoint signatures for protocol typing and reflection. The root API type reads `TypeRTRemoteNamespaceMap` directly instead of deriving methods indirectly through a key-remapped mapped type; the TypeScript Language Service cannot reliably navigate such indirect properties through a declaration map. A namespace interface name encodes the namespace's UTF-8 bytes as hexadecimal, so `goals` deterministically becomes `TypeRTRemoteNamespace$676f616c73`. Different packages generate the same interface name for the same namespace and use module augmentation to merge their methods, while `TypeRTRemoteNamespaceMap.goals` always refers to that one type. +`TypeRTRemoteMap` preserves canonical endpoint signatures for protocol typing and reflection. The root Remote type reads `TypeRTRemoteNamespaceMap` directly instead of deriving methods indirectly through a key-remapped mapped type; the TypeScript Language Service cannot reliably navigate such indirect properties through a declaration map. A namespace interface name encodes the namespace's UTF-8 bytes as hexadecimal, so `goals` deterministically becomes `TypeRTRemoteNamespace$676f616c73`. Different packages generate the same interface name for the same namespace and use module augmentation to merge their methods, while `TypeRTRemoteNamespaceMap.goals` always refers to that one type. TypeRT projects `TypeRTRemoteContextMap` onto a dedicated Scope type according to its Context key. The final programming interface remains: ```text -api.goals.create(agentId, request) -agent.goals.create(request) +ctx.remote.goals.create(agentId, request) +agentCtx.remote.goals.create(request) ``` -The Agent Scope supplies its own `SessionId` automatically. A `@Remote` method with an `agent` lookup can therefore generate both root and scoped consumer signatures. A `@RemoteContext('agent')` method also omits a separate Context identity, but generates only the scoped signature. In this phase, only the Client Agent Context gains `goals`; the Root Context does not. A future TUI must preserve the same Scope restriction. +The Agent Scope supplies its own `SessionId` automatically. A `@Remote` method with an `agent` lookup can therefore generate both root and scoped consumer signatures. A `@RemoteContext('agent')` method also omits a separate Context identity, but generates only the scoped signature. The root `Context` exposes direct namespaces through `ctx.remote`, while `AgentContext.remote` intersects that direct surface with the scoped surface. A future TUI must preserve the same distinction. -`RemoteApi` remains platform-independent, and the Browser Client uses it as its `ClientApi`. If a future TUI reuses this type, it must likewise access it through a dedicated API object and Agent Scope rather than treating the Host `Context` as a broader Service collection. Public Service methods without Remote markers do not enter the Remote maps. +`TypeRTClientRemote` remains platform-independent, and the Browser Client exposes it as `ctx.remote`. If a future TUI reuses this type, it must likewise access it through a dedicated Remote object and Agent Scope rather than treating the Host `Context` as a broader Service collection. Public Service methods without Remote markers do not enter the Remote maps. ## Client TypeRT and the API Gateway Client face @@ -303,39 +303,39 @@ TypeRT.remotes 已导入的 Remote contribution import goalsRemote from '@deepseek-ai/dsh-goal/remote' import sessionsRemote from '@deepseek-ai/dsh-session/remote' -ctx.api.mount(goalsRemote) -ctx.api.mount(sessionsRemote) +await ctx.remote.$mount(goalsRemote) +await ctx.remote.$mount(sessionsRemote) ``` -Client business packages depend only on `@deepseek-ai/dsh-api-remotes/client`, not directly on the API Gateway or the runtime entry of each business `/remote`. API Remotes consumes the shared `TypeRTClientApi` contract and Cordis `ctx.api` service, then re-exports declarations so the selected Remote map reaches business compilation. Adding or removing a complete Client capability changes only this assembly point. +Client business packages depend only on `@deepseek-ai/dsh-api-remotes/client`, not directly on the API Gateway or the runtime entry of each business `/remote`. API Remotes consumes the shared `TypeRTClientRemote` contract and Cordis `ctx.remote` service, then re-exports declarations so the selected Remote map reaches business compilation. Adding or removing a complete Client capability changes only this assembly point. -`ctx.api.mount()` registers a contribution with `TypeRT.remotes`, and its disposer is owned by the Cordis fiber that called the method. Duplicate endpoints, conflicting invocation modes for the same namespace and method, or conflicts between a descriptor and an existing type identity fail immediately. +`ctx.remote.$mount()` registers a contribution with `TypeRT.remotes`, installs its namespace Services and concrete methods, and resolves only after they are ready. Its disposer is owned by the Cordis fiber that called the method. Duplicate endpoints, conflicting invocation modes for the same namespace and method, or conflicts between a descriptor and an existing type identity fail immediately. -The API Service materializes each `@Remote` descriptor as a real function on the root `api`. The function constructs named `args` in descriptor parameter order, applies the Client's strict codec, and then calls `ctx.connection.rpc.call('/api', endpoint, { args }, signal)`. For a cancellation-aware descriptor, the generated function accepts a final optional signal and combines it with the contribution mount lifetime; unmounting therefore cancels every in-flight carrier call, while a caller can cancel one call independently. +The Client Remote Service materializes each `@Remote` descriptor as a real function on a `remote.` child Service. The function constructs named `args` in descriptor parameter order, applies the Client's strict codec, and then calls `ctx.connection.rpc.call('/api', endpoint, { args }, signal)`. For a cancellation-aware descriptor, the generated function accepts a final optional signal and combines it with the contribution mount lifetime; unmounting therefore cancels every in-flight carrier call, while a caller can cancel one call independently. -Neither a direct descriptor with `scope` nor a `@RemoteContext` descriptor copies functions into every Agent Scope. The API Service creates one root singleton Cordis Service for each scoped namespace and materializes methods on that Service. When `agent.goals.create()` is called, the Cordis tracker rebinds the Service's `this.ctx` to the current Agent Context. The method then asks the corresponding Context binder for identity from `this.ctx`. A direct scoped projection substitutes this identity at the lookup position named by `scope.wire`; a Context descriptor writes the identity into the receiver's separate wire field. Both issue the same kind of `/api` call. +Neither a direct descriptor with `scope` nor a `@RemoteContext` descriptor copies functions into every Agent Scope. The Client Remote Service creates one Cordis child Service per namespace, registered as `remote.`, and materializes direct and scoped variants on it. Accessing a method through `agentCtx.remote.goals` captures the current Agent Context before returning the callable handle. The method then asks the corresponding Context binder for identity from that Context. A direct scoped projection substitutes this identity at the lookup position named by `scope.wire`; a Context descriptor writes the identity into the receiver's separate wire field. Both issue the same kind of `/api` call. ```text -root ctx.api.goals.create(agentId, request) +root ctx.remote.goals.create(agentId, request) → direct descriptor → ctx.connection.rpc.call('/api', 'goals/create', { args }) -agent.goals.create(request) - → tracker 将 namespace Service rebind 到 agent Context +agentCtx.remote.goals.create(request) + → remote.goals accessor 捕获 agent Context → agent binder 从 caller Context 取得 agentId → 用 agentId 补入同一 direct descriptor 的 lookup 参数 → ctx.connection.rpc.call('/api', 'goals/create', { args }) ``` -The Root `Context` does not merge the scoped `goals` type; only `AgentContext` gains that property through `RemoteContextApi<'agent'>`. If a caller bypasses the type system and dynamically calls a scoped method from Root, the binder reports an explicit error. If the Client already has a Cordis service with the same name, or two contributions claim the same namespace and method incompatibly, mounting fails instead of overwriting the existing service. +The root `Context` merges only the direct `TypeRTClientRemote` surface. `AgentContext` replaces that property with the intersection of `TypeRTClientRemote` and `TypeRTRemoteContextApi<'agent'>`, so scoped-only methods remain unavailable from root code. If a caller bypasses the type system and dynamically calls a scoped-only method from Root, the binder reports an explicit error. If the Client already has a Cordis service named `remote.`, or two contributions claim the same namespace and method incompatibly, mounting fails instead of overwriting the existing service. -Generated Remote JS contains only descriptors, symbol keys, and codecs; it does not bundle Host Service implementations. The API Service creates real functions from that data, so the runtime does not depend on a JavaScript Proxy. A Proxy remains an implementation option but is not a source of types or reflection. +Generated Remote JS contains only descriptors, symbol keys, and codecs; it does not bundle Host Service implementations. The Client Remote Service creates real functions from that data, so the runtime does not depend on a JavaScript Proxy. A Proxy remains an implementation option but is not a source of types or reflection. ## Cross-environment isomorphism constraints Remote API is a consumer capability, not a synonym for Browser API. The shipped runtime implements Browser Client contribution mounting, Connection RPC calls, and Agent Scope association. -Remote DTS, Remote JS, `RemoteApi`, `InvocationDescriptor`, the Remote RPC data protocol, and Context binders must not depend on the DOM, Browser module loaders, or HTTP. Through Connection, the Browser Client encodes descriptor-materialized methods as `/api` RPC calls. +Remote DTS, Remote JS, `TypeRTClientRemote`, `InvocationDescriptor`, the Remote RPC data protocol, and Context binders must not depend on the DOM, Browser module loaders, or HTTP. Through Connection, the Browser Client encodes descriptor-materialized methods as `/api` RPC calls. A future TUI can join the same call abstraction without changing business decorators, Remote maps, or the shape of API calls. The TUI-visible API must still be generated exclusively from `@Remote` and `@RemoteContext`; sharing a process with the Host must not allow it to bypass Remote restrictions and expose Service methods directly. @@ -421,7 +421,7 @@ The Remote payload is a named JSON object, not a positional array, and does not The complete path is: ```text -ctx.api.goals.create(sessionId, request, signal?) +ctx.remote.goals.create(sessionId, request, signal?) → Client InvocationDescriptor 编码 { args: { agentId, request } } → Client 合并 caller signal 与 contribution mount lifetime → ctx.connection.rpc.call('/api', 'goals/create', { args }, signal) @@ -442,7 +442,7 @@ The Gateway does not handle per-method permissions, caller identity, idempotency ## Connection and protocol boundaries -The API Service owns Remote contributions, method materialization, Scope binding, and the correspondence between positional parameters and descriptors. The Gateway owns Host descriptors, endpoint ownership, lookup, Context, and business invocation. Connection sends `/api`, the endpoint, and `{ args }` as one RPC call to the target and returns the existing RPC result; it does not understand Goal, Agent, lookup, descriptors, or Client API types. +The Client Remote Service owns Remote contributions, namespace Service materialization, Scope binding, and the correspondence between positional parameters and descriptors. The Gateway owns Host descriptors, endpoint ownership, lookup, Context, and business invocation. Connection sends `/api`, the endpoint, and `{ args }` as one RPC call to the target and returns the existing RPC result; it does not understand Goal, Agent, lookup, descriptors, or Client Remote types. The Gateway registers only its ownership matcher and RPC handler with Connection; it does not register an HTTP route. Connection mounts the shared `/api` route into the HTTP Server and gives the bridge one composite FetchHandler; that handler dispatches claimed endpoints to Gateway and unclaimed endpoints to API Proxy. A future Connection transport can preserve this order without changing the Remote payload, business decorators, generated DTS, Remote API types, or Agent Scope programming interface. @@ -451,8 +451,8 @@ The Gateway registers only its ownership matcher and RPC handler with Connection - `@deepseek-ai/dsh-type-meta`: lightweight protocols for decorators, bindings, lookup, Remote Context, and descriptors. - TypeRT generator: analyzes Host/Client Programs, generates local faces and Remote consumer projections, and emits canonical symbol/Zod information. - TypeRT runtime: separately stores the current environment's local reflection and imported Remote contributions. -- `@deepseek-ai/dsh-api-gateway`: its default entry associates Host definitions with Services, claims Remote endpoints, performs lookup, resolves Context receivers, invokes methods, encodes results, and registers an `/api` interceptor with Connection; its `/client` entry mounts Remote contributions, creates strict API methods, and delegates calls to `ctx.connection.rpc`. The entries share the Remote protocol but do not import each other's Cordis interface merges. -- `@deepseek-ai/dsh-api-remotes`: the BFF layer; owns the Host Agent/Session resolver, selects Client `/remote` contributions, and exposes the merged API types to business packages through the shared `TypeRTClientApi` contract. +- `@deepseek-ai/dsh-api-gateway`: its default entry associates Host definitions with Services, claims Remote endpoints, performs lookup, resolves Context receivers, invokes methods, encodes results, and registers an `/api` interceptor with Connection; its `/client` entry mounts Remote contributions, creates strict Remote namespace Services and methods, and delegates calls to `ctx.connection.rpc`. The entries share the Remote protocol but do not import each other's Cordis interface merges. +- `@deepseek-ai/dsh-api-remotes`: the BFF layer; owns the Host Agent/Session resolver, selects Client `/remote` contributions, and exposes the merged Remote types to business packages through the shared `TypeRTClientRemote` contract. - Connection: owns the single HTTP Server/future WebSocket carrier, shared `/api` route and composite FetchHandler, API Proxy fallback, RPC envelope, rpcId, serialization, trust, and error transport. - Business-object packages such as Agent/Session: own lookup, Context providers, canonical ID types, and public type-only entries. - API Proxy Host composition: supplies Web Agent defaults and scope setup to API Remotes and consumes the same `agentFor()` for legacy methods. @@ -460,7 +460,7 @@ The Gateway registers only its ownership matcher and RPC handler with Connection ## Shipped scope and deferred work -The shipped vertical path is `@deepseek-ai/dsh-goal/remote → Browser Client API → Connection RPC /api → Host Gateway → GoalService.remoteExportCreate()`. The same direct descriptor with an Agent lookup supports both `ctx.api.goals.create(agentId, request)` and `agentCtx.goals.create(request)`. Ordinary cold sessions are resumed through `agentFor()` during lookup, while subagent-owned identities retain the existing `agent-busy` fence; `@RemoteContext('agent')` remains the distinct scoped-receiver mode. +The shipped vertical path is `@deepseek-ai/dsh-goal/remote → Browser Client Remote → Connection RPC /api → Host Gateway → GoalService.remoteExportCreate()`. The same direct descriptor with an Agent lookup supports both `ctx.remote.goals.create(agentId, request)` and `agentCtx.remote.goals.create(request)`. Ordinary cold sessions are resumed through `agentFor()` during lookup, while subagent-owned identities retain the existing `agent-busy` fence; `@RemoteContext('agent')` remains the distinct scoped-receiver mode. Connection supplies the shared-channel interceptor and current HTTP carrier mapping. WebSocket migration, the TUI runtime and carrier, TUI Agent Scope wiring, Permission/Approval state machines, Session event streams, call authorization, retries, idempotency, and cross-version protocol compatibility remain outside this decision. @@ -482,7 +482,7 @@ The package topology is `api/remotes → api/gateway → client/connection → h **Generate only Remote DTS, without JS.** Types would work, but the runtime could not enumerate endpoints, codecs, and Context modes without a Proxy or another hand-written registry. The same Host projection therefore emits a Remote JS contribution as well. -**Let a top-level `/remote` import register global state implicitly.** The target Cordis Context may not exist when ESM evaluation occurs, and ownership becomes ambiguous across multiple Contexts, HMR, and disposal. A normal value import therefore returns only a contribution, which the environment assembly explicitly mounts through the API Service. +**Let a top-level `/remote` import register global state implicitly.** The target Cordis Context may not exist when ESM evaluation occurs, and ownership becomes ambiguous across multiple Contexts, HMR, and disposal. A normal value import therefore returns only a contribution, which the environment assembly explicitly mounts through the Client Remote Service. **Create a separate transport, HTTP route, or `/api2` channel for Remote.** This would duplicate or split Connection's Server ownership, rpcId, serialization, trust, errors, and future WebSocket lifecycle. The shared `/api` interceptor instead keeps one physical route and lets Connection preserve API Proxy as the fallback FetchHandler. @@ -490,7 +490,7 @@ The package topology is `api/remotes → api/gateway → client/connection → h - Goal Service directly decorates mutation methods whose business signatures already match the Remote contract and keeps `remoteExportCreate(...)` only to adapt `GoalView` into `CreateGoalResult`, without a second route, codec, or Client method list. - A clean `build:lib` emits Host and consumer Remote artifacts before Client compilation, including the business package's JS, DTS, and declaration map under `/remote`. -- Importing `@deepseek-ai/dsh-goal/remote` adds the strict `api.goals.create(...)` type and declaration navigation to `remoteExportCreate`; omitting that import omits the namespace. +- Importing `@deepseek-ai/dsh-goal/remote` adds the strict `ctx.remote.goals.create(...)` type and declaration navigation to `remoteExportCreate`; omitting that import omits the namespace. - Mounting the same import's JS contribution supplies endpoint, parameter, result, lookup, Context, and Zod reflection and materializes the call without a handwritten stub. - Root and Agent-scoped calls cross the real shared `/api` carrier, resolve `agentId` to the live Agent, invoke the original Goal receiver, and return through the existing RPC envelope. - Agent and Session lookups share a single in-flight cold-session resume; ordinary cold sessions receive restored objects, while both cold and live subagent identities return `agent-busy` before business invocation. @@ -509,13 +509,13 @@ The permissive SRC descriptor does not validate the internal structure of ordina Canonical public types require business DTOs to have type-only entries, which may expose packages whose Host types and implementation entries are currently mixed. The build rejects those boundaries instead of copying types to conceal them. -Type imports and runtime contributions have different effects. `import type {}` extends only the static API. If a real calling environment omits the value contribution, the API Service must fail with an explicit "Remote not mounted" error. +Type imports and runtime contributions have different effects. `import type {}` extends only the static Remote surface. If a real calling environment omits the value contribution, the Client Remote Service must fail with an explicit "Remote not mounted" error. Browser and Host each hold their own Zod instances and cannot compare object identities across realms. Consistency is guaranteed only by canonical symbol keys, the same generated model, and wire behavior. A consumer may import a Remote contract that is not currently mounted on the Host. The types mean "this protocol capability was selected by the consumer," not that a corresponding Service currently exists in the target process; an unavailable endpoint must fail explicitly at runtime. -Connection's general channel API must suit both the current HTTP carrier and a future WebSocket carrier. If the API exposes `fetch`, an HTTP request, or a route handle to the Gateway/API Service, WebSocket migration will pierce the Remote layer again. Those physical objects must therefore remain internal to Connection. +Connection's general channel API must suit both the current HTTP carrier and a future WebSocket carrier. If the Client Remote or Gateway exposes `fetch`, an HTTP request, or a route handle, WebSocket migration will pierce the Remote layer again. Those physical objects must therefore remain internal to Connection. Remote endpoints use Connection's `trusted-host` authority. Loopback is accepted by default and LAN callers require an explicit trusted-host configuration, but this layer adds no per-method caller authorization; every trusted host can invoke a mounted Remote endpoint. diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md index 3d6ec680ba..f1b7e5f9c6 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md @@ -18,11 +18,11 @@ Host 与 Browser Client 使用独立的 TypeScript Program,因为两边会以 业务 Service 继承 `GatewayService`,并通过 `@Remote` 或 `@RemoteContext()` 声明可调用方法;已有其他基类的 Service 可以改用 `bindTypeRTGateway()` 暴露同一绑定。TypeRT 从 Host Program 生成 Host 本地反射产物和平台无关的 Remote 消费端投影;Client Program 继续独立生成自己的本地反射产物。 -Remote 消费端投影同时包含 `.d.ts`、`.d.ts.map` 和 `.js`。`.d.ts` 只暴露被 Remote decorator 标记的方法,并引用业务包唯一的公共类型符号;`.d.ts.map` 把消费端 API 方法导航回 Host 业务方法实现;`.js` 携带同一契约的 endpoint、参数、Context 和 Zod 信息。Browser Client 在 assembly 层把需要的 Remote JS 贡献集中挂到 Client API Service;该投影和 API 抽象保持平台无关,以便未来 TUI 复用。 +Remote 消费端投影同时包含 `.d.ts`、`.d.ts.map` 和 `.js`。`.d.ts` 只暴露被 Remote decorator 标记的方法,并引用业务包唯一的公共类型符号;`.d.ts.map` 把消费端 API 方法导航回 Host 业务方法实现;`.js` 携带同一契约的 endpoint、参数、Context 和 Zod 信息。Browser Client 在 assembly 层把需要的 Remote JS 贡献集中挂到 Client Remote Service;该投影和 Remote 抽象保持平台无关,以便未来 TUI 复用。 -`@deepseek-ai/dsh-api-gateway` 位于 `packages/api/gateway`,提供对称的两个 face:默认入口提供 Host `ctx.typertGateway`,`/client` 入口提供消费端 `ctx.api`。两边各自在本地消费由同一模型生成的 `InvocationDescriptor`,descriptor 不通过 wire 发送。Remote 数据协议运行在 Connection 共享的 `/api` RPC channel 上;业务调用界面不随 Connection 从 HTTP 迁移到 WebSocket 而改变。 +`@deepseek-ai/dsh-api-gateway` 位于 `packages/api/gateway`,提供对称的两个 face:默认入口提供 Host `ctx.typertGateway`,`/client` 入口提供消费端 `ctx.remote`。两边各自在本地消费由同一模型生成的 `InvocationDescriptor`,descriptor 不通过 wire 发送。Remote 数据协议运行在 Connection 共享的 `/api` RPC channel 上;业务调用界面不随 Connection 从 HTTP 迁移到 WebSocket 而改变。 -`@deepseek-ai/dsh-api-remotes` 位于 `packages/api/remotes`,是 Gateway 上层的 BFF 层。其 Host 入口负责 Agent/Session 身份解析与 TypeRT lookup 配置;`/client` 入口选择应用对外暴露的生成 Remote contribution。Client 入口通过 Cordis 消费共享的 `TypeRTClientApi` 契约,而不导入具体 Gateway 实现。 +`@deepseek-ai/dsh-api-remotes` 位于 `packages/api/remotes`,是 Gateway 上层的 BFF 层。其 Host 入口负责 Agent/Session 身份解析与 TypeRT lookup 配置;`/client` 入口选择应用对外暴露的生成 Remote contribution。Client 入口通过 Cordis 消费共享的 `TypeRTClientRemote` 契约,而不导入具体 Gateway 实现。 ## 组件和 Cordis 服务 @@ -33,12 +33,12 @@ Remote 消费端投影同时包含 `.d.ts`、`.d.ts.map` 和 `.js`。`.d.ts` 只 | TypeRT generator/loader | 无新增业务服务 | 从 Host/Client Program 生成三类 `lib` 产物,并把当前环境产物注册到 `ctx.typert` | | API Gateway 的 Host face | `ctx.typertGateway` | 关联 Host definition 与活 Service,解码参数、解析 receiver、调用方法和编码结果 | | Connection | `ctx.connection` | 独占 HTTP Server/未来 WebSocket、共享 `/api` route、RPC envelope、rpcId、序列化、trust、错误传输、TypeRT 拦截和旧 API Proxy 回退 | -| API Gateway 的 Client face | `ctx.api` | mount Remote contribution,实体化根 API 和 scoped API,把规范调用交给 `ctx.connection.rpc` | +| API Gateway 的 Client face | `ctx.remote`、`ctx.remote.` | mount Remote contribution,把每个 namespace 实体化为可追踪的 `remote.` 子 Service,并把规范调用交给 `ctx.connection.rpc` | | API Remotes | 无新增服务 | 负责 Host Agent/Session lookup 策略,并作为 Client 业务的唯一 facade,选择并挂载 `/remote` contribution,同时暴露所选 API 声明 | | Agent/Session owning 包 | 既有领域服务 | 同时提供静态 interface merge 与运行时 lookup/Context provider | | Goal 等业务包 | 既有业务 Service | 只声明 binding、Remote 方法和唯一 DTO,并导出生成的 `/remote` 子路径 | -Host Gateway 不依赖 `ctx.agents`、`ctx.sessions`、`ctx.goals` 或 `ctx.httpServer` 的具体实现。Client API 不理解物理 carrier,Connection 也不理解 Goal、Agent、lookup、`InvocationDescriptor` 或 Client API namespace。 +Host Gateway 不依赖 `ctx.agents`、`ctx.sessions`、`ctx.goals` 或 `ctx.httpServer` 的具体实现。Client Remote 不理解物理 carrier,Connection 也不理解 Goal、Agent、lookup、`InvocationDescriptor` 或 Remote namespace。 ## 业务声明 @@ -121,7 +121,7 @@ Client 侧也注册 `agent` Context binder。binder 只负责从一次调用所 ## InvocationDescriptor -TypeRT、SRC 弱解析器、Host Gateway 和 Client API 之间只交换一种规范描述: +TypeRT、SRC 弱解析器、Host Gateway 和 Client Remote 之间只交换一种规范描述: ```text InvocationDescriptor { @@ -141,7 +141,7 @@ InvocationDescriptor { } ``` -`method` 是 endpoint 和 Client API 使用的外部短名,`implementation` 是 Host receiver 上的真实成员名;两者相同时可省略 `implementation`。`direct` descriptor 保留原始 Service 实例作为 receiver。Context descriptor 先通过对应 Context provider 找到 scoped Context,再以 descriptor 的 service key 解析 receiver。 +`method` 是 endpoint 和 Client Remote 使用的外部短名,`implementation` 是 Host receiver 上的真实成员名;两者相同时可省略 `implementation`。`direct` descriptor 保留原始 Service 实例作为 receiver。Context descriptor 先通过对应 Context provider 找到 scoped Context,再以 descriptor 的 service key 解析 receiver。 严格生成器只在 direct 方法恰好包含一个 lookup 参数、同名 `TypeRTContextMap` 声明存在且两者使用同一 wire 类型 symbol 时写入 `scope`。`scope.wire` 必须指向该 lookup 参数;它声明消费端可以从调用所在 Context 补入这个参数,不改变 Host receiver 或 endpoint。多个 lookup、缺少 Context 声明或 wire 类型不一致时不生成 scoped 投影,其中类型不一致属于构建错误。 @@ -179,9 +179,9 @@ import type { CreateGoalRequest, CreateGoalResult } from '@deepseek-ai/dsh-goal/ 因此 `SessionId`、Agent wire ID、request 和 result 在 Host 与 Browser Client 中都指向同一 TypeScript declaration,未来 TUI 复用时也不需要第二份类型。DTO 的跳转定义、重命名和引用查找回到业务类型的唯一源码位置,而不是停在生成文件中的副本。 -Remote API 方法本身使用 declaration map 导航。TypeRT 把 `InvocationModel.location` 固定在 Host 被装饰方法的方法名 token,并在 namespace interface 的对应属性上写入 source-map segment。对于由适配器支撑的 endpoint,TypeScript editor 从 `ctx.api.models.list` 取得生成 declaration 后,再沿 `typert.remote-client.d.ts.map` 跳到 Host Service 的 `remoteExportList` 远程出口。该出口继续显式调用不改名的存量 `list()`,map 不把 decorator、class 或整个签名误当成方法定义位置。 +Remote 方法本身使用 declaration map 导航。TypeRT 把 `InvocationModel.location` 固定在 Host 被装饰方法的方法名 token,并在 namespace interface 的对应属性上写入 source-map segment。对于由适配器支撑的 endpoint,TypeScript editor 从 `ctx.remote.models.list` 取得生成 declaration 后,再沿 `typert.remote-client.d.ts.map` 跳到 Host Service 的 `remoteExportList` 远程出口。该出口继续显式调用不改名的存量 `list()`,map 不把 decorator、class 或整个签名误当成方法定义位置。 -TypeRT 为同一 symbol key 生成 wire Zod codec。Host Gateway 用它校验输入和编码结果,Client API 可以用它编码参数并校验响应;复杂类型无法生成严格 codec 时,LIB 构建失败,不降级为 `unknown` 或无校验 JSON。 +TypeRT 为同一 symbol key 生成 wire Zod codec。Host Gateway 用它校验输入和编码结果,Client Remote 用它编码参数并校验响应;复杂类型无法生成严格 codec 时,LIB 构建失败,不降级为 `unknown` 或无校验 JSON。 Remote 方法引用的命名业务类型必须从纯类型公共 subpath 导出。如果唯一可达入口会带入 Host Service、Cordis `Context` merge 或 Host-only 实现,构建失败并要求业务包提供安全的类型出口。原始值、字面量和 TypeRT 明确支持的简单组合不需要额外命名。 @@ -238,7 +238,7 @@ import goalsRemote from '@deepseek-ai/dsh-goal/remote' 业务 package 的发布文件必须同时包含 `lib/typert.remote-client.d.ts.map` 和 map 指向的 `src` 文件。生成 DTS 以 `//# sourceMappingURL=typert.remote-client.d.ts.map` 引用相邻 map;map 中的 source 从 `lib` 相对指向业务源码,例如 `../src/index.ts`。`/remote` export 不单独列出 map,package `files` 负责把它与源码一起发布。 -仅需要静态类型时可以使用 `import type {} from '@deepseek-ai/dsh-goal/remote'`;这种 import 在运行时会被擦除,不会加载 JS,也不能触发任何运行时注册。需要真实调用的环境必须把普通 value import 得到的 contribution 交给 API Service。 +仅需要静态类型时可以使用 `import type {} from '@deepseek-ai/dsh-goal/remote'`;这种 import 在运行时会被擦除,不会加载 JS,也不能触发任何运行时注册。需要真实调用的环境必须把普通 value import 得到的 contribution 交给 Client Remote Service。 workspace 对 `/remote` 的解析必须明确指向 `lib` 生成物,不能被通用 package-to-`src` paths 规则带回 Host 源码。普通业务 import 仍可按各环境既有规则解析到 SRC 或 LIB。 @@ -275,18 +275,18 @@ interface TypeRTRemoteContextMap { } ``` -`TypeRTRemoteMap` 保留规范 endpoint 签名,供协议类型和反射使用。根 API 类型直接读取 `TypeRTRemoteNamespaceMap`,不通过 key-remapped mapped type 间接推导方法;TypeScript Language Service 无法把这种间接属性稳定导航到 declaration map。namespace interface 名由 namespace 的 UTF-8 bytes 编成 hex,`goals` 因而稳定得到 `TypeRTRemoteNamespace$676f616c73`。不同 package 对同一 namespace 生成同名 interface,依靠 module augmentation 合并各自方法,且 `TypeRTRemoteNamespaceMap.goals` 始终引用同一类型。 +`TypeRTRemoteMap` 保留规范 endpoint 签名,供协议类型和反射使用。根 Remote 类型直接读取 `TypeRTRemoteNamespaceMap`,不通过 key-remapped mapped type 间接推导方法;TypeScript Language Service 无法把这种间接属性稳定导航到 declaration map。namespace interface 名由 namespace 的 UTF-8 bytes 编成 hex,`goals` 因而稳定得到 `TypeRTRemoteNamespace$676f616c73`。不同 package 对同一 namespace 生成同名 interface,依靠 module augmentation 合并各自方法,且 `TypeRTRemoteNamespaceMap.goals` 始终引用同一类型。 TypeRT 把 `TypeRTRemoteContextMap` 按 Context key 投影到专用 Scope 类型。最终编程界面保持: ```text -api.goals.create(agentId, request) -agent.goals.create(request) +ctx.remote.goals.create(agentId, request) +agentCtx.remote.goals.create(request) ``` -Agent Scope 自动提供自己的 `SessionId`。因此带 `agent` lookup 的 `@Remote` 方法可以同时生成 root 和 scoped 两种消费端签名;`@RemoteContext('agent')` 方法也省略独立的 Context identity,但只生成 scoped 签名。本期只有 Client Agent Context 获得 `goals`,Root Context 不获得该属性;未来 TUI 复用时必须维持相同的 Scope 限制。 +Agent Scope 自动提供自己的 `SessionId`。因此带 `agent` lookup 的 `@Remote` 方法可以同时生成 root 和 scoped 两种消费端签名;`@RemoteContext('agent')` 方法也省略独立的 Context identity,但只生成 scoped 签名。根 `Context` 通过 `ctx.remote` 暴露 direct namespace,`AgentContext.remote` 则把该 direct surface 与 scoped surface 取交集。未来 TUI 复用时必须维持相同区分。 -`RemoteApi` 保持平台无关,Browser Client 把它作为自己的 `ClientApi`。未来 TUI 若复用该类型,也必须通过专用 API 对象和 Agent Scope 使用它,不能把 Host `Context` 当成更宽的 Service 集合;未标记的 public Service 方法不会进入 Remote maps。 +`TypeRTClientRemote` 保持平台无关,Browser Client 通过 `ctx.remote` 暴露它。未来 TUI 若复用该类型,也必须通过专用 Remote 对象和 Agent Scope 使用它,不能把 Host `Context` 当成更宽的 Service 集合;未标记的 public Service 方法不会进入 Remote maps。 ## Client TypeRT 与 API Gateway Client face @@ -303,39 +303,39 @@ TypeRT.remotes 已导入的 Remote contribution import goalsRemote from '@deepseek-ai/dsh-goal/remote' import sessionsRemote from '@deepseek-ai/dsh-session/remote' -ctx.api.mount(goalsRemote) -ctx.api.mount(sessionsRemote) +await ctx.remote.$mount(goalsRemote) +await ctx.remote.$mount(sessionsRemote) ``` -Client 业务包只引用 `@deepseek-ai/dsh-api-remotes/client`,不直接依赖 API Gateway 或各业务 `/remote` 运行时入口。API Remotes 消费共享的 `TypeRTClientApi` 契约和 Cordis `ctx.api` 服务,再重新导出声明,使所选 Remote map 进入业务编译;新增或移除整套 Client 能力只修改这一处 assembly。 +Client 业务包只引用 `@deepseek-ai/dsh-api-remotes/client`,不直接依赖 API Gateway 或各业务 `/remote` 运行时入口。API Remotes 消费共享的 `TypeRTClientRemote` 契约和 Cordis `ctx.remote` 服务,再重新导出声明,使所选 Remote map 进入业务编译;新增或移除整套 Client 能力只修改这一处 assembly。 -`ctx.api.mount()` 把 contribution 注册到 `TypeRT.remotes`,并由调用该方法的 Cordis fiber 持有 disposer。endpoint 重复、同一 namespace/method 模式冲突或 descriptor 与现有类型身份冲突时直接失败。 +`ctx.remote.$mount()` 把 contribution 注册到 `TypeRT.remotes`,安装它的 namespace Service 和具体方法,并在它们就绪后才 resolve。调用该方法的 Cordis fiber 持有 disposer。endpoint 重复、同一 namespace/method 模式冲突或 descriptor 与现有类型身份冲突时直接失败。 -API Service 把 `@Remote` descriptor 实体化为根 `api` 上的真实函数。函数按 descriptor 的位置参数顺序构造具名 `args`,执行 Client strict codec,然后调用 `ctx.connection.rpc.call('/api', endpoint, { args }, signal)`。对于支持取消的 descriptor,生成的函数接受最后一个可选 signal,并将其与 contribution 的挂载生命周期合并;因此卸载会取消所有正在进行的 carrier 调用,而调用方也可以单独取消一次调用。 +Client Remote Service 把 `@Remote` descriptor 实体化为 `remote.` 子 Service 上的真实函数。函数按 descriptor 的位置参数顺序构造具名 `args`,执行 Client strict codec,然后调用 `ctx.connection.rpc.call('/api', endpoint, { args }, signal)`。对于支持取消的 descriptor,生成的函数接受最后一个可选 signal,并将其与 contribution 的挂载生命周期合并;因此卸载会取消所有正在进行的 carrier 调用,而调用方也可以单独取消一次调用。 -带 `scope` 的 direct descriptor 和 `@RemoteContext` descriptor 都不为每个 Agent Scope 复制函数。API Service 为每个 scoped namespace 建立一个 root singleton Cordis Service,并在该 Service 上实体化方法;Cordis tracker 在 `agent.goals.create()` 调用时把 Service 的 `this.ctx` rebind 到当前 Agent Context。方法再通过对应 Context binder 从 `this.ctx` 取得 identity。direct scoped 投影用 identity 替代 `scope.wire` 指定的 lookup 位置,Context descriptor 则把 identity 写入 receiver 的独立 wire 字段;两者都发起同一种 `/api` 调用。 +带 `scope` 的 direct descriptor 和 `@RemoteContext` descriptor 都不为每个 Agent Scope 复制函数。Client Remote Service 为每个 namespace 创建一个注册为 `remote.` 的 Cordis 子 Service,并在其上实体化 direct 与 scoped 变体。通过 `agentCtx.remote.goals` 取得方法时,accessor 会在返回可调用句柄前捕获当前 Agent Context。方法再通过对应 Context binder 从该 Context 取得 identity。direct scoped 投影用 identity 替代 `scope.wire` 指定的 lookup 位置,Context descriptor 则把 identity 写入 receiver 的独立 wire 字段;两者都发起同一种 `/api` 调用。 ```text -root ctx.api.goals.create(agentId, request) +root ctx.remote.goals.create(agentId, request) → direct descriptor → ctx.connection.rpc.call('/api', 'goals/create', { args }) -agent.goals.create(request) - → tracker 将 namespace Service rebind 到 agent Context +agentCtx.remote.goals.create(request) + → remote.goals accessor 捕获 agent Context → agent binder 从 caller Context 取得 agentId → 用 agentId 补入同一 direct descriptor 的 lookup 参数 → ctx.connection.rpc.call('/api', 'goals/create', { args }) ``` -Root `Context` 不 merge scoped `goals` 类型;只有 `AgentContext` 通过 `RemoteContextApi<'agent'>` 获得该属性。若调用方绕过类型从 Root 动态调用 scoped 方法,binder 明确报错。若 Client 已有同名 Cordis service,或两个 contribution 冲突占用同一 namespace/method,mount 直接失败,不覆盖现有服务。 +根 `Context` 只 merge direct `TypeRTClientRemote` surface;`AgentContext` 把该属性替换为 `TypeRTClientRemote` 与 `TypeRTRemoteContextApi<'agent'>` 的交叉,因而 scoped-only 方法不会暴露给 root 代码。若调用方绕过类型从 Root 动态调用 scoped-only 方法,binder 明确报错。若 Client 已有名为 `remote.` 的 Cordis service,或两个 contribution 冲突占用同一 namespace/method,mount 直接失败,不覆盖现有服务。 -生成的 Remote JS 只包含 descriptor、symbol key 和 codec,不打包 Host Service 实现。API Service 据此创建真实函数,因此运行时不依赖 JavaScript Proxy;Proxy 可以作为实现选择,但不会成为类型或反射来源。 +生成的 Remote JS 只包含 descriptor、symbol key 和 codec,不打包 Host Service 实现。Client Remote Service 据此创建真实函数,因此运行时不依赖 JavaScript Proxy;Proxy 可以作为实现选择,但不会成为类型或反射来源。 ## 跨环境同构约束 Remote API 是消费端能力,不等同于 Browser API。已交付的运行时实现 Browser Client contribution 挂载、Connection RPC 调用和 Agent Scope 关联。 -Remote DTS、Remote JS、`RemoteApi`、`InvocationDescriptor`、Remote RPC 数据协议和 Context binder 不得依赖 DOM、Browser module loader 或 HTTP。Browser Client 通过 Connection 把 descriptor 实体化的方法编码为 `/api` RPC 调用。 +Remote DTS、Remote JS、`TypeRTClientRemote`、`InvocationDescriptor`、Remote RPC 数据协议和 Context binder 不得依赖 DOM、Browser module loader 或 HTTP。Browser Client 通过 Connection 把 descriptor 实体化的方法编码为 `/api` RPC 调用。 未来 TUI 可以在不改变业务 decorator、Remote maps 和 API 调用形状的前提下接入同一调用抽象。届时 TUI 可见的 API 仍只能由 `@Remote` 和 `@RemoteContext` 生成,不能因为它与 Host 同进程就绕过 Remote 限制直接暴露 Service 方法。 @@ -421,7 +421,7 @@ Remote payload 使用具名 JSON 对象,不使用位置数组,也不发送 ` 完整链路为: ```text -ctx.api.goals.create(sessionId, request, signal?) +ctx.remote.goals.create(sessionId, request, signal?) → Client InvocationDescriptor 编码 { args: { agentId, request } } → Client 合并 caller signal 与 contribution mount lifetime → ctx.connection.rpc.call('/api', 'goals/create', { args }, signal) @@ -442,7 +442,7 @@ Gateway 不处理逐方法权限、调用者身份、幂等或长连接状态。 ## Connection 与协议边界 -API Service 负责 Remote contribution、方法实体化、Scope 绑定以及位置参数与 descriptor 的对应。Gateway 负责 Host descriptor、endpoint ownership、lookup、Context 和业务调用。Connection 把 `/api`、endpoint 和 `{ args }` 作为一个 RPC 调用发送到目标并返回既有 RPC result;它不理解 Goal、Agent、lookup、descriptor 或 Client API 类型。 +Client Remote Service 负责 Remote contribution、namespace Service 实体化、Scope 绑定以及位置参数与 descriptor 的对应。Gateway 负责 Host descriptor、endpoint ownership、lookup、Context 和业务调用。Connection 把 `/api`、endpoint 和 `{ args }` 作为一个 RPC 调用发送到目标并返回既有 RPC result;它不理解 Goal、Agent、lookup、descriptor 或 Client Remote 类型。 Gateway 只向 Connection 注册 ownership matcher 和 RPC handler,不注册 HTTP route。Connection 把共享 `/api` route 挂到 HTTP Server,并把一个复合 FetchHandler 交给 bridge;该 handler 将已认领 endpoint 分发给 Gateway,未认领 endpoint 则交给 API Proxy。未来 Connection transport 可以保留相同顺序,而不改变 Remote payload、业务 decorator、生成的 DTS、Remote API 类型或 Agent Scope 编程界面。 @@ -451,8 +451,8 @@ Gateway 只向 Connection 注册 ownership matcher 和 RPC handler,不注册 H - `@deepseek-ai/dsh-type-meta`:轻量 decorator、binding、lookup、Remote Context 和 descriptor 协议。 - TypeRT generator:分析 Host/Client Program,生成本地 face 和 Remote 消费端投影,并生成规范 symbol/Zod 信息。 - TypeRT runtime:分别保存当前环境的 local reflection 与导入的 Remote contribution。 -- `@deepseek-ai/dsh-api-gateway`:默认入口关联 Host definition 与 Service,认领 Remote endpoint,执行 lookup、Context receiver 解析、调用和结果编码,并向 Connection 注册 `/api` interceptor;`/client` 入口挂载 Remote contribution,创建严格 API 方法,并把调用交给 `ctx.connection.rpc`。两个入口共享 Remote 协议,但不互相导入各自的 Cordis interface merge。 -- `@deepseek-ai/dsh-api-remotes`:BFF 层;负责 Host Agent/Session resolver,选择 Client `/remote` contribution,并通过共享的 `TypeRTClientApi` 契约向业务包暴露合并后的 API 类型。 +- `@deepseek-ai/dsh-api-gateway`:默认入口关联 Host definition 与 Service,认领 Remote endpoint,执行 lookup、Context receiver 解析、调用和结果编码,并向 Connection 注册 `/api` interceptor;`/client` 入口挂载 Remote contribution,创建严格 Remote namespace Service 和方法,并把调用交给 `ctx.connection.rpc`。两个入口共享 Remote 协议,但不互相导入各自的 Cordis interface merge。 +- `@deepseek-ai/dsh-api-remotes`:BFF 层;负责 Host Agent/Session resolver,选择 Client `/remote` contribution,并通过共享的 `TypeRTClientRemote` 契约向业务包暴露合并后的 Remote 类型。 - Connection:拥有唯一 HTTP Server/未来 WebSocket carrier、共享 `/api` route 与复合 FetchHandler、API Proxy 回退、RPC envelope、rpcId、序列化、trust 和错误传输。 - Agent/Session 等业务对象包:拥有 lookup、Context provider、唯一 ID 类型和纯类型公共出口。 - API Proxy Host 组合:向 API Remotes 提供 Web Agent 默认值和 scope 设置,并让旧方法使用同一个 `agentFor()`。 @@ -460,7 +460,7 @@ Gateway 只向 Connection 注册 ownership matcher 和 RPC handler,不注册 H ## 已交付范围与后续工作 -已交付的纵向链路是 `@deepseek-ai/dsh-goal/remote → Browser Client API → Connection RPC /api → Host Gateway → GoalService.remoteExportCreate()`。同一个带 Agent lookup 的 direct descriptor 同时支持 `ctx.api.goals.create(agentId, request)` 与 `agentCtx.goals.create(request)`。普通冷会话在 lookup 时通过 `agentFor()` 恢复,subagent-owned identity 保持既有 `agent-busy` fence;`@RemoteContext('agent')` 仍是独立的 scoped receiver 模式。 +已交付的纵向链路是 `@deepseek-ai/dsh-goal/remote → Browser Client Remote → Connection RPC /api → Host Gateway → GoalService.remoteExportCreate()`。同一个带 Agent lookup 的 direct descriptor 同时支持 `ctx.remote.goals.create(agentId, request)` 与 `agentCtx.remote.goals.create(request)`。普通冷会话在 lookup 时通过 `agentFor()` 恢复,subagent-owned identity 保持既有 `agent-busy` fence;`@RemoteContext('agent')` 仍是独立的 scoped receiver 模式。 Connection 提供共享 channel interceptor 与当前 HTTP carrier 映射。WebSocket 迁移、TUI runtime 与 carrier、TUI Agent Scope 接线、Permission/Approval 状态机、Session 事件流、调用授权、重试、幂等及跨版本协议兼容均不属于本决策。 @@ -482,7 +482,7 @@ Connection 提供共享 channel interceptor 与当前 HTTP carrier 映射。WebS **只生成 Remote DTS,不生成 JS。** 类型可以成立,但运行时无法枚举 endpoint、codec 和 Context 模式,只能依赖 Proxy 或另一份手写注册表,因此同一次 Host 投影同时生成 Remote JS contribution。 -**让 `/remote` 的顶层 import 偷偷注册全局状态。** ESM 求值时未必已有目标 Cordis Context,多个 Context、HMR 和 dispose 也无法明确归属,因此普通 value import 只返回 contribution,由环境 assembly 的 API Service 显式挂载。 +**让 `/remote` 的顶层 import 偷偷注册全局状态。** ESM 求值时未必已有目标 Cordis Context,多个 Context、HMR 和 dispose 也无法明确归属,因此普通 value import 只返回 contribution,由环境 assembly 的 Client Remote Service 显式挂载。 **为 Remote 新建独立 transport、HTTP route 或 `/api2` channel。** 这会复制或拆分 Connection 的 Server ownership、rpcId、序列化、trust、错误和未来 WebSocket 生命周期。共享 `/api` interceptor 保留唯一物理 route,并让 Connection 继续以 API Proxy 作为回退 FetchHandler。 @@ -490,7 +490,7 @@ Connection 提供共享 channel interceptor 与当前 HTTP carrier 映射。WebS - Goal Service 直接装饰业务签名已经符合 Remote 契约的变更类方法,仅保留 `remoteExportCreate(...)` 把 `GoalView` 适配为 `CreateGoalResult`,无需第二条路由、第二份 codec 或 Client 方法清单。 - 一次干净的 `build:lib` 会在 Client 编译前生成 Host 与消费方 Remote 产物,包括业务包 `/remote` 下的 JS、DTS 和 declaration map。 -- 导入 `@deepseek-ai/dsh-goal/remote` 会加入严格的 `api.goals.create(...)` 类型,并可通过 declaration 导航到 `remoteExportCreate`;不导入时不会出现该 namespace。 +- 导入 `@deepseek-ai/dsh-goal/remote` 会加入严格的 `ctx.remote.goals.create(...)` 类型,并可通过 declaration 导航到 `remoteExportCreate`;不导入时不会出现该 namespace。 - 挂载同一次 import 得到的 JS contribution 会提供 endpoint、参数、结果、lookup、Context 和 Zod 反射,并在无需手写 stub 的情况下实体化调用。 - Root 与 Agent-scoped 调用会经过真实的共享 `/api` carrier,将 `agentId` 解析为活 Agent,调用原始 Goal receiver,并通过既有 RPC envelope 返回。 - Agent 与 Session lookup 会共享同一次并发冷恢复;普通冷会话得到恢复后的对象,冷态或 live subagent identity 均在业务调用前返回 `agent-busy`。 @@ -509,13 +509,13 @@ SRC 弱 descriptor 不验证普通 JSON 内部结构。Host Remote 签名变化 公共类型唯一性要求业务 DTO 具有纯类型出口,可能暴露现有包中 Host 类型与实现入口混杂的问题。构建会拒绝这些边界,而不是复制类型掩盖问题。 -类型 import 与运行时 contribution 是两种不同效果。`import type {}` 只扩展静态 API;真实调用环境遗漏 value contribution 时,API Service 必须以明确的“Remote 未挂载”错误失败。 +类型 import 与运行时 contribution 是两种不同效果。`import type {}` 只扩展静态 Remote surface;真实调用环境遗漏 value contribution 时,Client Remote Service 必须以明确的“Remote 未挂载”错误失败。 Browser 与 Host 各自持有 Zod 实例,不能依赖对象 identity 跨 realm 比较;一致性只由规范 symbol key、同一生成模型和 wire 行为保证。 消费端可以导入 Host 当前未挂载的 Remote contract。类型表示“该协议能力已被消费端选择”,不保证目标进程当前存在对应 Service;运行时 endpoint 不可用必须明确失败。 -Connection 的通用 channel API 必须同时适合当前 HTTP carrier 和后续 WebSocket carrier。若接口把 `fetch`、HTTP request 或 route handle 暴露给 Gateway/API Service,WebSocket 迁移会再次穿透 Remote 层,因此这些物理对象必须留在 Connection 内部。 +Connection 的通用 channel API 必须同时适合当前 HTTP carrier 和后续 WebSocket carrier。若 Client Remote 或 Gateway 暴露 `fetch`、HTTP request 或 route handle,WebSocket 迁移会再次穿透 Remote 层,因此这些物理对象必须留在 Connection 内部。 Remote endpoint 使用 Connection 的 `trusted-host` authority。系统默认接受 loopback;LAN 调用方必须通过显式 trusted-host 配置接入,但本层不增加逐方法调用方授权,因此每个 trusted host 都能调用已挂载的 Remote endpoint。 diff --git a/docs/api-gateway.i18n.yaml b/docs/api-gateway.i18n.yaml index 05038eb8b9..d07272c182 100644 --- a/docs/api-gateway.i18n.yaml +++ b/docs/api-gateway.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/api-gateway.md -api-gateway.md: 090758d58306d5ea806567f0de710a1c1f5ed747 -api-gateway.zh.md: 9d7286b6b86918f3bc1e7a6cdd9bdf04447abc57 +api-gateway.md: 90aa661cc86a4f419e173560c55511c969182990 +api-gateway.zh.md: 6fcbb562b204e71d00833042ee0632bda0217940 diff --git a/docs/api-gateway.md b/docs/api-gateway.md index 090758d583..90aa661cc8 100644 --- a/docs/api-gateway.md +++ b/docs/api-gateway.md @@ -6,7 +6,7 @@ This is the current-state reference for the TypeRT API Gateway. It describes how ## Programming model -Business services use `@Remote` or `@RemoteContext` to select the methods exposed to the Client. Unmarked methods do not enter the generated Client types or runtime contributions and cannot be called through `ctx.api`. +Business services use `@Remote` or `@RemoteContext` to select the methods exposed to the Client. Unmarked methods do not enter the generated Client types or runtime contributions and cannot be called through `ctx.remote`. `@Remote` denotes calling a Cordis service registered on the root Host Context. Complex Host objects cannot cross the wire directly; the business package must declare their association with a wire identity through `TypeRTLookupMap` and register a default resolution provider with `ctx.typert.lookups` at runtime. For example, an `Agent` parameter named `agent` in the Host signature produces an `agentId` wire field, and the Gateway resolves that id to a Host object before invoking the business method. Host composition can use `ctx.typert.lookups.configure()` to override the resolution policy for a lookup key without changing the parameter name, wire field, or canonical type symbol owned by the business package. @@ -55,7 +55,7 @@ export class GoalService extends GatewayService { Remote methods may return a value synchronously or return a Promise. For cooperative cancellation, the final parameter in the Host signature must be `signal: AbortSignal` using the global type; it is recorded in the descriptor instead of entering `args`, while the generated Client method accepts an optional final `AbortSignal`. -The Client uses concrete functions on ordinary objects, not a JavaScript Proxy. Direct Remotes appear under `ctx.api.`; when an `@Remote` method has exactly one lookup parameter and a same-named `TypeRTContextMap` uses the same wire identity, the generator also projects the method without that identity parameter onto the corresponding scoped Context. `@RemoteContext` generates only the scoped invocation interface. +The Client uses concrete functions on ordinary objects, not a JavaScript Proxy. Direct and scoped calls appear under `ctx.remote.` and `agentCtx.remote.`. Each namespace is a traced Cordis child Service registered as `remote.`; the Client assembly mounts contributions through `ctx.remote.$mount()`, consumers inject both `remote` and the namespace Service they call, and the namespace unloads after its last method is withdrawn. When an `@Remote` method has exactly one lookup parameter and a same-named `TypeRTContextMap` uses the same wire identity, the generated scoped signature omits that identity parameter. `@RemoteContext` generates only the scoped invocation interface. ```ts import type { SessionId } from '@deepseek-ai/dsh-session/types' @@ -67,13 +67,13 @@ declare const ctx: Context declare const agentCtx: AgentContext declare const agentId: SessionId -await ctx.api.goals.create(agentId, { objective: 'ship it' }) -await agentCtx.goals.create({ objective: 'ship it' }) +await ctx.remote.goals.create(agentId, { objective: 'ship it' }) +await agentCtx.remote.goals.create({ objective: 'ship it' }) ``` -Client applications assemble only `@deepseek-ai/dsh-api-remotes`. That package imports the `/remote` subpaths of selected business packages as runtime values, mounts their contributions on `ctx.api`, and re-exports the declaration merges from the same files. Adding a Host Remote package is an explicit choice by the Client composition owner; business components do not need to load the TypeRT Gateway or the business package's Remote JS separately. +Client applications assemble only `@deepseek-ai/dsh-api-remotes`. That package imports the `/remote` subpaths of selected business packages as runtime values, mounts their contributions through `ctx.remote.$mount()`, and re-exports the declaration merges from the same files. Adding a Host Remote package is an explicit choice by the Client composition owner; business components do not need to load the TypeRT Gateway or the business package's Remote JS separately. -A future TUI can assemble the same React-independent `api-remotes` and `ctx.api` contract, so the Host methods visible to it are likewise limited to the Remote methods selected at generation time. This document does not define or implement the TUI composition. +A future TUI can assemble the same React-independent `api-remotes` and `ctx.remote` contract, so the Host methods visible to it are likewise limited to the Remote methods selected at generation time. This document does not define or implement the TUI composition. ## Component responsibilities @@ -84,11 +84,11 @@ A future TUI can assemble the same React-independent `api-remotes` and `ctx.api` | 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-remotes` | 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, resolves objects or Contexts, invokes live Cordis services, and validates boundaries | -| Client | `@deepseek-ai/dsh-api-gateway/client` | Provides `ctx.api`, mounts generated descriptors as concrete methods, and initiates, validates, and cancels calls through the Connection | +| Client | `@deepseek-ai/dsh-api-gateway/client` | Provides `ctx.remote` and `remote.` child Services, mounts generated descriptors as concrete methods, and initiates, validates, 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 current `/api` HTTP bridge | -The API Gateway package owns the Host dispatcher and Client API as peer entries, but the two builds never enter the same `ts.Program`. The Host entry does not import the Client Cordis `Context` merge, and the Client entry does not import the Host Gateway service. +The API Gateway package owns the Host dispatcher and Client Remote endpoint as peer entries, but the two builds never enter the same `ts.Program`. The Host entry does not import the Client Cordis `Context` merge, and the Client entry does not import the Host Gateway service. ## Strict generation pipeline @@ -106,13 +106,13 @@ Each contributing business package writes generated files to its own `lib/` dire Business packages expose the Host Loader entry through `./typert` and the Host-for-Client entry through `./remote`. The generator also validates these package exports and published-file lists; it generates artifacts only for explicit contribution packages that provide the corresponding entry. -Parameter names in Remote Client declarations come from wire fields, while parameter and return types reference Client-safe types exported by the original business package. The declaration map resolves the generated property behind `ctx.api.goals.create` back to the Host source method marked with `@Remote`, so editors that support declaration maps can navigate from a Client call to the real implementation instead of stopping at the generated `.d.ts`. +Parameter names in Remote Client declarations come from wire fields, while parameter and return types reference Client-safe types exported by the original business package. The declaration map resolves the generated property behind `ctx.remote.goals.create` back to the Host source method marked with `@Remote`, so editors that support declaration maps can navigate from a Client call to the real implementation instead of stopping at the generated `.d.ts`. Strict analysis requires a Remote to be a public, non-static instance method with a concrete implementation. The method cannot be generic; parameters must be required, named simple identifiers and cannot use destructuring, default values, rest parameters, or optional parameters. TypeRT generates strict schemas for ordinary JSON-representable types; complex objects such as workspace classes must have a unique `TypeRTLookupMap` declaration. Lookup and Context packages are responsible for both static declaration merges and runtime provider registration; if either side is missing, the build or earliest resolvable runtime boundary fails. ## Runtime invocation -Remote and API Proxy currently share the Connection's `/api` route; there is no separate `/api2` server or second Connection. The Client API calls `connection.rpc.call('/api', '/', { args }, signal)`; the current HTTP carrier maps this to `POST /api//`, with a payload containing only a named `args` object. +Remote and API Proxy currently share the Connection's `/api` route; there is no separate `/api2` server or second Connection. The Client Remote calls `connection.rpc.call('/api', '/', { args }, signal)`; the current HTTP carrier maps this to `POST /api//`, with a payload containing only a named `args` object. The Connection performs the unified trust check for `/api` before the HTTP bridge, then dispatches inside the shared FetchHandler in interceptor order. The TypeRT Gateway claims only two-segment endpoints that have a strict descriptor or active SRC marker; unclaimed requests fall back to the existing API Proxy. The Connection owns transport, RPC ids, response envelopes, and request cancellation, while the Gateway owns only the Remote data protocol and business dispatch. Replacing the Connection carrier in the future does not require changes to Remote descriptors or the Client programming interface. @@ -128,7 +128,7 @@ When the Host starts from source through `node --import tsx/esm`, it does not ex The SRC fallback parses simple parameter names from the live function. When a parameter name matches the `parameter` of a registered lookup, such as `agent` or `session`, it uses the lookup's `agentId` or `sessionId` wire field and resolves the object on the Host; other parameters are checked only for cycle-free, JSON-safe data with no special prototype. `@RemoteContext` directly uses the wire field of a registered Host Context provider. SRC does not read TypeScript types, generate Zod schemas, infer optional parameters, or support destructuring, default values, rest parameters, or duplicate parameter names. -SRC solves only dispatch for a Host process running from source. The Client does not discover decorators from the running Host, and the Client API refuses to mount SRC descriptors that lack strict codecs; its types, codecs, and Remote registration values always come from the most recently generated `lib/typert.remote-client.*` artifacts. +SRC solves only dispatch for a Host process running from source. The Client does not discover decorators from the running Host, and the Client Remote refuses to mount SRC descriptors that lack strict codecs; its types, codecs, and Remote registration values always come from the most recently generated `lib/typert.remote-client.*` artifacts. ## Development mode diff --git a/docs/api-gateway.zh.md b/docs/api-gateway.zh.md index 9d7286b6b8..6fcbb562b2 100644 --- a/docs/api-gateway.zh.md +++ b/docs/api-gateway.zh.md @@ -6,7 +6,7 @@ ## 编程模型 -业务 Service 通过 `@Remote` 或 `@RemoteContext` 选择对 Client 开放的方法。未标记的方法不会进入生成的 Client 类型或运行时贡献,也不能通过 `ctx.api` 调用。 +业务 Service 通过 `@Remote` 或 `@RemoteContext` 选择对 Client 开放的方法。未标记的方法不会进入生成的 Client 类型或运行时贡献,也不能通过 `ctx.remote` 调用。 `@Remote` 表示调用根 Host Context 中注册的 Cordis Service。复杂的 Host 对象不能直接跨 wire 传输;业务包必须通过 `TypeRTLookupMap` 声明它与 wire identity 的关联,并在运行时向 `ctx.typert.lookups` 注册默认解析提供方。例如 `Agent` 参数在 Host 签名中名为 `agent`,生成的 wire 字段为 `agentId`,Gateway 在调用业务方法前将 id 解析为 Host 对象。Host 组合可以用 `ctx.typert.lookups.configure()` 覆盖某个 lookup key 的解析策略,而不改变业务包拥有的参数名、wire 字段或规范类型 symbol。 @@ -55,7 +55,7 @@ export class GoalService extends GatewayService { Remote 方法可以同步返回或返回 Promise。若需要协作式取消,Host 签名的最后一个参数必须是全局类型的 `signal: AbortSignal`;它记录在描述符中而不是进入 `args`,Client 生成的方法则接受最后一个可选的 `AbortSignal`。 -Client 使用普通对象上的具体函数,不使用 JavaScript Proxy。直接 Remote 出现在 `ctx.api.`;当一个 `@Remote` 方法恰好有一个 lookup 参数、且同名 `TypeRTContextMap` 使用相同 wire identity 时,生成器还会把去掉该 identity 参数后的方法投影到对应作用域 Context。`@RemoteContext` 只生成作用域调用界面。 +Client 使用普通对象上的具体函数,不使用 JavaScript Proxy。直接调用与作用域调用分别出现在 `ctx.remote.` 和 `agentCtx.remote.`。每个 namespace 都是注册为 `remote.` 的可追踪 Cordis 子 Service;Client assembly 通过 `ctx.remote.$mount()` 挂载贡献,消费方同时注入 `remote` 与所调用的 namespace Service,最后一个方法撤回后该 namespace 随即卸载。当一个 `@Remote` 方法恰好有一个 lookup 参数、且同名 `TypeRTContextMap` 使用相同 wire identity 时,生成的作用域签名会省略该 identity 参数。`@RemoteContext` 只生成作用域调用界面。 ```ts import type { SessionId } from '@deepseek-ai/dsh-session/types' @@ -67,13 +67,13 @@ declare const ctx: Context declare const agentCtx: AgentContext declare const agentId: SessionId -await ctx.api.goals.create(agentId, { objective: 'ship it' }) -await agentCtx.goals.create({ objective: 'ship it' }) +await ctx.remote.goals.create(agentId, { objective: 'ship it' }) +await agentCtx.remote.goals.create({ objective: 'ship it' }) ``` -Client 应用只装配 `@deepseek-ai/dsh-api-remotes`。该包以运行时值导入被选业务包的 `/remote` 子路径,并向 `ctx.api` 挂载贡献,同时重新导出相同文件中的声明合并。增加一个 Host Remote 包是 Client 组合所有者的显式选择;业务组件不需要分别加载 TypeRT Gateway 或业务包的 Remote JS。 +Client 应用只装配 `@deepseek-ai/dsh-api-remotes`。该包以运行时值导入被选业务包的 `/remote` 子路径,通过 `ctx.remote.$mount()` 挂载贡献,同时重新导出相同文件中的声明合并。增加一个 Host Remote 包是 Client 组合所有者的显式选择;业务组件不需要分别加载 TypeRT Gateway 或业务包的 Remote JS。 -未来的 TUI 可以装配同一个不依赖 React 的 `api-remotes` 与 `ctx.api` 契约,因此它能看到的 Host 方法同样只限于生成时选择的 Remote 方法。本文不定义或实现 TUI 组合。 +未来的 TUI 可以装配同一个不依赖 React 的 `api-remotes` 与 `ctx.remote` 契约,因此它能看到的 Host 方法同样只限于生成时选择的 Remote 方法。本文不定义或实现 TUI 组合。 ## 组件职责 @@ -84,11 +84,11 @@ Client 应用只装配 `@deepseek-ai/dsh-api-remotes`。该包以运行时值导 | Host | `@deepseek-ai/dsh-typert-registry` 与 Loader | 把生成的 Host 描述符、schema 及业务包注册项放入 `ctx.typert`,并持有 lookup 与 Context 提供方 | | Host | `@deepseek-ai/dsh-api-remotes` | 负责应用的 Agent/Session 身份策略,并配置对应的 TypeRT lookup | | Host | `@deepseek-ai/dsh-api-gateway` | 提供 `ctx.typertGateway`,认领 Remote endpoint,解析对象或 Context,调用实时 Cordis Service 并校验边界 | -| Client | `@deepseek-ai/dsh-api-gateway/client` | 提供 `ctx.api`,把生成的描述符挂成具体方法,并通过 Connection 发起、校验和取消调用 | +| Client | `@deepseek-ai/dsh-api-gateway/client` | 提供 `ctx.remote` 与 `remote.` 子 Service,把生成的描述符挂成具体方法,并通过 Connection 发起、校验和取消调用 | | Client | `@deepseek-ai/dsh-api-remotes/client` | 显式选择并挂载本应用允许使用的 `/remote` 贡献,向业务代码带入对应的声明合并 | | 双侧 | `@deepseek-ai/dsh-client-connection` | 提供 RPC carrier、请求关联、信任边界、取消、响应 envelope 与当前 `/api` HTTP bridge | -API Gateway 包同时拥有 Host dispatcher 与 Client API 两个对等入口,但两侧构建不会进入同一个 `ts.Program`。Host 入口不导入 Client 的 Cordis `Context` 合并,Client 入口也不导入 Host Gateway 服务。 +API Gateway 包同时拥有 Host dispatcher 与 Client Remote endpoint 两个对等入口,但两侧构建不会进入同一个 `ts.Program`。Host 入口不导入 Client 的 Cordis `Context` 合并,Client 入口也不导入 Host Gateway 服务。 ## 严格生成链路 @@ -106,13 +106,13 @@ API Gateway 包同时拥有 Host dispatcher 与 Client API 两个对等入口, 业务包通过 `./typert` 暴露 Host Loader 入口,通过 `./remote` 暴露 Host-for-Client 入口。生成器同时校验这些 package export 及发布文件清单;只有具备相应入口的显式贡献包才会生成产物。 -Remote Client 声明中的参数名来自 wire 字段,参数和返回类型则引用原业务包导出的 Client-safe 类型。声明 map 把 `ctx.api.goals.create` 最终解析到的生成属性映射到带 `@Remote` 的 Host 源方法,因此支持 declaration-map 的编辑器可以从 Client 调用跳到真实实现,而不是停在生成的 `.d.ts`。 +Remote Client 声明中的参数名来自 wire 字段,参数和返回类型则引用原业务包导出的 Client-safe 类型。声明 map 把 `ctx.remote.goals.create` 最终解析到的生成属性映射到带 `@Remote` 的 Host 源方法,因此支持 declaration-map 的编辑器可以从 Client 调用跳到真实实现,而不是停在生成的 `.d.ts`。 严格分析要求 Remote 是公开、非静态、有具体实现的实例方法。方法不能是泛型;参数必须是具名且必填的简单标识符,不能使用解构、默认值、rest 或可选参数。可 JSON 表示的普通类型由 TypeRT 生成严格 schema;工作区 class 等复杂对象必须具有唯一的 `TypeRTLookupMap` 声明。lookup 与 Context 包同时负责静态声明合并和运行时提供方注册,缺少任一侧都会在构建或最早可解析的运行时边界报错。 ## 运行时调用 -当前 Remote 与 API Proxy 共用 Connection 的 `/api` 路由,不存在独立 `/api2` server 或第二套 Connection。Client API 调用 `connection.rpc.call('/api', '/', { args }, signal)`;当前 HTTP carrier 对应 `POST /api//`,payload 只包含一个具名 `args` 对象。 +当前 Remote 与 API Proxy 共用 Connection 的 `/api` 路由,不存在独立 `/api2` server 或第二套 Connection。Client Remote 调用 `connection.rpc.call('/api', '/', { args }, signal)`;当前 HTTP carrier 对应 `POST /api//`,payload 只包含一个具名 `args` 对象。 Connection 在 HTTP bridge 之前执行 `/api` 的统一信任检查,再在共享 FetchHandler 内按 interceptor 顺序分发。TypeRT Gateway 只认领存在严格描述符或活跃 SRC marker 的两段式 endpoint;未认领的请求回退到既有 API Proxy。Connection 拥有传输、RPC id、响应 envelope 和 request cancellation,Gateway 只拥有 Remote 数据协议和业务分发。未来替换 Connection carrier 不要求改变 Remote 描述符或 Client 编程界面。 @@ -128,7 +128,7 @@ Host 通过 `node --import tsx/esm` 从源码启动时不会执行 TypeRT 编译 SRC 回退从运行中函数解析简单参数名。参数名与某个已注册 lookup 的 `parameter` 相同,例如 `agent` 或 `session`,就使用其 `agentId` 或 `sessionId` wire 字段并在 Host 解析对象;其他参数只检查值是否为无循环、无特殊 prototype 的 JSON-safe 数据。`@RemoteContext` 直接使用已注册 Host Context provider 的 wire 字段。SRC 不读取 TypeScript 类型,不生成 Zod schema,不推断可选参数,也不支持解构、默认值、rest 或重复参数名。 -SRC 只解决 Host 源码进程的分发问题。Client 不会从运行中的 Host 发现 decorator,Client API 也拒绝挂载缺少严格 codec 的 SRC 描述符;其类型、codec 和 Remote 注册值始终来自最近一次生成的 `lib/typert.remote-client.*`。 +SRC 只解决 Host 源码进程的分发问题。Client 不会从运行中的 Host 发现 decorator,Client Remote 也拒绝挂载缺少严格 codec 的 SRC 描述符;其类型、codec 和 Remote 注册值始终来自最近一次生成的 `lib/typert.remote-client.*`。 ## 开发模式 diff --git a/docs/core-data-structures/typert.i18n.yaml b/docs/core-data-structures/typert.i18n.yaml index a6e1eb5415..75b7837687 100644 --- a/docs/core-data-structures/typert.i18n.yaml +++ b/docs/core-data-structures/typert.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/typert.md -typert.md: a61ed8587833e03fd5c1246311e62a6ffaeb3bd0 -typert.zh.md: 18c24018f4abd644cf35185c2bd06b6980195481 +typert.md: c70e50e2fea8455eb75dfdf8c309f659ab9cb2f9 +typert.zh.md: 2cd1636d4cc8dbcfa009073b4a8e1dcc8d5897e4 diff --git a/docs/core-data-structures/typert.md b/docs/core-data-structures/typert.md index a61ed85878..c70e50e2fe 100644 --- a/docs/core-data-structures/typert.md +++ b/docs/core-data-structures/typert.md @@ -126,10 +126,10 @@ interface TypeRTService { } ``` -Generated consumer declarations merge direct namespaces into the map inherited by `TypeRTClientApi`. +Generated consumer declarations merge direct namespaces into the map inherited by `TypeRTClientRemote`. ```ts type-equiv -/** Merge-extensible direct namespace surface generated for Client API services. */ +/** Merge-extensible direct namespace surface generated for Client Remote services. */ interface TypeRTRemoteNamespaceMap {} ``` @@ -186,18 +186,18 @@ interface TypertGateway { } ``` -## Consumer API +## Consumer Remote -`ctx.api` exposes only namespaces contributed by imported `/remote` artifacts. Mounting installs the generated descriptors and concrete root/scoped methods as one fiber-owned operation; no JavaScript Proxy or Host Service type enters the consumer. +`ctx.remote` exposes only namespaces contributed by imported `/remote` artifacts. `$mount()` installs generated descriptors and concrete methods as one fiber-owned operation. Each namespace is a traced `remote.` Cordis child Service whose lifetime spans its mounted methods; no JavaScript Proxy or Host business Service type enters the consumer. ```ts type-equiv -/** Client API capability implemented by the Gateway and consumed by Remote assemblies. */ -interface TypeRTClientApi extends TypeRTRemoteNamespaceMap { +/** Client Remote capability implemented by the Gateway and consumed by Remote assemblies. */ +interface TypeRTClientRemote extends TypeRTRemoteNamespaceMap { /** * Mount one generated Host-for-Client contribution in the caller's fiber. * @param contribution - explicitly selected Remote package artifact. - * @returns disposer withdrawing descriptors and concrete methods together. + * @returns disposer after namespace services and concrete methods are ready. */ - mount(contribution: TypeRTRemoteContribution): TypeRTDisposer + $mount(contribution: TypeRTRemoteContribution): Promise } ``` diff --git a/docs/core-data-structures/typert.zh.md b/docs/core-data-structures/typert.zh.md index 18c24018f4..2cd1636d4c 100644 --- a/docs/core-data-structures/typert.zh.md +++ b/docs/core-data-structures/typert.zh.md @@ -126,10 +126,10 @@ interface TypeRTService { } ``` -生成的消费方声明会把 direct namespace 合并到 `TypeRTClientApi` 继承的 map 中。 +生成的消费方声明会把 direct namespace 合并到 `TypeRTClientRemote` 继承的 map 中。 ```ts type-equiv -/** Merge-extensible direct namespace surface generated for Client API services. */ +/** Merge-extensible direct namespace surface generated for Client Remote services. */ interface TypeRTRemoteNamespaceMap {} ``` @@ -186,18 +186,18 @@ interface TypertGateway { } ``` -## 消费方 API +## 消费方 Remote -`ctx.api` 只暴露由已导入 `/remote` 产物贡献的 namespace。挂载会把生成的 descriptor 与具体的 root/scoped 方法作为一项由 fiber 持有的操作统一注册;JavaScript Proxy 与 Host 服务类型都不会进入消费方。 +`ctx.remote` 只暴露由已导入 `/remote` 产物贡献的 namespace。`$mount()` 会把生成的 descriptor 与具体方法作为一项由 fiber 持有的操作统一注册。每个 namespace 都是可追踪的 `remote.` Cordis 子 Service,其生命周期覆盖已挂载的方法;JavaScript Proxy 与 Host 业务 Service 类型都不会进入消费方。 ```ts type-equiv -/** Client API capability implemented by the Gateway and consumed by Remote assemblies. */ -interface TypeRTClientApi extends TypeRTRemoteNamespaceMap { +/** Client Remote capability implemented by the Gateway and consumed by Remote assemblies. */ +interface TypeRTClientRemote extends TypeRTRemoteNamespaceMap { /** * Mount one generated Host-for-Client contribution in the caller's fiber. * @param contribution - explicitly selected Remote package artifact. - * @returns disposer withdrawing descriptors and concrete methods together. + * @returns disposer after namespace services and concrete methods are ready. */ - mount(contribution: TypeRTRemoteContribution): TypeRTDisposer + $mount(contribution: TypeRTRemoteContribution): Promise } ``` diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index b0809af72e..933f204fa0 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/development.md -development.md: f832956c4c7cbde96613a69db6c636a2246786a7 -development.zh.md: 3ae70e7135ad5faee0e37d99f55cdb41373aab2c +development.md: 37bc88c7c1cfedfbe1a93e08a4cbde833ac32372 +development.zh.md: a738e53cb3434d7930aa82107782a4c22aea1470 diff --git a/docs/development.md b/docs/development.md index f832956c4c..37bc88c7c1 100644 --- a/docs/development.md +++ b/docs/development.md @@ -62,7 +62,7 @@ Host and client stay two aggregate programs because both sides declaration-merge Static analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. Decision record: [solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md); the tsc-first emit pipeline is the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md). -Business services declare callable methods on the Host with `@Remote` or `@RemoteContext`; the Host build generates Host-for-Client types and runtime contributions, and the Client's `api-remotes` composition loads those contributions and mounts concrete methods on `ctx.api` or the corresponding scoped Context. See [API Gateway](api-gateway.md) for the generated artifacts on both sides, their assembly relationships, the SRC development fallback, and the Web build order. +Business services declare callable methods on the Host with `@Remote` or `@RemoteContext`; the Host build generates Host-for-Client types and runtime contributions, and the Client's `api-remotes` composition loads those contributions under `ctx.remote` and scoped `agentCtx.remote` namespaces. See [API Gateway](api-gateway.md) for the generated artifacts on both sides, their assembly relationships, the SRC development fallback, and the Web build order. If a relevant local check consumes built package output, build once first: diff --git a/docs/development.zh.md b/docs/development.zh.md index 3ae70e7135..a738e53cb3 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -62,7 +62,7 @@ host 与 client 保持两个聚合 program,是因为两侧在相同键下以 静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。决策记录:[solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md);tsc-first 发射管线见 [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md)。 -业务 Service 在 Host 使用 `@Remote` 或 `@RemoteContext` 声明可调用方法;Host 构建生成 Host-for-Client 类型与运行时贡献,Client 的 `api-remotes` 组合加载这些贡献并将具体方法挂载到 `ctx.api` 或对应的作用域 Context。两侧的生成产物、装配关系、SRC 开发回退和 Web 构建顺序见 [API Gateway](api-gateway.md)。 +业务 Service 在 Host 使用 `@Remote` 或 `@RemoteContext` 声明可调用方法;Host 构建生成 Host-for-Client 类型与运行时贡献,Client 的 `api-remotes` 组合加载这些贡献并挂到 `ctx.remote` 与作用域 `agentCtx.remote` namespace。两侧的生成产物、装配关系、SRC 开发回退和 Web 构建顺序见 [API Gateway](api-gateway.md)。 如果相关的本地检查需要使用构建后的包产物,请先构建一次: diff --git a/packages/api/README.i18n.yaml b/packages/api/README.i18n.yaml index 855eeb8eaa..6a834cdf4d 100644 --- a/packages/api/README.i18n.yaml +++ b/packages/api/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/api/README.md -README.md: 0dcded5922fea1ea6676315029ba0eadd74dd3df -README.zh.md: 1b9bb9133a955d0cbef0ca91728aab1545831d94 +README.md: 7c75e8012459266e0ce09c97416d140e5ac777e1 +README.zh.md: 87bd15fc4e5ad23ef785f7c9ee805a4aa1a35e46 diff --git a/packages/api/README.md b/packages/api/README.md index 0dcded5922..7c75e80124 100644 --- a/packages/api/README.md +++ b/packages/api/README.md @@ -6,10 +6,10 @@ The application-facing Remote stack. `remotes` owns BFF policy and the selected | Package | Role | ctx key | |---|---|---| -| [`remotes/`](remotes/README.md) | Host Agent/Session lookup policy and Client Remote contribution assembly | no service; configures `ctx.typert` and consumes `ctx.api` | -| [`gateway/`](gateway/README.md) | Host TypeRT dispatcher and Client API endpoint | `ctx.typertGateway` / `ctx.api` | +| [`remotes/`](remotes/README.md) | Host Agent/Session lookup policy and Client Remote contribution assembly | no service; configures `ctx.typert` and consumes `ctx.remote` | +| [`gateway/`](gateway/README.md) | Host TypeRT dispatcher and Client Remote endpoint | `ctx.typertGateway` / `ctx.remote` | -The runtime dependency direction is `remotes → gateway → connection → webserver`: the BFF consumes the shared `TypeRTClientApi` contract, Gateway delegates transport to Connection, and Connection mounts on the HTTP server. Cordis service injection and Client module metadata preserve this order without importing the concrete Gateway from the Remotes Client entry. +The runtime dependency direction is `remotes → gateway → connection → webserver`: the BFF consumes the shared `TypeRTClientRemote` contract, Gateway delegates transport to Connection, and Connection mounts on the HTTP server. Cordis service injection and Client module metadata preserve this order without importing the concrete Gateway from the Remotes Client entry. ## Known Limitations and Deferred Work diff --git a/packages/api/README.zh.md b/packages/api/README.zh.md index 1b9bb9133a..87bd15fc4e 100644 --- a/packages/api/README.zh.md +++ b/packages/api/README.zh.md @@ -6,10 +6,10 @@ | 包 | 职责 | ctx key | |---|---|---| -| [`remotes/`](remotes/README.md) | Host Agent/Session lookup 策略与 Client Remote contribution 装配 | 无服务;配置 `ctx.typert` 并消费 `ctx.api` | -| [`gateway/`](gateway/README.md) | Host TypeRT 分发器与 Client API endpoint | `ctx.typertGateway` / `ctx.api` | +| [`remotes/`](remotes/README.md) | Host Agent/Session lookup 策略与 Client Remote contribution 装配 | 无服务;配置 `ctx.typert` 并消费 `ctx.remote` | +| [`gateway/`](gateway/README.md) | Host TypeRT 分发器与 Client Remote endpoint | `ctx.typertGateway` / `ctx.remote` | -运行时依赖方向为 `remotes → gateway → connection → webserver`:BFF 消费共享的 `TypeRTClientApi` 契约,Gateway 把传输交给 Connection,Connection 再挂载到 HTTP server。Cordis 服务注入与 Client 模块元数据在不让 Remotes Client 入口导入具体 Gateway 实现的前提下维持该顺序。 +运行时依赖方向为 `remotes → gateway → connection → webserver`:BFF 消费共享的 `TypeRTClientRemote` 契约,Gateway 把传输交给 Connection,Connection 再挂载到 HTTP server。Cordis 服务注入与 Client 模块元数据在不让 Remotes Client 入口导入具体 Gateway 实现的前提下维持该顺序。 ## 已知限制与延期工作 diff --git a/packages/api/gateway/README.i18n.yaml b/packages/api/gateway/README.i18n.yaml index 41bbb0621f..3a9a0ba50d 100644 --- a/packages/api/gateway/README.i18n.yaml +++ b/packages/api/gateway/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/api/gateway/README.md -README.md: 9e3d4d89788bbc6edebfc0c0127999fed3ed9261 -README.zh.md: 9bbd46c71185a2fbf8da163565d6c19141c079ca +README.md: e37359db71c1388667e9e61f538354711e90c0c1 +README.zh.md: 2054febb9a5423297c32b029b40a035062250aab diff --git a/packages/api/gateway/README.md b/packages/api/gateway/README.md index 9e3d4d8978..e37359db71 100644 --- a/packages/api/gateway/README.md +++ b/packages/api/gateway/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Two-sided TypeRT RPC endpoint for Host and Client Cordis environments. The Host entry provides `ctx.typertGateway`, while `@deepseek-ai/dsh-api-gateway/client` provides `ctx.api`; both consume the same generated `InvocationDescriptor` contract and leave business selection to API Remotes and transport, request correlation, trust, and response envelopes to Connection. +Two-sided TypeRT RPC endpoint for Host and Client Cordis environments. The Host entry provides `ctx.typertGateway`, while `@deepseek-ai/dsh-api-gateway/client` provides `ctx.remote`; both consume the same generated `InvocationDescriptor` contract and leave business selection to API Remotes and transport, request correlation, trust, and response envelopes to Connection. ## Host service: `TypertGatewayService` (ctx key: `typertGateway`) @@ -14,13 +14,13 @@ The Host entry registers a trusted-host interceptor on Connection's shared `/api 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. -## Client service: `ClientApi` (ctx key: `api`) +## Client service: `ClientRemote` (ctx key: `remote`) -`ctx.api.mount()` validates and registers a generated Host-for-Client contribution, then installs concrete direct and scoped methods for the calling Cordis fiber. Duplicate endpoints, namespace collisions, and descriptors without strict generated codecs fail before methods become callable. +`ctx.remote.$mount()` validates and registers a generated Host-for-Client contribution, then installs concrete direct and scoped methods for the calling Cordis fiber. Each namespace is a traced `remote.` child Service and unloads after its last method is withdrawn. Duplicate endpoints, namespace collisions, and descriptors without strict generated codecs fail before methods become callable. Each call validates positional inputs, constructs the descriptor's exact named `args`, and sends it through `ctx.connection.rpc.call('/api', endpoint, ...)`. Generated cancellation-aware methods accept a final optional `AbortSignal`; the Client combines it with the contribution mount lifetime before calling Connection. The returned value is validated before reaching application code. Withdrawing a contribution removes its descriptors and methods together, aborts in-flight calls, and makes retained method handles reject. -Generated declaration merges provide the TypeScript API through the shared `TypeRTClientApi` contract. The Client entry contains no Host Service or Host Cordis interface merge, and method lookup and invocation use ordinary objects and functions rather than a JavaScript Proxy. +Generated declaration merges provide the TypeScript API through the shared `TypeRTClientRemote` contract. The Client entry contains no Host Service or Host Cordis interface merge, and method lookup and invocation use ordinary objects and functions rather than a JavaScript Proxy. ## Model Experience diff --git a/packages/api/gateway/README.zh.md b/packages/api/gateway/README.zh.md index 9bbd46c711..2054febb9a 100644 --- a/packages/api/gateway/README.zh.md +++ b/packages/api/gateway/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -为 Host 与 Client 两侧的 Cordis 环境提供 TypeRT RPC endpoint。Host 入口提供 `ctx.typertGateway`,`@deepseek-ai/dsh-api-gateway/client` 则提供 `ctx.api`;两者使用同一份生成的 `InvocationDescriptor` 契约,并将业务选择交给 API Remotes,将传输、请求关联、信任和响应封装交给 Connection。 +为 Host 与 Client 两侧的 Cordis 环境提供 TypeRT RPC endpoint。Host 入口提供 `ctx.typertGateway`,`@deepseek-ai/dsh-api-gateway/client` 则提供 `ctx.remote`;两者使用同一份生成的 `InvocationDescriptor` 契约,并将业务选择交给 API Remotes,将传输、请求关联、信任和响应封装交给 Connection。 ## Host 服务:`TypertGatewayService`(ctx key:`typertGateway`) @@ -14,13 +14,13 @@ Connection 可用时,Host 入口会在 Connection 共享的 `/api` FetchHandle 支持取消的 Remote 方法会把 `signal: AbortSignal` 声明为最后一个 Host 参数。signal 是 descriptor 元数据,而不是 wire 参数:Connection 将它提供给 Gateway,Gateway 则在已解码的业务参数之后注入它。SRC 识别这个保留的末位参数名,严格生成还要求它具有全局 `AbortSignal` 类型。 -## Client 服务:`ClientApi`(ctx key:`api`) +## Client 服务:`ClientRemote`(ctx key:`remote`) -`ctx.api.mount()` 会校验并注册生成的 Host-for-Client 贡献项,然后为发起调用的 Cordis fiber 安装具体的直接方法和作用域方法。重复端点、命名空间冲突,以及缺少生成的严格编解码器的描述符,都会在方法可调用前报错。 +`ctx.remote.$mount()` 会校验并注册生成的 Host-for-Client 贡献项,然后为发起调用的 Cordis fiber 安装具体的直接方法和作用域方法。每个 namespace 都是可追踪的 `remote.` 子 Service,并在最后一个方法撤回后卸载。重复端点、命名空间冲突,以及缺少生成的严格编解码器的描述符,都会在方法可调用前报错。 每次调用都会校验位置参数,构造与描述符完全匹配的具名 `args`,再通过 `ctx.connection.rpc.call('/api', endpoint, ...)` 发送。生成的支持取消的方法接受最后一个可选 `AbortSignal`;Client 会在调用 Connection 前将它与贡献项的挂载生命周期合并。返回值经过校验后才会交给应用代码。撤回贡献项会同时移除其描述符和方法、中止正在进行的调用,并使外部仍持有的方法句柄在调用时返回拒绝。 -生成的声明合并通过共享的 `TypeRTClientApi` 契约提供 TypeScript API。Client 入口不包含 Host 服务或 Host Cordis 接口合并;方法查找和调用使用普通对象与函数,而不使用 JavaScript Proxy。 +生成的声明合并通过共享的 `TypeRTClientRemote` 契约提供 TypeScript API。Client 入口不包含 Host 服务或 Host Cordis 接口合并;方法查找和调用使用普通对象与函数,而不使用 JavaScript Proxy。 ## 模型体验 diff --git a/packages/api/gateway/src/client/index.ts b/packages/api/gateway/src/client/index.ts index a9343823ff..d0429339c8 100644 --- a/packages/api/gateway/src/client/index.ts +++ b/packages/api/gateway/src/client/index.ts @@ -1,37 +1,25 @@ /** * Client projection of generated TypeRT Remote descriptors. Contributions - * install concrete namespace methods; no JavaScript Proxy participates in - * lookup, invocation, or type exposure. + * install traced `remote.` services; no JavaScript Proxy + * participates in method lookup, invocation, or type exposure. */ -import { Service, symbols } from 'cordis' +import { Service } from 'cordis' import type { Context } from 'cordis' import type { ConnectionHandle, RpcError } from '@deepseek-ai/dsh-client-connection/client' import type { InvocationDescriptor, - TypeRTClientApi, + TypeRTClientRemote, TypeRTCodec, TypeRTDisposer, TypeRTRemoteContribution, } from '@deepseek-ai/dsh-type-meta' -type RemoteMethod = (...args: unknown[]) => Promise - interface MountToken { active: boolean readonly abort: AbortController } -interface DirectNamespaceRecord { - readonly value: Record - readonly tokens: Map -} - -interface ScopedNamespaceRecord { - readonly service: ScopedRemoteNamespace - readonly tokens: Map -} - interface ScopedProjection { readonly context: string readonly wire: string @@ -39,13 +27,36 @@ interface ScopedProjection { readonly parameterIndex?: number } -/** Typed API service augmented by generated direct Remote namespaces. */ -export type ClientApi = TypeRTClientApi +interface DirectMethod { + readonly descriptor: InvocationDescriptor + readonly token: MountToken +} + +interface ScopedMethod extends DirectMethod { + readonly projection: ScopedProjection +} + +interface RemoteMethodRecord { + direct?: DirectMethod + scoped?: ScopedMethod +} + +interface BoundContextIdentity { + readonly value: unknown +} + +interface RemoteNamespaceHandle { + readonly service: RemoteNamespaceService + readonly dispose: TypeRTDisposer +} + +/** Typed Remote service augmented by generated direct namespaces. */ +export type ClientRemote = TypeRTClientRemote declare module 'cordis' { interface Context { - /** Generated direct Remote namespaces selected by the Client assembly. */ - api: ClientApi + /** Generated Remote namespaces selected by the Client assembly. */ + remote: ClientRemote } } @@ -53,48 +64,56 @@ declare module 'cordis' { export const inject = ['typert', 'connection'] /** - * Install the typed Client API service. + * Install the typed Client Remote service. * @param ctx - Client Cordis root. */ export function apply(ctx: Context): void { - new ClientApiService(ctx) + new ClientRemoteService(ctx) } -class ClientApiService extends Service implements TypeRTClientApi { +class ClientRemoteService extends Service implements TypeRTClientRemote { private readonly ownerCtx: Context - private readonly direct = new Map() - private readonly scoped = new Map() + private readonly namespaces = new Map() + private mutations = Promise.resolve() constructor(ctx: Context) { - super(ctx, 'api') + super(ctx, 'remote') this.ownerCtx = ctx } - mount(contribution: TypeRTRemoteContribution): ReturnType { - this.validateContribution(contribution) + async $mount(contribution: TypeRTRemoteContribution): ReturnType { const callerCtx = this.ctx + const owned = callerCtx.effect(async () => { + const dispose = await this.enqueue(() => this.mountContribution(callerCtx, contribution)) + return () => this.enqueue(dispose) + }, `api-gateway.client.$mount(${JSON.stringify(contribution.package)})`) + await owned + return async () => { await owned() } + } + + private enqueue(operation: () => T | Promise): Promise { + const result = this.mutations.then(operation, operation) + this.mutations = result.then(() => undefined, () => undefined) + return result + } + + private async mountContribution( + callerCtx: Context, + contribution: TypeRTRemoteContribution, + ): Promise { + this.validateContribution(contribution) const disposeRemote = callerCtx.typert.remotes.register(contribution) - let disposeMethods: () => void | Promise + const installed: TypeRTDisposer[] = [] try { - disposeMethods = callerCtx.effect(() => { - const installed: Array<() => void> = [] - try { - for (const descriptor of contribution.descriptors) installed.push(this.install(descriptor)) - } catch (error) { - for (const dispose of installed.reverse()) dispose() - throw error - } - return () => { - for (const dispose of installed.reverse()) dispose() - } - }, `api-gateway.client.mount(${JSON.stringify(contribution.package)})`) + for (const descriptor of contribution.descriptors) installed.push(await this.install(descriptor)) } catch (error) { - /* v8 ignore next -- rollback disposal only rejects if Cordis teardown itself fails while handling the installation error. */ - Promise.resolve(disposeRemote()).catch(() => {}) + for (const dispose of installed.reverse()) await dispose() + await disposeRemote() throw error } return async () => { - await Promise.all([disposeMethods(), disposeRemote()]) + for (const dispose of installed.reverse()) await dispose() + await disposeRemote() } } @@ -112,10 +131,8 @@ class ClientApiService extends Service implements TypeRTClientApi { } methods.add(descriptor.method) table.set(descriptor.namespace, methods) - const live = kind === 'direct' - ? this.direct.get(descriptor.namespace)?.tokens - : this.scoped.get(descriptor.namespace)?.tokens - if (live?.has(descriptor.method) === true) { + const namespace = this.namespaces.get(descriptor.namespace)?.service + if (namespace?.has(kind, descriptor.method) === true) { throw new Error(`client api: ${kind} method ${endpointOf(descriptor)} is already mounted`) } } @@ -124,118 +141,151 @@ class ClientApiService extends Service implements TypeRTClientApi { if (descriptor.invocation.kind === 'direct') add(direct, descriptor, 'direct') if (scopedProjection(descriptor) !== undefined) add(scoped, descriptor, 'scoped') } - for (const namespace of direct.keys()) { - if (!this.direct.has(namespace) && namespace in this) { - throw new Error(`client api: namespace ${JSON.stringify(namespace)} conflicts with the API service`) - } - } - for (const [namespace, methods] of scoped) { - const record = this.scoped.get(namespace) - if (record !== undefined) { - for (const method of methods) record.service.assertMethodAvailable(method) - } else { - for (const method of methods) ScopedRemoteNamespace.assertMethodAvailable(namespace, method) - const property = this.ownerCtx.reflect.props[namespace] - if (property?.type === 'accessor' || this.ownerCtx.get(namespace) !== undefined) { - throw new Error(`client api: scoped namespace ${JSON.stringify(namespace)} conflicts with an existing Context property`) + const namespaces = new Set([...direct.keys(), ...scoped.keys()]) + for (const namespace of namespaces) { + const service = this.namespaces.get(namespace)?.service + if (service === undefined) { + if (namespace in this) { + throw new Error(`client api: namespace ${JSON.stringify(namespace)} conflicts with the Remote service`) } + const serviceKey = remoteServiceKey(namespace) + const property = this.ownerCtx.reflect.props[serviceKey] + if (property?.type === 'accessor' || this.ownerCtx.get(serviceKey) !== undefined) { + throw new Error(`client api: namespace ${JSON.stringify(namespace)} conflicts with an existing Remote namespace`) + } + } + for (const method of new Set([...(direct.get(namespace) ?? []), ...(scoped.get(namespace) ?? [])])) { + if (service === undefined) RemoteNamespaceService.assertMethodAvailable(namespace, method) + else service.assertMethodAvailable(method) } } } - private install(descriptor: InvocationDescriptor): () => void { + private async install(descriptor: InvocationDescriptor): Promise { const token: MountToken = { active: true, abort: new AbortController() } - const installed: (() => void)[] = [] + const installed: TypeRTDisposer[] = [] try { if (descriptor.invocation.kind === 'direct') { - installed.push(this.installDirect(descriptor, token)) + installed.push(await this.installDirect(descriptor, token)) } const projection = scopedProjection(descriptor) - if (projection !== undefined) installed.push(this.installScoped(descriptor, projection, token)) + if (projection !== undefined) installed.push(await this.installScoped(descriptor, projection, token)) } catch (error) { token.active = false - for (const dispose of installed.reverse()) dispose() token.abort.abort() + for (const dispose of installed.reverse()) await dispose() throw error } - return () => { + return async () => { /* v8 ignore next -- Cordis effect disposers are idempotent and invoke this cleanup at most once. */ if (!token.active) return token.active = false - for (const dispose of installed.reverse()) dispose() token.abort.abort() + for (const dispose of installed.reverse()) await dispose() } } - private installDirect(descriptor: InvocationDescriptor, token: MountToken): () => void { - let namespace = this.direct.get(descriptor.namespace) - const fresh = namespace === undefined - if (namespace === undefined) { - namespace = { value: Object.create(null) as Record, tokens: new Map() } - Object.defineProperty(this, descriptor.namespace, { - configurable: true, - enumerable: true, - value: namespace.value, - }) - } + private async installDirect(descriptor: InvocationDescriptor, token: MountToken): Promise { + const namespace = await this.namespace(descriptor.namespace) try { - Object.defineProperty(namespace.value, descriptor.method, { - configurable: true, - enumerable: true, - value: (...args: unknown[]) => this.invoke(descriptor, undefined, token, this.ownerCtx, args), - }) + namespace.service.installDirect(descriptor, token) } catch (error) { - if (fresh) Reflect.deleteProperty(this, descriptor.namespace) + await this.disposeNamespace(descriptor.namespace, namespace) throw error } - if (fresh) this.direct.set(descriptor.namespace, namespace) - namespace.tokens.set(descriptor.method, token) - return () => { - /* v8 ignore next -- duplicate live methods are rejected before installation, so no newer token can replace this one. */ - if (namespace.tokens.get(descriptor.method) !== token) return - Reflect.deleteProperty(namespace.value, descriptor.method) - namespace.tokens.delete(descriptor.method) - if (namespace.tokens.size !== 0) return - this.direct.delete(descriptor.namespace) - Reflect.deleteProperty(this, descriptor.namespace) + return async () => { + if (!namespace.service.remove('direct', descriptor.method, token)) return + await this.disposeNamespace(descriptor.namespace, namespace) } } - private installScoped( + private async installScoped( descriptor: InvocationDescriptor, projection: ScopedProjection, token: MountToken, - ): () => void { - let namespace = this.scoped.get(descriptor.namespace) - if (namespace === undefined) { - const service = new ScopedRemoteNamespace( - this.ownerCtx, - descriptor.namespace, - (current, currentProjection, currentToken, caller, args) => - this.invoke(current, currentProjection, currentToken, caller, args), - ) - service.install(descriptor, projection, token) - namespace = { service, tokens: new Map() } - this.scoped.set(descriptor.namespace, namespace) - } else { - namespace.service.install(descriptor, projection, token) + ): Promise { + const namespace = await this.namespace(descriptor.namespace) + try { + namespace.service.installScoped(descriptor, projection, token) + } catch (error) { + await this.disposeNamespace(descriptor.namespace, namespace) + throw error } - namespace.tokens.set(descriptor.method, token) - return () => { - /* v8 ignore next -- duplicate live methods are rejected before installation, so no newer token can replace this one. */ - if (namespace.tokens.get(descriptor.method) !== token) return - namespace.service.remove(descriptor.method) - namespace.tokens.delete(descriptor.method) - if (namespace.tokens.size === 0) this.scoped.delete(descriptor.namespace) + return async () => { + if (!namespace.service.remove('scoped', descriptor.method, token)) return + await this.disposeNamespace(descriptor.namespace, namespace) } } + private async namespace(name: string): Promise { + let namespace = this.namespaces.get(name) + if (namespace !== undefined) return namespace + let service: RemoteNamespaceService | undefined + const fiber = this.ownerCtx.plugin({ + name: remoteServiceKey(name), + apply: (ctx: Context) => { + service = new RemoteNamespaceService( + ctx, + name, + (direct, scoped, caller, args) => this.invokeMethod(direct, scoped, caller, args), + ) + }, + }) + try { + await fiber + } catch (error) { + await fiber.dispose() + throw error + } + /* v8 ignore next -- a settled namespace fiber synchronously constructs its Service. */ + if (service === undefined) throw new Error(`client api: namespace ${JSON.stringify(name)} did not start`) + namespace = { service, dispose: fiber.dispose } + this.namespaces.set(name, namespace) + return namespace + } + + private async disposeNamespace(name: string, namespace: RemoteNamespaceHandle): Promise { + if (!namespace.service.empty || this.namespaces.get(name) !== namespace) return + this.namespaces.delete(name) + await namespace.dispose() + } + + private invokeMethod( + direct: DirectMethod | undefined, + scoped: ScopedMethod | undefined, + callerCtx: Context, + values: readonly unknown[], + ): Promise { + if (scoped !== undefined) { + const binder = this.ownerCtx.typert.contexts.getClient(scoped.projection.context) + const identity = binder?.identity(callerCtx) + if (identity !== undefined) { + return this.invoke( + scoped.descriptor, + scoped.projection, + scoped.token, + callerCtx, + values, + { value: identity }, + ) + } + } + if (direct !== undefined) { + return this.invoke(direct.descriptor, undefined, direct.token, callerCtx, values) + } + if (scoped !== undefined) { + return this.invoke(scoped.descriptor, scoped.projection, scoped.token, callerCtx, values) + } + throw new Error('client api: Remote method is no longer mounted') + } + private async invoke( descriptor: InvocationDescriptor, projection: ScopedProjection | undefined, token: MountToken, callerCtx: Context, values: readonly unknown[], + boundIdentity?: BoundContextIdentity, ): Promise { const endpoint = endpointOf(descriptor) if (!token.active) throw new Error(`client api: Remote method ${endpoint} is no longer mounted`) @@ -251,11 +301,15 @@ class ClientApiService extends Service implements TypeRTClientApi { } const args = Object.create(null) as Record if (projection !== undefined) { - const binder = this.ownerCtx.typert.contexts.getClient(projection.context) - if (binder === undefined) { + const binder = boundIdentity === undefined + ? this.ownerCtx.typert.contexts.getClient(projection.context) + : undefined + if (boundIdentity === undefined && binder === undefined) { throw new Error(`client api: ${endpoint} has no Client Context binder for ${JSON.stringify(projection.context)}`) } - const identity = binder.identity(callerCtx) + const identity = boundIdentity === undefined + ? binder?.identity(callerCtx) + : boundIdentity.value if (identity === undefined) { throw new Error(`client api: ${endpoint} requires a ${JSON.stringify(projection.context)} Context`) } @@ -281,23 +335,19 @@ class ClientApiService extends Service implements TypeRTClientApi { } type InvokeRemote = ( - descriptor: InvocationDescriptor, - projection: ScopedProjection, - token: MountToken, + direct: DirectMethod | undefined, + scoped: ScopedMethod | undefined, callerCtx: Context, args: readonly unknown[], ) => Promise -class ScopedRemoteNamespace { - private readonly ctx: Context - private readonly ownerCtx: Context - private readonly methods = new Set() - private disposeService: TypeRTDisposer | undefined - readonly name: string +class RemoteNamespaceService extends Service { + private readonly methods = new Map() + private readonly namespace: string static assertMethodAvailable(namespace: string, method: string): void { - if (SCOPED_NAMESPACE_FIELDS.has(method) || method in ScopedRemoteNamespace.prototype) { - throw new Error(`client api: scoped method ${JSON.stringify(`${namespace}/${method}`)} conflicts with its namespace service`) + if (REMOTE_NAMESPACE_FIELDS.has(method) || method in RemoteNamespaceService.prototype) { + throw new Error(`client api: method ${JSON.stringify(`${namespace}/${method}`)} conflicts with its namespace service`) } } @@ -306,54 +356,92 @@ class ScopedRemoteNamespace { name: string, private readonly invokeRemote: InvokeRemote, ) { - this.ctx = ctx - this.ownerCtx = ctx - this.name = name - Object.defineProperty(this, symbols.tracker, { - value: { associate: name, property: 'ctx' }, - }) + super(ctx, remoteServiceKey(name)) + this.namespace = name } assertMethodAvailable(method: string): void { - ScopedRemoteNamespace.assertMethodAvailable(this.name, method) - if (method in this) { - throw new Error(`client api: scoped method ${JSON.stringify(`${this.name}/${method}`)} conflicts with its namespace service`) + RemoteNamespaceService.assertMethodAvailable(this.namespace, method) + if (method in this && !this.methods.has(method)) { + throw new Error(`client api: method ${JSON.stringify(`${this.namespace}/${method}`)} conflicts with its namespace service`) } } - install(descriptor: InvocationDescriptor, projection: ScopedProjection, token: MountToken): void { - this.assertMethodAvailable(descriptor.method) - const activate = this.methods.size === 0 - const method = descriptor.method + get empty(): boolean { + return this.methods.size === 0 + } + + has(kind: 'direct' | 'scoped', method: string): boolean { + return this.methods.get(method)?.[kind] !== undefined + } + + installDirect(descriptor: InvocationDescriptor, token: MountToken): void { + this.install(descriptor.method, 'direct', { descriptor, token }) + } + + installScoped(descriptor: InvocationDescriptor, projection: ScopedProjection, token: MountToken): void { + this.install(descriptor.method, 'scoped', { descriptor, projection, token }) + } + + private install(method: string, kind: 'direct', value: DirectMethod): void + private install(method: string, kind: 'scoped', value: ScopedMethod): void + private install(method: string, kind: 'direct' | 'scoped', value: DirectMethod | ScopedMethod): void { + this.assertMethodAvailable(method) + let record = this.methods.get(method) + const fresh = record === undefined + record ??= {} + if (record[kind] !== undefined) { + throw new Error(`client api: ${kind} method ${this.namespace}/${method} is already mounted`) + } try { - Object.defineProperty(this, method, { - configurable: true, - enumerable: true, - value: function (this: ScopedRemoteNamespace, ...args: unknown[]): Promise { - return this.invokeRemote(descriptor, projection, token, this.ctx, args) - }, - }) - if (activate) { - this.disposeService = this.ownerCtx.reflect.provide(this.name, this) + if (fresh) { + Object.defineProperty(this, method, { + configurable: true, + enumerable: true, + get: function (this: RemoteNamespaceService): (...args: unknown[]) => Promise { + const callerCtx = this.ctx + const current = this.methods.get(method) + const direct = current?.direct + const scoped = current?.scoped + return (...args: unknown[]) => { + return this.invokeRemote(direct, scoped, callerCtx, args) + } + }, + }) + this.methods.set(method, record) } + if (kind === 'direct') record.direct = value + else record.scoped = value as ScopedMethod } catch (error) { - Reflect.deleteProperty(this, method) + if (kind === 'direct') delete record.direct + else delete record.scoped + if (fresh) { + this.methods.delete(method) + Reflect.deleteProperty(this, method) + } throw error } - this.methods.add(method) } - remove(method: string): void { - Reflect.deleteProperty(this, method) + remove(kind: 'direct' | 'scoped', method: string, token: MountToken): boolean { + const record = this.methods.get(method) + const current = record?.[kind] + /* v8 ignore next -- duplicate live variants are rejected before installation, so no newer token can replace this one. */ + if (record === undefined || current?.token !== token) return false + if (kind === 'direct') delete record.direct + else delete record.scoped + if (record.direct !== undefined || record.scoped !== undefined) return true this.methods.delete(method) - if (this.methods.size !== 0) return - const disposeService = this.disposeService - this.disposeService = undefined - void disposeService?.() + Reflect.deleteProperty(this, method) + return true } } -const SCOPED_NAMESPACE_FIELDS = new Set(['ctx', 'disposeService', 'invokeRemote', 'methods', 'name', 'ownerCtx']) +const REMOTE_NAMESPACE_FIELDS = new Set(['ctx', 'empty', 'invokeRemote', 'methods', 'name', 'namespace']) + +function remoteServiceKey(namespace: string): string { + return `remote.${namespace}` +} function endpointOf(descriptor: Pick): string { return `${descriptor.namespace}/${descriptor.method}` diff --git a/packages/api/gateway/tests/client.spec.ts b/packages/api/gateway/tests/client.spec.ts index feae3056c9..216f2359e7 100644 --- a/packages/api/gateway/tests/client.spec.ts +++ b/packages/api/gateway/tests/client.spec.ts @@ -1,4 +1,4 @@ -import { Context } from 'cordis' +import { Context, Service } from 'cordis' import { describe, expect, it, vi } from 'vitest' import { z } from 'zod' import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' @@ -38,7 +38,7 @@ declare module '@deepseek-ai/dsh-type-meta' { } -type FixtureContext = Context & TypeRTRemoteContextApi<'fixture'> +type FixtureContext = Omit & { readonly remote: TypeRTRemoteContextApi<'fixture'> } const idSchema = z.string().min(1) const requestSchema = z.object({ objective: z.string().min(1) }) @@ -105,17 +105,16 @@ describe('Client TypeRT API', () => { const call = vi.fn() .mockResolvedValue({ ok: true, value: { ref: 'goal-1' } }) const ctx = await bench(call) - let retained: typeof ctx.api.goals.create | undefined + const businessGoals = { owner: 'host business service' } + const disposeBusinessGoals = ctx.provide('goals', businessGoals) const assembly = ctx.plugin(Object.assign( - (scope: Context) => { - scope.api.mount({ package: '@fixture/goals', descriptors: [directDescriptor()] }) - retained = scope.api.goals.create - }, - { inject: ['api'] }, + (scope: Context) => scope.remote.$mount({ package: '@fixture/goals', descriptors: [directDescriptor()] }), + { inject: ['remote'] }, )) await assembly + const retained = ctx.remote.goals.create - await expect(ctx.api.goals.create('agent-1', { objective: 'ship' })).resolves.toEqual({ ref: 'goal-1' }) + await expect(ctx.remote.goals.create('agent-1', { objective: 'ship' })).resolves.toEqual({ ref: 'goal-1' }) expect(call).toHaveBeenCalledWith( '/api', 'goals/create', @@ -123,7 +122,7 @@ describe('Client TypeRT API', () => { expect.any(AbortSignal), ) const callerAbort = new AbortController() - await expect(ctx.api.goals.create( + await expect(ctx.remote.goals.create( 'agent-1', { objective: 'cancel me' }, callerAbort.signal, @@ -135,16 +134,18 @@ describe('Client TypeRT API', () => { callerAbort.abort(cancellation) expect(combinedSignal?.aborted).toBe(true) expect(combinedSignal?.reason).toBe(cancellation) - await expect(ctx.api.goals.create('', { objective: 'ship' })).rejects.toThrow('rejected "agentId"') + await expect(ctx.remote.goals.create('', { objective: 'ship' })).rejects.toThrow('rejected "agentId"') call.mockResolvedValueOnce({ ok: true, value: { ref: 1 } }) - await expect(ctx.api.goals.create('agent-1', { objective: 'ship' })).rejects.toThrow('rejected "result"') + await expect(ctx.remote.goals.create('agent-1', { objective: 'ship' })).rejects.toThrow('rejected "result"') await assembly.dispose() - expect((ctx.api as unknown as Record).goals).toBeUndefined() - expect(ctx.get('goals')).toBeUndefined() + expect((ctx.remote as unknown as Record).goals).toBeUndefined() + expect(ctx.get('remote.goals')).toBeUndefined() + expect(ctx.get('goals')).toBe(businessGoals) expect(ctx.typert.remotes.list()).toEqual([]) await expect(retained?.('agent-1', { objective: 'ship' })).rejects.toThrow('no longer mounted') + disposeBusinessGoals() }) it('projects one direct lookup descriptor onto an Agent-scoped alias', async () => { @@ -156,26 +157,24 @@ describe('Client TypeRT API', () => { identity: candidate => (candidate as Context & { fixtureId?: string }).fixtureId, }) const assembly = ctx.plugin(Object.assign( - (scope: Context) => { - scope.api.mount({ package: '@fixture/goals', descriptors: [directDescriptor()] }) - }, - { inject: ['api'] }, + (scope: Context) => scope.remote.$mount({ package: '@fixture/goals', descriptors: [directDescriptor()] }), + { inject: ['remote'] }, )) await assembly - await expect(agentCtx.goals.create({ objective: 'ship scoped' })).resolves.toEqual({ ref: 'goal-2' }) + await expect(agentCtx.remote.goals.create({ objective: 'ship scoped' })).resolves.toEqual({ ref: 'goal-2' }) expect(call).toHaveBeenCalledWith( '/api', 'goals/create', { args: { agentId: 'agent-2', request: { objective: 'ship scoped' } } }, expect.any(AbortSignal), ) - await expect((ctx as FixtureContext).goals.create({ objective: 'wrong scope' })) - .rejects.toThrow('requires a "fixture" Context') + await expect((ctx as FixtureContext).remote.goals.create({ objective: 'wrong scope' })) + .rejects.toThrow('expected 2 business argument(s)') await assembly.dispose() - expect((ctx.api as unknown as Record).goals).toBeUndefined() - expect(ctx.get('goals')).toBeUndefined() + expect((ctx.remote as unknown as Record).goals).toBeUndefined() + expect(ctx.get('remote.goals')).toBeUndefined() }) it('uses the caller Context identity for scoped namespace methods', async () => { @@ -187,25 +186,23 @@ describe('Client TypeRT API', () => { identity: candidate => (candidate as Context & { fixtureId?: string }).fixtureId, }) const assembly = ctx.plugin(Object.assign( - (scope: Context) => { - scope.api.mount({ package: '@fixture/goals', descriptors: [contextDescriptor()] }) - }, - { inject: ['api'] }, + (scope: Context) => scope.remote.$mount({ package: '@fixture/goals', descriptors: [contextDescriptor()] }), + { inject: ['remote'] }, )) await assembly - await expect(agentCtx.goals.rename({ objective: 'land' })).resolves.toEqual({ renamed: true }) + await expect(agentCtx.remote.goals.rename({ objective: 'land' })).resolves.toEqual({ renamed: true }) expect(call).toHaveBeenCalledWith( '/api', 'goals/rename', { args: { agentId: 'agent-2', request: { objective: 'land' } } }, expect.any(AbortSignal), ) - await expect((ctx as FixtureContext).goals.rename({ objective: 'land' })) + await expect((ctx as FixtureContext).remote.goals.rename({ objective: 'land' })) .rejects.toThrow('requires a "fixture" Context') await assembly.dispose() - expect(ctx.get('goals')).toBeUndefined() + expect(ctx.get('remote.goals')).toBeUndefined() }) it('rejects weak descriptors and namespace collisions before registration', async () => { @@ -215,12 +212,12 @@ describe('Client TypeRT API', () => { result: { mode: 'src-json' }, } - expect(() => ctx.api.mount({ package: '@fixture/weak', descriptors: [weak] })) - .toThrow('has no strict codec') - expect(() => ctx.api.mount({ + await expect(ctx.remote.$mount({ package: '@fixture/weak', descriptors: [weak] })) + .rejects.toThrow('has no strict codec') + await expect(ctx.remote.$mount({ package: '@fixture/conflict', - descriptors: [{ ...directDescriptor(), namespace: 'mount' }], - })).toThrow('conflicts with the API service') + descriptors: [{ ...directDescriptor(), namespace: '$mount' }], + })).rejects.toThrow('conflicts with the Remote service') expect(ctx.typert.remotes.list()).toEqual([]) }) @@ -235,48 +232,50 @@ describe('Client TypeRT API', () => { const direct = directDescriptor() const context = contextDescriptor() - expect(() => ctx.api.mount({ + await expect(ctx.remote.$mount({ package: '@fixture/direct-duplicates', descriptors: [direct, { ...direct, id: '@fixture/goals#goals/create-again' }], - })).toThrow('repeats direct method') - expect(() => ctx.api.mount({ + })).rejects.toThrow('repeats direct method') + await expect(ctx.remote.$mount({ package: '@fixture/scoped-duplicates', descriptors: [context, { ...context, id: '@fixture/goals#goals/rename-again' }], - })).toThrow('repeats scoped method') + })).rejects.toThrow('repeats scoped method') - const disposeDirect = ctx.api.mount({ package: '@fixture/direct-live', descriptors: [direct] }) - expect(() => ctx.api.mount({ + const disposeDirect = await ctx.remote.$mount({ package: '@fixture/direct-live', descriptors: [direct] }) + await expect(ctx.remote.$mount({ package: '@fixture/direct-conflict', descriptors: [{ ...direct, id: '@fixture/other#goals/create' }], - })).toThrow('direct method goals/create is already mounted') + })).rejects.toThrow('direct method goals/create is already mounted') await disposeDirect() - const disposeScoped = ctx.api.mount({ package: '@fixture/scoped-live', descriptors: [context] }) - expect(() => ctx.api.mount({ + const disposeScoped = await ctx.remote.$mount({ package: '@fixture/scoped-live', descriptors: [context] }) + await expect(ctx.remote.$mount({ package: '@fixture/scoped-conflict', descriptors: [{ ...context, id: '@fixture/other#goals/rename' }], - })).toThrow('scoped method goals/rename is already mounted') - expect(() => ctx.api.mount({ + })).rejects.toThrow('scoped method goals/rename is already mounted') + await expect(ctx.remote.$mount({ package: '@fixture/service-method-conflict', descriptors: [{ ...context, id: '@fixture/goals#goals/remove', method: 'remove' }], - })).toThrow('conflicts with its namespace service') - const scopedService = ctx.get('goals') as unknown as object + })).rejects.toThrow('conflicts with its namespace service') + const scopedService = ctx.get('remote.goals') as unknown as object Object.defineProperty(scopedService, 'custom', { configurable: true, value: () => undefined }) - expect(() => ctx.api.mount({ + await expect(ctx.remote.$mount({ package: '@fixture/service-own-property-conflict', descriptors: [{ ...direct, id: '@fixture/goals#goals/custom', method: 'custom' }], - })).toThrow('conflicts with its namespace service') + })).rejects.toThrow('conflicts with its namespace service') Reflect.deleteProperty(scopedService, 'custom') await disposeScoped() - expect(() => ctx.api.mount({ + const disposeRemoteTypert = ctx.reflect.provide('remote.typert', { owner: 'fixture' }) + await expect(ctx.remote.$mount({ package: '@fixture/context-property-conflict', descriptors: [{ ...context, namespace: 'typert' }], - })).toThrow('conflicts with an existing Context property') + })).rejects.toThrow('conflicts with an existing Remote namespace') + await disposeRemoteTypert() - const disposeMultipleScoped = ctx.api.mount({ + const disposeMultipleScoped = await ctx.remote.$mount({ package: '@fixture/multiple-scoped', descriptors: [directDescriptor(), contextDescriptor()], }) - await expect(agentCtx.goals.rename({ objective: 'remounted' })).resolves.toEqual({ renamed: true }) + await expect(agentCtx.remote.goals.rename({ objective: 'remounted' })).resolves.toEqual({ renamed: true }) expect(call).toHaveBeenLastCalledWith( '/api', 'goals/rename', @@ -286,41 +285,6 @@ describe('Client TypeRT API', () => { await disposeMultipleScoped() }) - it('rolls back direct projection when scoped installation fails', async () => { - const ctx = await bench(vi.fn()) - const disposeScoped = ctx.api.mount({ - package: '@fixture/scoped-base', - descriptors: [contextDescriptor()], - }) - const defineProperty = Object.defineProperty - let createDefinitions = 0 - const definePropertySpy = vi.spyOn(Object, 'defineProperty').mockImplementation((target, key, attributes) => { - // The direct projection defines `create` first; fail the following scoped projection. - if (key === 'create' && ++createDefinitions === 2) throw new Error('simulated scoped installation failure') - return defineProperty(target, key, attributes) - }) - - try { - expect(() => ctx.api.mount({ - package: '@fixture/failing-install', - descriptors: [directDescriptor()], - })).toThrow('simulated scoped installation failure') - } finally { - definePropertySpy.mockRestore() - } - - expect((ctx.api as unknown as Record).goals).toBeUndefined() - expect(ctx.get('goals') !== undefined).toBe(true) - expect(ctx.typert.remotes.list()).toHaveLength(1) - - const disposeRetry = ctx.api.mount({ - package: '@fixture/retry', - descriptors: [directDescriptor()], - }) - await disposeRetry() - await disposeScoped() - }) - it('rolls back earlier descriptors when a later descriptor fails to install', async () => { const ctx = await bench(vi.fn()) const { scope: _scope, ...first } = directDescriptor() @@ -335,17 +299,17 @@ describe('Client TypeRT API', () => { return defineProperty(target, key, attributes) }) try { - expect(() => ctx.api.mount({ package: '@fixture/failing-batch', descriptors: [first, second] })) - .toThrow('fixture later-descriptor failure') + await expect(ctx.remote.$mount({ package: '@fixture/failing-batch', descriptors: [first, second] })) + .rejects.toThrow('fixture later-descriptor failure') } finally { spy.mockRestore() } - expect((ctx.api as unknown as Record).goals).toBeUndefined() + expect((ctx.remote as unknown as Record).goals).toBeUndefined() await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) }) - const retry = ctx.api.mount({ package: '@fixture/retry-batch', descriptors: [first, second] }) - expect(ctx.api.goals.create).toBeTypeOf('function') - expect((ctx.api.goals as unknown as Record).archive).toBeTypeOf('function') + const retry = await ctx.remote.$mount({ package: '@fixture/retry-batch', descriptors: [first, second] }) + expect(ctx.remote.goals.create).toBeTypeOf('function') + expect((ctx.remote.goals as unknown as Record).archive).toBeTypeOf('function') await retry() }) @@ -353,7 +317,7 @@ describe('Client TypeRT API', () => { const ctx = await bench(vi.fn()) const direct = directDescriptor() const context = contextDescriptor() - expect(() => ctx.api.mount({ + await expect(ctx.remote.$mount({ package: '@fixture/weak-parameter', descriptors: [{ ...direct, @@ -361,19 +325,19 @@ describe('Client TypeRT API', () => { ? { ...parameter, codec: { mode: 'src-json' } } : parameter), }], - })).toThrow('has no strict codec') - expect(() => ctx.api.mount({ + })).rejects.toThrow('has no strict codec') + await expect(ctx.remote.$mount({ package: '@fixture/weak-context', descriptors: [{ ...context, invocation: { ...context.invocation, codec: { mode: 'src-json' } }, } as InvocationDescriptor], - })).toThrow('has no strict codec') - expect(() => ctx.api.mount({ + })).rejects.toThrow('has no strict codec') + await expect(ctx.remote.$mount({ package: '@fixture/malformed-scope', descriptors: [{ ...direct, scope: { context: 'fixture', wire: 'missingId' } }], - })).toThrow('scope must select its only lookup parameter') - expect(() => ctx.api.mount({ + })).rejects.toThrow('scope must select its only lookup parameter') + await expect(ctx.remote.$mount({ package: '@fixture/ambiguous-scope', descriptors: [{ ...direct, @@ -382,7 +346,7 @@ describe('Client TypeRT API', () => { codec: { mode: 'strict', typeSymbol: '@fixture#AgentId', schema: idSchema }, }], }], - })).toThrow('scope must select its only lookup parameter') + })).rejects.toThrow('scope must select its only lookup parameter') }) it('validates invocation arity, required binders, live Connection, and mutable descriptor codecs', async () => { @@ -390,27 +354,29 @@ describe('Client TypeRT API', () => { .mockResolvedValue({ ok: true, value: { ref: 'goal-1' } }) const ctx = await bench(call) const descriptor = directDescriptor() - const dispose = ctx.api.mount({ + const dispose = await ctx.remote.$mount({ package: '@fixture/goals', descriptors: [descriptor, contextDescriptor()], }) - const create = ctx.api.goals.create as unknown as (...args: unknown[]) => Promise - const goals = (ctx as FixtureContext).goals + const create = ctx.remote.goals.create as unknown as (...args: unknown[]) => Promise + const goals = (ctx as FixtureContext).remote.goals const rename = goals.rename as unknown as (...args: unknown[]) => Promise await expect(create('agent-1')).rejects.toThrow('expected 2 business argument(s) plus an optional AbortSignal, got 1') await expect(create('agent-1', { objective: 'ship' }, undefined, 'extra')) .rejects.toThrow('got 4') await expect(rename.call(goals)).rejects.toThrow('expected 1 argument(s), got 0') - await expect((ctx as FixtureContext).goals.create({ objective: 'ship' })) + await expect((ctx as FixtureContext).remote.goals.create({ objective: 'ship' })) + .rejects.toThrow('expected 2 business argument(s)') + await expect((ctx as FixtureContext).remote.goals.rename({ objective: 'ship' })) .rejects.toThrow('no Client Context binder') ;(descriptor.parameters[0] as { codec: { mode: string } }).codec.mode = 'src-json' - await expect(ctx.api.goals.create('agent-1', { objective: 'ship' })).rejects.toThrow('has no strict codec') + await expect(ctx.remote.goals.create('agent-1', { objective: 'ship' })).rejects.toThrow('has no strict codec') ;(descriptor.parameters[0] as { codec: { mode: string } }).codec.mode = 'strict' ctx.set('connection', undefined) - await expect(ctx.api.goals.create('agent-1', { objective: 'ship' })).rejects.toThrow('no active Connection') + await expect(ctx.remote.goals.create('agent-1', { objective: 'ship' })).rejects.toThrow('no active Connection') await dispose() }) @@ -427,14 +393,14 @@ describe('Client TypeRT API', () => { id: '@fixture/goals#goals/archive', method: 'archive', } - const dispose = ctx.api.mount({ package: '@fixture/goals', descriptors: [first, second] }) - const invocation = ctx.api.goals.create('agent-1', { objective: 'ship' }) + const dispose = await ctx.remote.$mount({ package: '@fixture/goals', descriptors: [first, second] }) + const invocation = ctx.remote.goals.create('agent-1', { objective: 'ship' }) await vi.waitFor(() => { expect(call).toHaveBeenCalledTimes(1) }) await dispose() resolveCall({ ok: true, value: { ref: 'goal-1' } }) await expect(invocation).rejects.toThrow('withdrawn during invocation') - expect((ctx.api as unknown as Record).goals).toBeUndefined() + expect((ctx.remote as unknown as Record).goals).toBeUndefined() }) it('preserves a __proto__ wire parameter as an own named argument', async () => { @@ -453,9 +419,9 @@ describe('Client TypeRT API', () => { codec: { mode: 'strict', typeSymbol: '@fixture#PrototypeValue', schema: z.string() }, }], } - const dispose = ctx.api.mount({ package: '@fixture/prototype', descriptors: [descriptor] }) + const dispose = await ctx.remote.$mount({ package: '@fixture/prototype', descriptors: [descriptor] }) - const method = (ctx.api.goals as unknown as Record Promise>).prototype + const method = (ctx.remote.goals as unknown as Record Promise>).prototype await expect(method?.('wire-value')).resolves.toEqual({ ref: 'goal-1' }) const payload = call.mock.calls[0]?.[2] as { readonly args: Record } expect(Object.getPrototypeOf(payload.args)).toBeNull() @@ -464,23 +430,23 @@ describe('Client TypeRT API', () => { await dispose() }) - it('rolls back Remote registration when concrete method installation fails', async () => { + it('rolls back Remote registration when namespace Service startup fails', async () => { const ctx = await bench(vi.fn()) const defineProperty = Object.defineProperty const spy = vi.spyOn(Object, 'defineProperty').mockImplementation((target, key, attributes) => { - if (key === 'goals') throw new Error('fixture installation failure') + if (key === Service.tracker) throw new Error('fixture namespace startup failure') return defineProperty(target, key, attributes) }) try { - expect(() => ctx.api.mount({ package: '@fixture/goals', descriptors: [directDescriptor()] })) - .toThrow('fixture installation failure') + await expect(ctx.remote.$mount({ package: '@fixture/goals', descriptors: [directDescriptor()] })) + .rejects.toThrow('fixture namespace startup failure') await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) }) } finally { spy.mockRestore() } - const retry = ctx.api.mount({ package: '@fixture/goals-retry', descriptors: [directDescriptor()] }) - expect(ctx.api.goals.create).toBeTypeOf('function') + const retry = await ctx.remote.$mount({ package: '@fixture/goals-retry', descriptors: [directDescriptor()] }) + expect(ctx.remote.goals.create).toBeTypeOf('function') await retry() }) @@ -492,16 +458,21 @@ describe('Client TypeRT API', () => { return defineProperty(target, key, attributes) }) try { - expect(() => ctx.api.mount({ package: '@fixture/direct-method-failure', descriptors: [directDescriptor()] })) - .toThrow('fixture direct method installation failure') + await expect(ctx.remote.$mount({ + package: '@fixture/direct-method-failure', + descriptors: [directDescriptor()], + })).rejects.toThrow('fixture direct method installation failure') } finally { spy.mockRestore() } - expect((ctx.api as unknown as Record).goals).toBeUndefined() + expect((ctx.remote as unknown as Record).goals).toBeUndefined() await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) }) - const retry = ctx.api.mount({ package: '@fixture/direct-method-retry', descriptors: [directDescriptor()] }) - expect(ctx.api.goals.create).toBeTypeOf('function') + const retry = await ctx.remote.$mount({ + package: '@fixture/direct-method-retry', + descriptors: [directDescriptor()], + }) + expect(ctx.remote.goals.create).toBeTypeOf('function') await retry() }) @@ -513,41 +484,41 @@ describe('Client TypeRT API', () => { return defineProperty(target, key, attributes) }) try { - expect(() => ctx.api.mount({ package: '@fixture/scoped-failure', descriptors: [contextDescriptor()] })) - .toThrow('fixture scoped installation failure') + await expect(ctx.remote.$mount({ package: '@fixture/scoped-failure', descriptors: [contextDescriptor()] })) + .rejects.toThrow('fixture scoped installation failure') } finally { spy.mockRestore() } - expect(ctx.get('goals')).toBeUndefined() + expect(ctx.get('remote.goals')).toBeUndefined() await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) }) - const retry = ctx.api.mount({ package: '@fixture/scoped-retry', descriptors: [contextDescriptor()] }) - expect((ctx.get('goals') as unknown as Record).rename).toBeTypeOf('function') + const retry = await ctx.remote.$mount({ package: '@fixture/scoped-retry', descriptors: [contextDescriptor()] }) + expect((ctx.get('remote.goals') as unknown as Record).rename).toBeTypeOf('function') await retry() }) it('unregisters an empty scoped namespace so another provider can claim its name', async () => { const ctx = await bench(vi.fn()) - const dispose = ctx.api.mount({ package: '@fixture/scoped', descriptors: [contextDescriptor()] }) - expect(ctx.get('goals')).toBeDefined() + const dispose = await ctx.remote.$mount({ package: '@fixture/scoped', descriptors: [contextDescriptor()] }) + expect(ctx.get('remote.goals')).toBeDefined() await dispose() - expect(ctx.get('goals')).toBeUndefined() + expect(ctx.get('remote.goals')).toBeUndefined() const replacement = { owner: 'replacement' } - const disposeReplacement = ctx.reflect.provide('goals', replacement) - expect(ctx.get('goals')).toBe(replacement) + const disposeReplacement = ctx.reflect.provide('remote.goals', replacement) + expect(ctx.get('remote.goals')).toBe(replacement) await disposeReplacement() }) it('throws RPC failures with the structured error as its cause', async () => { const rpcError = { code: 'internal' as const, message: 'host failed', details: {} } const ctx = await bench(vi.fn().mockResolvedValue({ ok: false, error: rpcError })) - ctx.api.mount({ package: '@fixture/goals', descriptors: [directDescriptor()] }) + await ctx.remote.$mount({ package: '@fixture/goals', descriptors: [directDescriptor()] }) let failure: unknown try { - await ctx.api.goals.create('agent-1', { objective: 'ship' }) + await ctx.remote.goals.create('agent-1', { objective: 'ship' }) } catch (error) { failure = error } diff --git a/packages/api/remotes/README.i18n.yaml b/packages/api/remotes/README.i18n.yaml index c3c13a8049..82947331c5 100644 --- a/packages/api/remotes/README.i18n.yaml +++ b/packages/api/remotes/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/api/remotes/README.md -README.md: cf54a56a849246d4efdca09cadd42e157064bdee -README.zh.md: 5cd7ef21c926440ca4df6d88ee4adfe87defcc3f +README.md: 7f6a2114d900413d972584c0f1c141b7f835ba36 +README.zh.md: cce263747d696570f362811556fa6f5c0be0a0f5 diff --git a/packages/api/remotes/README.md b/packages/api/remotes/README.md index cf54a56a84..7f6a2114d9 100644 --- a/packages/api/remotes/README.md +++ b/packages/api/remotes/README.md @@ -2,13 +2,13 @@ English | [中文](README.zh.md) -Two-sided BFF for Host Remote capabilities selected by this application. The Host entry owns Agent/Session identity policy; the Client entry imports generated `/remote` artifacts as runtime values, mounts each contribution through `ctx.api`, and re-exports their declaration merges. Client business packages depend on this facade rather than the Gateway implementation or individual Remote runtime entries. +Two-sided BFF for Host Remote capabilities selected by this application. The Host entry owns Agent/Session identity policy; the Client entry imports generated `/remote` artifacts as runtime values, mounts each contribution through `ctx.remote.$mount()`, and re-exports their declaration merges. Client business packages depend on this facade rather than the Gateway implementation or individual Remote runtime entries. `createApiRemoteAgentResolver()` reuses live Agents, resumes ordinary cold sessions, deduplicates concurrent resumes, preserves the subagent ownership fence, and configures the same resolver for TypeRT `agent` and `session` lookups. The standard Web API Proxy supplies its Agent defaults and scope setup, then uses the returned resolver for legacy methods, so migrated and unmigrated methods share one policy implementation. -The current Client assembly mounts only the Goal Remote contribution. Cordis effect ownership withdraws every contribution when this assembly unloads, while `@deepseek-ai/dsh-api-gateway/client` owns descriptor validation, concrete root and scoped methods, invocation, and cancellation. The Client entry consumes the shared `TypeRTClientApi` interface through Cordis and does not import the concrete Gateway. +The current Client assembly mounts only the Goal Remote contribution. Cordis effect ownership withdraws every contribution when this assembly unloads, while `@deepseek-ai/dsh-api-gateway/client` owns descriptor validation, traced namespace Services, direct and scoped methods, invocation, and cancellation. The Client entry consumes the shared `TypeRTClientRemote` interface through Cordis and does not import the concrete Gateway. -This package contains no transport or Host service discovery logic. Its Client face can be reused by Web or a future TUI that provides the same React-free `ctx.api` contract. +This package contains no transport or Host service discovery logic. Its Client face can be reused by Web or a future TUI that provides the same React-free `ctx.remote` contract. ## Model Experience diff --git a/packages/api/remotes/README.zh.md b/packages/api/remotes/README.zh.md index 5cd7ef21c9..cce263747d 100644 --- a/packages/api/remotes/README.zh.md +++ b/packages/api/remotes/README.zh.md @@ -2,13 +2,13 @@ [English](README.md) | 中文 -为本应用选定的 Host Remote 能力提供双侧 BFF。Host 入口负责 Agent/Session 身份策略;Client 入口以运行时值形式导入生成的 `/remote` 产物,通过 `ctx.api` 挂载每项贡献,并重新导出对应的声明合并。Client 业务包依赖该外观,而不依赖 Gateway 实现或单独的 Remote 运行时入口。 +为本应用选定的 Host Remote 能力提供双侧 BFF。Host 入口负责 Agent/Session 身份策略;Client 入口以运行时值形式导入生成的 `/remote` 产物,通过 `ctx.remote.$mount()` 挂载每项贡献,并重新导出对应的声明合并。Client 业务包依赖该外观,而不依赖 Gateway 实现或单独的 Remote 运行时入口。 `createApiRemoteAgentResolver()` 会复用 live Agent、恢复普通冷会话、对并发恢复去重、保留 subagent ownership fence,并为 TypeRT `agent` 和 `session` lookup 配置同一个 resolver。标准 Web API Proxy 提供 Agent 默认值和 scope 设置,再将返回的 resolver 用于旧方法,使已迁移与未迁移方法共用同一份策略实现。 -当前 Client 组合仅挂载 Goal Remote 贡献。该组合卸载时,Cordis effect 的所有权机制会撤回所有贡献;`@deepseek-ai/dsh-api-gateway/client` 负责描述符校验、具体的根级方法和作用域方法、调用与取消。Client 入口通过 Cordis 消费共享的 `TypeRTClientApi` 接口,不导入具体 Gateway。 +当前 Client 组合仅挂载 Goal Remote 贡献。该组合卸载时,Cordis effect 的所有权机制会撤回所有贡献;`@deepseek-ai/dsh-api-gateway/client` 负责描述符校验、可追踪 namespace Service、直接与作用域方法、调用与取消。Client 入口通过 Cordis 消费共享的 `TypeRTClientRemote` 接口,不导入具体 Gateway。 -本包不包含传输逻辑或 Host 服务发现逻辑。Web 或未来的 TUI 只要提供同一份不依赖 React 的 `ctx.api` 契约,均可复用其 Client face。 +本包不包含传输逻辑或 Host 服务发现逻辑。Web 或未来的 TUI 只要提供同一份不依赖 React 的 `ctx.remote` 契约,均可复用其 Client face。 ## 模型体验 diff --git a/packages/api/remotes/src/client/index.ts b/packages/api/remotes/src/client/index.ts index 1bc36b62ee..ebd342300e 100644 --- a/packages/api/remotes/src/client/index.ts +++ b/packages/api/remotes/src/client/index.ts @@ -2,25 +2,25 @@ import type { Context } from 'cordis' import goalsRemote from '@deepseek-ai/dsh-goal/remote' -import type { TypeRTClientApi } from '@deepseek-ai/dsh-type-meta' +import type { TypeRTClientRemote } from '@deepseek-ai/dsh-type-meta' -export type { TypeRTClientApi as ClientApi } from '@deepseek-ai/dsh-type-meta' +export type { TypeRTClientRemote as ClientRemote } from '@deepseek-ai/dsh-type-meta' export type {} from '@deepseek-ai/dsh-goal/remote' declare module 'cordis' { interface Context { - /** Generated direct Remote namespaces selected by this Client assembly. */ - api: TypeRTClientApi + /** Generated Remote namespaces selected by this Client assembly. */ + remote: TypeRTClientRemote } } -/** Required service: the typed Client API contribution mount. */ -export const inject = ['api'] +/** Required service: the typed Client Remote contribution mount. */ +export const inject = ['remote'] /** * Mount the Host capabilities explicitly selected for this Client assembly. * @param ctx - Client Cordis root carrying the typed API service. */ -export function apply(ctx: Context): void { - ctx.api.mount(goalsRemote) +export function apply(ctx: Context): Promise<() => Promise> { + return ctx.remote.$mount(goalsRemote) } diff --git a/packages/api/remotes/tests/built-lib.e2e.ts b/packages/api/remotes/tests/built-lib.e2e.ts index b8f6c81e98..af584cba7f 100644 --- a/packages/api/remotes/tests/built-lib.e2e.ts +++ b/packages/api/remotes/tests/built-lib.e2e.ts @@ -143,18 +143,18 @@ describe.skipIf(!requiredArtifacts)('Goal Remote built LIB chain', () => { let invalidRejected = false try { - await client.api.goals.create(rootAgent.id, { objective: 1 }) + await client.remote.goals.create(rootAgent.id, { objective: 1 }) } catch { invalidRejected = true } - const rootResult = await client.api.goals.create(rootAgent.id, { objective: 'root goal' }) - const rootEdit = await client.api.goals.edit( + const rootResult = await client.remote.goals.create(rootAgent.id, { objective: 'root goal' }) + const rootEdit = await client.remote.goals.edit( rootAgent.id, rootResult.ref, { objective: 'edited root goal' }, ) const agentContext = client.extend({ builtAgentId: scopedAgent.id }) - const scopedResult = await agentContext.goals.create({ objective: 'scoped goal', maxGoalRounds: 3 }) + const scopedResult = await agentContext.remote.goals.create({ objective: 'scoped goal', maxGoalRounds: 3 }) const result = { invalidRejected, rootResult, diff --git a/packages/client/runtime/src/client/agents/scope.ts b/packages/client/runtime/src/client/agents/scope.ts index ba4fd8ede7..1154d10feb 100644 --- a/packages/client/runtime/src/client/agents/scope.ts +++ b/packages/client/runtime/src/client/agents/scope.ts @@ -18,7 +18,12 @@ import { Context as CordisContext } from 'cordis' import type { Context, Fiber } from 'cordis' import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' -import type { TypeRTRemoteContextApi } from '@deepseek-ai/dsh-type-meta' +import type { TypeRTClientRemote, TypeRTRemoteContextApi } from '@deepseek-ai/dsh-type-meta' + +/** Client Cordis Context carrying one Agent identity and its scoped Remote namespaces. */ +export type AgentContext = Omit & { + readonly remote: TypeRTClientRemote & TypeRTRemoteContextApi<'agent'> +} /** Context tag written by {@link createScope}. */ const kScope = Symbol('dsh.client.scope') @@ -30,7 +35,7 @@ export interface AgentScopeHandle { * through it (passing it as the dispatch subject routes to this agent's * tagged listeners plus every untagged one). */ - ctx: Context & TypeRTRemoteContextApi<'agent'> + ctx: AgentContext /** Backing fiber (dispose tears down every scope-owned registration). */ fiber: Fiber } @@ -55,7 +60,7 @@ export function createScope(ctx: Context, key: SessionId): AgentScopeHandle { const tag = scopeOf(listenerCtx) return tag === undefined || tag === key }, - }) as Context & TypeRTRemoteContextApi<'agent'> + }) as AgentContext return { fiber, ctx: scoped, diff --git a/packages/client/runtime/src/client/contract/sessions.ts b/packages/client/runtime/src/client/contract/sessions.ts index 8e9c530720..2af2ef51c8 100644 --- a/packages/client/runtime/src/client/contract/sessions.ts +++ b/packages/client/runtime/src/client/contract/sessions.ts @@ -11,8 +11,8 @@ import type { Context } from 'cordis' import type { RpcResult, SessionId, SubagentAddress, } from '@deepseek-ai/dsh-client-connection/client' -import type { TypeRTRemoteContextApi } from '@deepseek-ai/dsh-type-meta' import type { HostObservable, SessionMaybeProvideInfo } from '@deepseek-ai/dsh-client-ui-slots' +import type { AgentContext } from '../agents/scope.ts' import type { SessionSearchResultItem } from '../sessions/manager.ts' import type { SessionBinding, SessionListState, SessionProvideDescriptor, @@ -20,8 +20,7 @@ import type { import type { SessionFace } from './session.ts' import type { ObservableSnapshot } from './store.ts' -/** Client Cordis Context carrying one Agent identity and its generated Remote namespaces. */ -export type AgentContext = Context & TypeRTRemoteContextApi<'agent'> +export type { AgentContext } from '../agents/scope.ts' /** The sessions-service face injected as `ctx.sessions`. */ export interface ISessions { diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index a9d2bb0d7d..b772e315a3 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -179,8 +179,8 @@ declare module 'cordis' { } } -/** Required services: the typed Remote API, wire handle, and Client TypeRT registry. */ -export const inject = ['api', 'connection', 'typert'] +/** Required services: the Remote root and Goal namespace, wire handle, and Client TypeRT registry. */ +export const inject = ['remote', 'remote.goals', 'connection', 'typert'] /** Mounts the browser runtime services and connection stream. * @param ctx - Client Cordis context. diff --git a/packages/client/runtime/tests/client-apply.spec.ts b/packages/client/runtime/tests/client-apply.spec.ts index 5635793122..e9b387fb00 100644 --- a/packages/client/runtime/tests/client-apply.spec.ts +++ b/packages/client/runtime/tests/client-apply.spec.ts @@ -38,7 +38,8 @@ async function mount(): Promise { }, } ctx.reflect.provide('connection', handle) - ctx.reflect.provide('api', {}) + ctx.reflect.provide('remote', {}) + ctx.reflect.provide('remote.goals', {}) await ctx.plugin(RuntimeClient).await() return bench } diff --git a/packages/client/runtime/tests/wire-events.spec.ts b/packages/client/runtime/tests/wire-events.spec.ts index 5ab644682a..703c5b1728 100644 --- a/packages/client/runtime/tests/wire-events.spec.ts +++ b/packages/client/runtime/tests/wire-events.spec.ts @@ -32,7 +32,8 @@ async function mount(): Promise { }, } ctx.reflect.provide('connection', handle) - ctx.reflect.provide('api', {}) + ctx.reflect.provide('remote', {}) + ctx.reflect.provide('remote.goals', {}) await ctx.plugin(RuntimeClient).await() return bench } diff --git a/packages/client/ui-goal/README.i18n.yaml b/packages/client/ui-goal/README.i18n.yaml index f30f14ed48..55853ef4bd 100644 --- a/packages/client/ui-goal/README.i18n.yaml +++ b/packages/client/ui-goal/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-goal/README.md -README.md: b99aaf624a7d669879ba668938ee455e3cdc68ad -README.zh.md: 3d823d013066bc912398f61c85553887e05ca3b4 +README.md: a53fb3a89eaee364cb025ca728ca42ce934887b0 +README.zh.md: 1ad9f50aee5b103f6455e4d4b7d29fa9eb29a108 diff --git a/packages/client/ui-goal/README.md b/packages/client/ui-goal/README.md index b99aaf624a..a53fb3a89e 100644 --- a/packages/client/ui-goal/README.md +++ b/packages/client/ui-goal/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Goal surface plugin, browser half: the `GoalBar` strip is the second standalone card in the `conversation.input.dock` composer-context stack (order 10, after Todo and before Queue). The live goal arrives through `useProjection('goal')` — the host-computed whole value seeded by the history tail page and updated by `session/projection` frames — so the plugin owns no domain store, refresh chain, or event listener. The slot inject face carries only the four mutation verbs (edit / pause / resume / clear through `ctx.api.goals` — an active goal offers the pause action, a paused one resume); each reads the CAS ref from the session's current projected value at call time and surfaces the rejected Remote error inline. The strip single-flights mutations synchronously because React's pending render cannot fence same-frame clicks; after a successful clear it immediately suppresses that exact goal id while the authoritative null projection catches up. Goal creation stays on the `/goal` host command; loading, absent, completed, and successfully cleared goals render nothing. +Goal surface plugin, browser half: the `GoalBar` strip is the second standalone card in the `conversation.input.dock` composer-context stack (order 10, after Todo and before Queue). The live goal arrives through `useProjection('goal')` — the host-computed whole value seeded by the history tail page and updated by `session/projection` frames — so the plugin owns no domain store, refresh chain, or event listener. The slot inject face carries only the four mutation verbs (edit / pause / resume / clear through `ctx.remote.goals` — an active goal offers the pause action, a paused one resume); each reads the CAS ref from the session's current projected value at call time and surfaces the rejected Remote error inline. The strip single-flights mutations synchronously because React's pending render cannot fence same-frame clicks; after a successful clear it immediately suppresses that exact goal id while the authoritative null projection catches up. Goal creation stays on the `/goal` host command; loading, absent, completed, and successfully cleared goals render nothing. The `/client` export surface is the plugin body (`apply`/`inject`), the `GoalBar`/`GoalDock` components, and the injected verb face types. diff --git a/packages/client/ui-goal/README.zh.md b/packages/client/ui-goal/README.zh.md index 3d823d0130..1ad9f50aee 100644 --- a/packages/client/ui-goal/README.zh.md +++ b/packages/client/ui-goal/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -Goal 界面插件(浏览器端部分):`GoalBar` 条带是 `conversation.input.dock` composer 上下文堆栈中的第二张独立卡片(order 10,位于 Todo 之后、Queue 之前)。活值经 `useProjection('goal')` 到达——host 计算的全量值由历史尾页播种、由 `session/projection` 帧更新——因此本插件不持有领域 store、不设刷新链、不挂事件监听。slot 注入面只携带四个变更动词(edit / pause / resume / clear,经 `ctx.api.goals` 调用——active 的 goal 提供暂停动作,paused 的提供恢复);每个动词在调用时从会话当前投影值读取 CAS ref,并将 Remote 调用的拒绝错误内联呈现。由于 React 的 pending 渲染无法拦住同一帧内的点击,横条会同步为变更建立 single-flight 防护;清除成功后,会立即抑制该 goal id 对应的目标显示,直到权威的 null 投影追上。goal 的创建仍归 `/goal` host 命令;加载中、无 goal、已完成和已成功清除的 goal 一律不渲染。 +Goal 界面插件(浏览器端部分):`GoalBar` 条带是 `conversation.input.dock` composer 上下文堆栈中的第二张独立卡片(order 10,位于 Todo 之后、Queue 之前)。活值经 `useProjection('goal')` 到达——host 计算的全量值由历史尾页播种、由 `session/projection` 帧更新——因此本插件不持有领域 store、不设刷新链、不挂事件监听。slot 注入面只携带四个变更动词(edit / pause / resume / clear,经 `ctx.remote.goals` 调用——active 的 goal 提供暂停动作,paused 的提供恢复);每个动词在调用时从会话当前投影值读取 CAS ref,并将 Remote 调用的拒绝错误内联呈现。由于 React 的 pending 渲染无法拦住同一帧内的点击,横条会同步为变更建立 single-flight 防护;清除成功后,会立即抑制该 goal id 对应的目标显示,直到权威的 null 投影追上。goal 的创建仍归 `/goal` host 命令;加载中、无 goal、已完成和已成功清除的 goal 一律不渲染。 `/client` 的导出接口包括插件本体(`apply`/`inject`)、`GoalBar`/`GoalDock` 组件与注入动词面类型。 diff --git a/packages/client/ui-goal/src/client/index.ts b/packages/client/ui-goal/src/client/index.ts index bea4f67df2..2c041e0eae 100644 --- a/packages/client/ui-goal/src/client/index.ts +++ b/packages/client/ui-goal/src/client/index.ts @@ -9,7 +9,7 @@ * Goal creation stays on the /goal host command. */ import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client' -// Type-only: pulls the generated Remote API and ctx.api merge through the Client assembly boundary. +// Type-only: pulls the generated Remote API and ctx.remote merge through the Client assembly boundary. import type {} from '@deepseek-ai/dsh-api-remotes/client' // Type-only: pulls the ui-conversation SlotMap merge (the input.dock entry). import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' @@ -36,7 +36,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { const NS = 'goal' /** Required services: slots for the dock entry, sessions for the projected ref, API for Remote mutations, locale for the copy. */ -export const inject = ['slots', 'sessions', 'api', 'locale'] +export const inject = ['slots', 'sessions', 'remote', 'remote.goals', 'locale'] /** Map one generated Remote call, including synchronous namespace lookup failures, onto the strip's inline-render shape. */ async function settle(invoke: () => Promise): Promise { @@ -94,22 +94,22 @@ export function apply(ctx: ClientContext): void { onEdit: async (objective) => { const ref = refOf(sessionId) if (ref === undefined) return noCurrentGoal - return settle(() => ctx.api.goals.edit(sessionId, ref, { objective })) + return settle(() => ctx.remote.goals.edit(sessionId, ref, { objective })) }, onPause: async () => { const ref = refOf(sessionId) if (ref === undefined) return noCurrentGoal - return settle(() => ctx.api.goals.pause(sessionId, ref)) + return settle(() => ctx.remote.goals.pause(sessionId, ref)) }, onResume: async () => { const ref = refOf(sessionId) if (ref === undefined) return noCurrentGoal - return settle(() => ctx.api.goals.resume(sessionId, ref)) + return settle(() => ctx.remote.goals.resume(sessionId, ref)) }, onClear: async () => { const ref = refOf(sessionId) if (ref === undefined) return noCurrentGoal - return settle(() => ctx.api.goals.clear(sessionId, ref)) + return settle(() => ctx.remote.goals.clear(sessionId, ref)) }, }), }, GoalDock)) diff --git a/packages/client/ui-goal/tests/browser-plugin.spec.tsx b/packages/client/ui-goal/tests/browser-plugin.spec.tsx index f900682712..756968136e 100644 --- a/packages/client/ui-goal/tests/browser-plugin.spec.tsx +++ b/packages/client/ui-goal/tests/browser-plugin.spec.tsx @@ -10,7 +10,7 @@ * plugin fiber (HMR safety). The node half and the invariant companion are * exercised over the same Context. */ -import { Context } from 'cordis' +import { Context, Service } from 'cordis' import { describe, expect, it, vi } from 'vitest' import { cleanup, render } from '@testing-library/react' import { afterEach } from 'vitest' @@ -71,8 +71,17 @@ async function bench(options: { clear: answer(`${prefix}/clear`, ref), }) let activeGoals: ReturnType | undefined = goals('goals') - ctx.provide('api', { - get goals() { return activeGoals }, + class RemoteService extends Service { + constructor(serviceCtx: Context) { + super(serviceCtx, 'remote') + } + } + new RemoteService(ctx) + ctx.provide('remote.goals', { + get edit() { return activeGoals?.edit }, + get pause() { return activeGoals?.pause }, + get resume() { return activeGoals?.resume }, + get clear() { return activeGoals?.clear }, }) await ctx.plugin(SlotsService).await() ctx.slots.register({ diff --git a/packages/typert/generator/tests/remote-model.spec.ts b/packages/typert/generator/tests/remote-model.spec.ts index 27bdac2fac..eaaf680cc6 100644 --- a/packages/typert/generator/tests/remote-model.spec.ts +++ b/packages/typert/generator/tests/remote-model.spec.ts @@ -593,8 +593,8 @@ const created: Promise = create('agent-1', { title: 'ship' }) const cancellable: Promise = create('agent-1', { title: 'ship' }, new AbortController().signal) const createdScoped: Promise = createScoped({ title: 'ship' }) const renamed: Promise = rename({ ref: 'goal-1', title: 'land' }) -declare const ctx: { api: TypeRTRemoteNamespaceMap } -const navigated: Promise = ctx.api.goals.create('agent-1', { title: 'navigate' }) +declare const ctx: { remote: TypeRTRemoteNamespaceMap } +const navigated: Promise = ctx.remote.goals.create('agent-1', { title: 'navigate' }) void contribution void created void cancellable @@ -643,7 +643,7 @@ void navigated readFile: path => ts.sys.readFile(path), realpath: path => ts.sys.realpath?.(path) ?? path, }) - const navigation = 'ctx.api.goals.create' + const navigation = 'ctx.remote.goals.create' const position = consumerSource.indexOf(navigation) + navigation.lastIndexOf('create') + 1 const definitions = languageService.getDefinitionAtPosition(consumerPath, position) const generatedDefinition = definitions?.find(candidate => candidate.fileName === declarationPath) @@ -672,8 +672,8 @@ function assertRemoteConsumerWithoutImportHasNoNamespace(consumerRoot: string): const consumerPath = join(consumerRoot, 'consumer-without-remote.ts') writeFileSync(consumerPath, ` import type { TypeRTRemoteNamespaceMap } from '@deepseek-ai/dsh-type-meta' -declare const ctx: { api: TypeRTRemoteNamespaceMap } -ctx.api.goals.create('agent-1', { title: 'must not compile' }) +declare const ctx: { remote: TypeRTRemoteNamespaceMap } +ctx.remote.goals.create('agent-1', { title: 'must not compile' }) `) const configPath = join(consumerRoot, 'tsconfig.consumer-without-remote.json') writeFileSync(configPath, JSON.stringify({ diff --git a/packages/typert/type-meta/src/index.ts b/packages/typert/type-meta/src/index.ts index 2f687f985f..774c6d3b32 100644 --- a/packages/typert/type-meta/src/index.ts +++ b/packages/typert/type-meta/src/index.ts @@ -41,7 +41,7 @@ export type { InvocationDescriptor, InvocationParameterDescriptor, InvocationSourceLocation, - TypeRTClientApi, + TypeRTClientRemote, TypeRTClientContextBinder, TypeRTCodec, TypeRTContext, diff --git a/packages/typert/type-meta/src/types.ts b/packages/typert/type-meta/src/types.ts index ed309b7857..5e7c20cd7c 100644 --- a/packages/typert/type-meta/src/types.ts +++ b/packages/typert/type-meta/src/types.ts @@ -77,7 +77,7 @@ export type TypeRTRemoteContextApi = { TypeRTRemoteContextNamespace } -/** Merge-extensible direct namespace surface generated for Client API services. */ +/** Merge-extensible direct namespace surface generated for Client Remote services. */ export interface TypeRTRemoteNamespaceMap {} /** Awaitable disposer returned by Cordis-owned TypeRT registrations. */ @@ -176,14 +176,14 @@ export interface TypeRTRemoteContribution { readonly descriptors: readonly InvocationDescriptor[] } -/** Client API capability implemented by the Gateway and consumed by Remote assemblies. */ -export interface TypeRTClientApi extends TypeRTRemoteNamespaceMap { +/** Client Remote capability implemented by the Gateway and consumed by Remote assemblies. */ +export interface TypeRTClientRemote extends TypeRTRemoteNamespaceMap { /** * Mount one generated Host-for-Client contribution in the caller's fiber. * @param contribution - explicitly selected Remote package artifact. - * @returns disposer withdrawing descriptors and concrete methods together. + * @returns disposer after namespace services and concrete methods are ready. */ - mount(contribution: TypeRTRemoteContribution): TypeRTDisposer + $mount(contribution: TypeRTRemoteContribution): Promise } /** From d362cdb54f228ddd0edda4771b5db64420eec04e Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 7 Aug 2026 18:35:50 +0800 Subject: [PATCH 51/63] refactor(typert): rename RemoteContext to RemoteScope --- ...08-02-typert-remote-method-calls.i18n.yaml | 4 +-- .../2026-08-02-typert-remote-method-calls.md | 36 +++++++++---------- ...026-08-02-typert-remote-method-calls.zh.md | 36 +++++++++---------- docs/api-gateway.i18n.yaml | 4 +-- docs/api-gateway.md | 14 ++++---- docs/api-gateway.zh.md | 14 ++++---- docs/development.i18n.yaml | 4 +-- docs/development.md | 2 +- docs/development.zh.md | 2 +- packages/api/gateway/README.i18n.yaml | 4 +-- packages/api/gateway/README.md | 4 +-- packages/api/gateway/README.zh.md | 4 +-- packages/api/gateway/tests/client.spec.ts | 6 ++-- packages/api/gateway/tests/gateway.spec.ts | 10 +++--- .../client/runtime/src/client/agents/scope.ts | 4 +-- packages/typert/generator/src/analyzer.ts | 14 ++++---- packages/typert/generator/src/emitter.ts | 2 +- .../remote-model/packages/remote/src/index.ts | 4 +-- .../fixtures/remote-model/type-meta.d.ts | 4 +-- .../generator/tests/remote-model.spec.ts | 18 +++++----- packages/typert/type-meta/README.i18n.yaml | 4 +-- packages/typert/type-meta/README.md | 4 +-- packages/typert/type-meta/README.zh.md | 4 +-- packages/typert/type-meta/src/index.ts | 14 ++++---- packages/typert/type-meta/src/types.ts | 20 +++++------ .../type-meta/tests/fixtures/source-launch.ts | 4 +-- .../typert/type-meta/tests/type-meta.spec.ts | 14 ++++---- 27 files changed, 127 insertions(+), 127 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml index 341bf44923..71ded0fa8d 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md -2026-08-02-typert-remote-method-calls.md: a8254090e042e4b359ae74fc5c19bad8abc5ef89 -2026-08-02-typert-remote-method-calls.zh.md: f1b7e5f9c61b474379962ce007e5d6bb966e5ebd +2026-08-02-typert-remote-method-calls.md: 215c647bcd7413b92625ee670022dc7316e3045a +2026-08-02-typert-remote-method-calls.zh.md: 0ce431b7cbc948e937f722f2769b15a1d26dcec9 diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md index a8254090e0..215c647bcd 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md @@ -16,7 +16,7 @@ The Host and Browser Client use separate TypeScript Programs because each side a ## Decision -A business Service extends `GatewayService` and declares callable methods with `@Remote` or `@RemoteContext()`. A Service that already has another base class may instead expose the same binding through `bindTypeRTGateway()`. TypeRT generates the Host-local reflection artifact and a platform-independent Remote consumer projection from the Host Program. The Client Program continues to generate its own local reflection artifact independently. +A business Service extends `GatewayService` and declares callable methods with `@Remote` or `@RemoteScope()`. A Service that already has another base class may instead expose the same binding through `bindTypeRTGateway()`. TypeRT generates the Host-local reflection artifact and a platform-independent Remote consumer projection from the Host Program. The Client Program continues to generate its own local reflection artifact independently. The Remote consumer projection contains `.d.ts`, `.d.ts.map`, and `.js` files. The `.d.ts` exposes only methods marked with a Remote decorator and refers to the business package's single public type symbols. The `.d.ts.map` navigates consumer API methods back to their Host business method implementations. The `.js` carries endpoint, parameter, Context, and Zod information for the same contract. At the assembly layer, the Browser Client mounts the required Remote JS contributions onto the Client Remote Service. The projection and Remote abstraction remain platform-independent so that a future TUI can reuse them. @@ -64,7 +64,7 @@ export class GoalService extends GatewayService { `goals` is the explicit Cordis service key passed to `super()` and is the default wire namespace. Pass a `namespace` option as the third argument only when the protocol namespace genuinely needs to differ from the service key. -Use `@RemoteContext()` when the Service receiver must be resolved within an isolated kind of Context. Context identity does not enter the business method's parameters: +Use `@RemoteScope()` when the Service receiver must be resolved within an isolated kind of Context. Scope identity does not enter the business method's parameters: ```text export class ScopedGoalService extends GatewayService { @@ -72,28 +72,28 @@ export class ScopedGoalService extends GatewayService { super(ctx, 'goals') } - @RemoteContext('agent', 'create') + @RemoteScope('agent', 'create') remoteExportCreate(request: CreateGoalRequest): Promise { // Runs against the goals service resolved from the Agent Context. } } ``` -An endpoint selects exactly one invocation mode. A flow that needs an explicit `Agent` parameter uses `@Remote`. A flow that first switches to an Agent Context and then resolves a scoped receiver uses `@RemoteContext('agent')`. TypeRT does not infer either mode from the method body or from a missing parameter. +An endpoint selects exactly one invocation mode. A flow that needs an explicit `Agent` parameter uses `@Remote`. A flow that first switches to an Agent Context and then resolves a scoped receiver uses `@RemoteScope('agent')`. TypeRT does not infer either mode from the method body or from a missing parameter. -Business packages depend only on the lightweight `@deepseek-ai/dsh-type-meta`. It provides `GatewayService` and declaration protocols for decorators, the binding fallback, lookup, Remote Context, and descriptors, without depending on the TypeScript compiler, Zod, HTTP, or the Client runtime. +Business packages depend only on the lightweight `@deepseek-ai/dsh-type-meta`. It provides `GatewayService` and declaration protocols for decorators, the binding fallback, lookup, Remote Scope, and descriptors, without depending on the TypeScript compiler, Zod, HTTP, or the Client runtime. A method that cooperatively supports cancellation declares `signal: AbortSignal` as its final Host parameter. This reserved parameter is not a business value, lookup, or JSON field. The generated consumer method exposes it as a final optional parameter so ordinary calls remain unchanged while callers that own cancellation can pass a signal. ## Decorators and the explicit Gateway facet -A decorator only states that a method participates in the Remote contract. It performs no runtime type reflection and injects no hidden symbol into a Service constructor. The arguments to `@Remote('create')` and `@RemoteContext('agent', 'create')` are external method names; the decorated member may be the business method itself or an adapter such as `remoteExportCreate`. The member name becomes the external method name only when no alias is provided. Inheriting `GatewayService` is the normal explicit declaration that a Service has joined the Gateway; its public readonly `typertGateway` field keeps the binding visible on the runtime instance. +A decorator only states that a method participates in the Remote contract. It performs no runtime type reflection and injects no hidden symbol into a Service constructor. The arguments to `@Remote('create')` and `@RemoteScope('agent', 'create')` are external method names; the decorated member may be the business method itself or an adapter such as `remoteExportCreate`. The member name becomes the external method name only when no alias is provided. Inheriting `GatewayService` is the normal explicit declaration that a Service has joined the Gateway; its public readonly `typertGateway` field keeps the binding visible on the runtime instance. In SRC mode, the decorator may record the prototype, method name, and invocation mode in a `WeakMap` internal to `dsh-type-meta`. It writes no custom properties to a Service instance, prototype, constructor, or method function. In LIB mode, the TypeRT compiler performs strict method discovery, type resolution, and descriptor generation. It accepts a literal service key in `GatewayService`'s direct `super()` call or the explicit binding fallback; generation neither rewrites business source nor injects hidden registration metadata. -## Lookup and Remote Context registration +## Lookup and Remote Scope registration The Gateway has no built-in branches for Agent, Session, or other business objects. Each object-owning package provides both a static declaration and a runtime provider: @@ -115,7 +115,7 @@ The static declaration tells TypeRT that `Agent` corresponds to `SessionId` on t Lookup objects such as Agent and Session may each occupy only one top-level parameter position. An ordinary JSON request may be passed as another complete parameter, but this design does not support `request.agent`, object destructuring, arrays of objects, nested lookups, or searching arbitrary complex structures for IDs. -Remote Context uses a separate merge-extensible map and provider. The Agent package registers an `agent` Context provider that locates the Agent Context from its wire identity and resolves the Service key named by the descriptor from that Context. The Gateway does not know the internal structure of an Agent Context. +Remote Scope uses a separate merge-extensible map and Context provider. The Agent package registers an `agent` provider that locates the Agent Context from its wire identity and resolves the Service key named by the descriptor from that Context. The Gateway does not know the internal structure of an Agent Context. The Client also registers an `agent` Context binder. The binder only retrieves a `SessionId` from the Context in which a call occurs; it neither enumerates Scopes nor copies methods into each one. A Cordis Service tracker automatically rebinds a scoped namespace to the current Agent Context. @@ -267,7 +267,7 @@ interface TypeRTRemoteNamespaceMap { goals: TypeRTRemoteNamespace$676f616c73 } -interface TypeRTRemoteContextMap { +interface TypeRTRemoteScopeMap { 'agent:goals/create': ( request: CreateGoalRequest, signal?: AbortSignal, @@ -277,14 +277,14 @@ interface TypeRTRemoteContextMap { `TypeRTRemoteMap` preserves canonical endpoint signatures for protocol typing and reflection. The root Remote type reads `TypeRTRemoteNamespaceMap` directly instead of deriving methods indirectly through a key-remapped mapped type; the TypeScript Language Service cannot reliably navigate such indirect properties through a declaration map. A namespace interface name encodes the namespace's UTF-8 bytes as hexadecimal, so `goals` deterministically becomes `TypeRTRemoteNamespace$676f616c73`. Different packages generate the same interface name for the same namespace and use module augmentation to merge their methods, while `TypeRTRemoteNamespaceMap.goals` always refers to that one type. -TypeRT projects `TypeRTRemoteContextMap` onto a dedicated Scope type according to its Context key. The final programming interface remains: +TypeRT projects `TypeRTRemoteScopeMap` onto a dedicated Scope type according to its Context key. The final programming interface remains: ```text ctx.remote.goals.create(agentId, request) agentCtx.remote.goals.create(request) ``` -The Agent Scope supplies its own `SessionId` automatically. A `@Remote` method with an `agent` lookup can therefore generate both root and scoped consumer signatures. A `@RemoteContext('agent')` method also omits a separate Context identity, but generates only the scoped signature. The root `Context` exposes direct namespaces through `ctx.remote`, while `AgentContext.remote` intersects that direct surface with the scoped surface. A future TUI must preserve the same distinction. +The Agent Scope supplies its own `SessionId` automatically. A `@Remote` method with an `agent` lookup can therefore generate both root and scoped consumer signatures. A `@RemoteScope('agent')` method also omits a separate Scope identity, but generates only the scoped signature. The root `Context` exposes direct namespaces through `ctx.remote`, while `AgentContext.remote` intersects that direct surface with the scoped surface. A future TUI must preserve the same distinction. `TypeRTClientRemote` remains platform-independent, and the Browser Client exposes it as `ctx.remote`. If a future TUI reuses this type, it must likewise access it through a dedicated Remote object and Agent Scope rather than treating the Host `Context` as a broader Service collection. Public Service methods without Remote markers do not enter the Remote maps. @@ -313,7 +313,7 @@ Client business packages depend only on `@deepseek-ai/dsh-api-remotes/client`, n The Client Remote Service materializes each `@Remote` descriptor as a real function on a `remote.` child Service. The function constructs named `args` in descriptor parameter order, applies the Client's strict codec, and then calls `ctx.connection.rpc.call('/api', endpoint, { args }, signal)`. For a cancellation-aware descriptor, the generated function accepts a final optional signal and combines it with the contribution mount lifetime; unmounting therefore cancels every in-flight carrier call, while a caller can cancel one call independently. -Neither a direct descriptor with `scope` nor a `@RemoteContext` descriptor copies functions into every Agent Scope. The Client Remote Service creates one Cordis child Service per namespace, registered as `remote.`, and materializes direct and scoped variants on it. Accessing a method through `agentCtx.remote.goals` captures the current Agent Context before returning the callable handle. The method then asks the corresponding Context binder for identity from that Context. A direct scoped projection substitutes this identity at the lookup position named by `scope.wire`; a Context descriptor writes the identity into the receiver's separate wire field. Both issue the same kind of `/api` call. +Neither a direct descriptor with `scope` nor a `@RemoteScope` descriptor copies functions into every Agent Scope. The Client Remote Service creates one Cordis child Service per namespace, registered as `remote.`, and materializes direct and scoped variants on it. Accessing a method through `agentCtx.remote.goals` captures the current Agent Context before returning the callable handle. The method then asks the corresponding Context binder for identity from that Context. A direct scoped projection substitutes this identity at the lookup position named by `scope.wire`; a Remote Scope descriptor writes the identity into the receiver's separate wire field. Both issue the same kind of `/api` call. ```text root ctx.remote.goals.create(agentId, request) @@ -327,7 +327,7 @@ agentCtx.remote.goals.create(request) → ctx.connection.rpc.call('/api', 'goals/create', { args }) ``` -The root `Context` merges only the direct `TypeRTClientRemote` surface. `AgentContext` replaces that property with the intersection of `TypeRTClientRemote` and `TypeRTRemoteContextApi<'agent'>`, so scoped-only methods remain unavailable from root code. If a caller bypasses the type system and dynamically calls a scoped-only method from Root, the binder reports an explicit error. If the Client already has a Cordis service named `remote.`, or two contributions claim the same namespace and method incompatibly, mounting fails instead of overwriting the existing service. +The root `Context` merges only the direct `TypeRTClientRemote` surface. `AgentContext` replaces that property with the intersection of `TypeRTClientRemote` and `TypeRTRemoteScopeApi<'agent'>`, so scoped-only methods remain unavailable from root code. If a caller bypasses the type system and dynamically calls a scoped-only method from Root, the binder reports an explicit error. If the Client already has a Cordis service named `remote.`, or two contributions claim the same namespace and method incompatibly, mounting fails instead of overwriting the existing service. Generated Remote JS contains only descriptors, symbol keys, and codecs; it does not bundle Host Service implementations. The Client Remote Service creates real functions from that data, so the runtime does not depend on a JavaScript Proxy. A Proxy remains an implementation option but is not a source of types or reflection. @@ -337,7 +337,7 @@ Remote API is a consumer capability, not a synonym for Browser API. The shipped Remote DTS, Remote JS, `TypeRTClientRemote`, `InvocationDescriptor`, the Remote RPC data protocol, and Context binders must not depend on the DOM, Browser module loaders, or HTTP. Through Connection, the Browser Client encodes descriptor-materialized methods as `/api` RPC calls. -A future TUI can join the same call abstraction without changing business decorators, Remote maps, or the shape of API calls. The TUI-visible API must still be generated exclusively from `@Remote` and `@RemoteContext`; sharing a process with the Host must not allow it to bypass Remote restrictions and expose Service methods directly. +A future TUI can join the same call abstraction without changing business decorators, Remote maps, or the shape of API calls. The TUI-visible API must still be generated exclusively from `@Remote` and `@RemoteScope`; sharing a process with the Host must not allow it to bypass Remote restrictions and expose Service methods directly. TUI runtime mounting, carriers, Agent Scope association, and SRC startup wiring are outside this phase. @@ -345,7 +345,7 @@ The Web already depends on build artifacts such as `lib/client.js`, so it requir ## SRC and LIB operating modes -SRC supports local source startup. The `WeakMap` records created by `@Remote` and `@RemoteContext()` provide method names and invocation modes. At runtime, the system reads ordered parameter names from the JavaScript function signature and combines them with registered lookup/Context providers to produce a permissive descriptor. +SRC supports local source startup. The `WeakMap` records created by `@Remote` and `@RemoteScope()` provide method names and invocation modes. At runtime, the system reads ordered parameter names from the JavaScript function signature and combines them with registered lookup/Context providers to produce a permissive descriptor. For example, `@Remote('create') remoteExportCreate(agent, request, signal)` resolves to the external method `create`, implementation member `remoteExportCreate`, two top-level business parameters, and one cancellation injection point. Lookup registration rewrites `agent` to the wire field `agentId`, `request` is passed as a same-named JSON parameter, and the final `signal` stays outside the payload. SRC does not start a `ts.Program`, use a preload or loader hook, generate or rewrite source, or inspect the internal structure of an ordinary JSON object. @@ -365,7 +365,7 @@ Invocation resolves the descriptor, receiver, lookup providers, and Context prov An ordinary `@Remote` call retains the original Service instance as receiver. After lookups succeed, the Gateway calls the member identified by `implementation ?? method` with parameters in descriptor order, followed by the carrier signal when the descriptor declares cancellation. -A `@RemoteContext('agent')` call first asks the Agent Context provider to resolve the wire identity, then reads the descriptor's service key from that Context and invokes the scoped receiver. The business method receives neither a hidden Context parameter nor an Agent ID. +A `@RemoteScope('agent')` call first asks the Agent Context provider to resolve the wire identity, then reads the descriptor's service key from that Context and invokes the scoped receiver. The business method receives neither a hidden Context parameter nor an Agent ID. ```text ctx.typertGateway.invoke({ namespace, method, args, signal }) @@ -448,7 +448,7 @@ The Gateway registers only its ownership matcher and RPC handler with Connection ## Package boundaries -- `@deepseek-ai/dsh-type-meta`: lightweight protocols for decorators, bindings, lookup, Remote Context, and descriptors. +- `@deepseek-ai/dsh-type-meta`: lightweight protocols for decorators, bindings, lookup, Remote Scope, and descriptors. - TypeRT generator: analyzes Host/Client Programs, generates local faces and Remote consumer projections, and emits canonical symbol/Zod information. - TypeRT runtime: separately stores the current environment's local reflection and imported Remote contributions. - `@deepseek-ai/dsh-api-gateway`: its default entry associates Host definitions with Services, claims Remote endpoints, performs lookup, resolves Context receivers, invokes methods, encodes results, and registers an `/api` interceptor with Connection; its `/client` entry mounts Remote contributions, creates strict Remote namespace Services and methods, and delegates calls to `ctx.connection.rpc`. The entries share the Remote protocol but do not import each other's Cordis interface merges. @@ -460,7 +460,7 @@ The Gateway registers only its ownership matcher and RPC handler with Connection ## Shipped scope and deferred work -The shipped vertical path is `@deepseek-ai/dsh-goal/remote → Browser Client Remote → Connection RPC /api → Host Gateway → GoalService.remoteExportCreate()`. The same direct descriptor with an Agent lookup supports both `ctx.remote.goals.create(agentId, request)` and `agentCtx.remote.goals.create(request)`. Ordinary cold sessions are resumed through `agentFor()` during lookup, while subagent-owned identities retain the existing `agent-busy` fence; `@RemoteContext('agent')` remains the distinct scoped-receiver mode. +The shipped vertical path is `@deepseek-ai/dsh-goal/remote → Browser Client Remote → Connection RPC /api → Host Gateway → GoalService.remoteExportCreate()`. The same direct descriptor with an Agent lookup supports both `ctx.remote.goals.create(agentId, request)` and `agentCtx.remote.goals.create(request)`. Ordinary cold sessions are resumed through `agentFor()` during lookup, while subagent-owned identities retain the existing `agent-busy` fence; `@RemoteScope('agent')` remains the distinct scoped-receiver mode. Connection supplies the shared-channel interceptor and current HTTP carrier mapping. WebSocket migration, the TUI runtime and carrier, TUI Agent Scope wiring, Permission/Approval state machines, Session event streams, call authorization, retries, idempotency, and cross-version protocol compatibility remain outside this decision. diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md index f1b7e5f9c6..0ce431b7cb 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md @@ -16,7 +16,7 @@ Host 与 Browser Client 使用独立的 TypeScript Program,因为两边会以 ## 决策 -业务 Service 继承 `GatewayService`,并通过 `@Remote` 或 `@RemoteContext()` 声明可调用方法;已有其他基类的 Service 可以改用 `bindTypeRTGateway()` 暴露同一绑定。TypeRT 从 Host Program 生成 Host 本地反射产物和平台无关的 Remote 消费端投影;Client Program 继续独立生成自己的本地反射产物。 +业务 Service 继承 `GatewayService`,并通过 `@Remote` 或 `@RemoteScope()` 声明可调用方法;已有其他基类的 Service 可以改用 `bindTypeRTGateway()` 暴露同一绑定。TypeRT 从 Host Program 生成 Host 本地反射产物和平台无关的 Remote 消费端投影;Client Program 继续独立生成自己的本地反射产物。 Remote 消费端投影同时包含 `.d.ts`、`.d.ts.map` 和 `.js`。`.d.ts` 只暴露被 Remote decorator 标记的方法,并引用业务包唯一的公共类型符号;`.d.ts.map` 把消费端 API 方法导航回 Host 业务方法实现;`.js` 携带同一契约的 endpoint、参数、Context 和 Zod 信息。Browser Client 在 assembly 层把需要的 Remote JS 贡献集中挂到 Client Remote Service;该投影和 Remote 抽象保持平台无关,以便未来 TUI 复用。 @@ -64,7 +64,7 @@ export class GoalService extends GatewayService { `goals` 是传给 `super()` 的明确 Cordis service key,并默认作为 wire namespace。只有协议 namespace 确实需要与 service key 不同时,才通过第三个参数传入 `namespace` 选项。 -需要在某类隔离 Context 中查找 Service receiver 时使用 `@RemoteContext()`。Context identity 不进入业务方法参数: +需要在某类隔离 Context 中查找 Service receiver 时使用 `@RemoteScope()`。Scope identity 不进入业务方法参数: ```text export class ScopedGoalService extends GatewayService { @@ -72,28 +72,28 @@ export class ScopedGoalService extends GatewayService { super(ctx, 'goals') } - @RemoteContext('agent', 'create') + @RemoteScope('agent', 'create') remoteExportCreate(request: CreateGoalRequest): Promise { // Runs against the goals service resolved from the Agent Context. } } ``` -同一个 endpoint 只能选择一种调用模式。需要显式 `Agent` 参数的流程使用 `@Remote`;需要切换到 Agent Context 再解析 scoped receiver 的流程使用 `@RemoteContext('agent')`,两者不会由 TypeRT 根据方法体或参数缺失自动猜测。 +同一个 endpoint 只能选择一种调用模式。需要显式 `Agent` 参数的流程使用 `@Remote`;需要切换到 Agent Context 再解析 scoped receiver 的流程使用 `@RemoteScope('agent')`,两者不会由 TypeRT 根据方法体或参数缺失自动猜测。 -业务包只依赖轻量的 `@deepseek-ai/dsh-type-meta`。它提供 `GatewayService`,以及 decorator、binding 回退、lookup、Remote Context 和 descriptor 的声明协议,不依赖 TypeScript compiler、Zod、HTTP 或 Client runtime。 +业务包只依赖轻量的 `@deepseek-ai/dsh-type-meta`。它提供 `GatewayService`,以及 decorator、binding 回退、lookup、Remote Scope 和 descriptor 的声明协议,不依赖 TypeScript compiler、Zod、HTTP 或 Client runtime。 支持协作式取消的方法会把 `signal: AbortSignal` 声明为最后一个 Host 参数。这个保留参数不是业务值、lookup 或 JSON 字段。生成的消费方方法将其暴露为最后一个可选参数,因此普通调用保持不变,而拥有取消控制权的调用方可以传入 signal。 ## Decorator 与显式 Gateway facet -Decorator 只表达“该方法参与 Remote 契约”,不负责运行时类型反射,也不向 Service constructor 注入隐藏 symbol。`@Remote('create')` 和 `@RemoteContext('agent', 'create')` 的参数是外部方法名;被装饰成员既可以是业务方法本身,也可以是 `remoteExportCreate` 这样的适配器。未给别名时才使用成员名作为外部方法名。继承 `GatewayService` 是 Service 加入 Gateway 的常规显式声明;其 public readonly `typertGateway` 字段使运行时实例上的绑定保持可见。 +Decorator 只表达“该方法参与 Remote 契约”,不负责运行时类型反射,也不向 Service constructor 注入隐藏 symbol。`@Remote('create')` 和 `@RemoteScope('agent', 'create')` 的参数是外部方法名;被装饰成员既可以是业务方法本身,也可以是 `remoteExportCreate` 这样的适配器。未给别名时才使用成员名作为外部方法名。继承 `GatewayService` 是 Service 加入 Gateway 的常规显式声明;其 public readonly `typertGateway` 字段使运行时实例上的绑定保持可见。 SRC 运行时允许 decorator 在 `dsh-type-meta` 内部的 `WeakMap` 记录 prototype、方法名和调用模式。它不向 Service 实例、prototype、constructor 或方法函数写入自定义属性。 LIB 的严格方法发现、类型解析和 descriptor 生成由 TypeRT compiler 完成。它接受 `GatewayService` 直接 `super()` 调用中的字面量 service key,或显式 binding 回退;生成过程不改写业务源码,也不注入隐藏注册元数据。 -## Lookup 与 Remote Context 注册 +## Lookup 与 Remote Scope 注册 Gateway 不内置 Agent、Session 或其他业务对象分支。对象所属包同时提供静态声明和运行时 provider: @@ -115,7 +115,7 @@ ctx.typert.lookups.register('agent', { Agent、Session 等 lookup 对象只能各自占据一个顶层参数位置。普通 JSON request 可以作为另一个完整参数传入,但本设计不支持 `request.agent`、对象解构、对象数组、嵌套 lookup 或从任意复杂结构中搜索 ID。 -Remote Context 使用独立的 merge-extensible map 和 provider。Agent 包注册 `agent` Context provider,负责用 wire identity 找到 Agent Context,并从该 Context 解析 descriptor 指定的 service key;Gateway 不知道 Agent Context 的内部结构。 +Remote Scope 使用独立的 merge-extensible map 和 Context provider。Agent 包注册 `agent` provider,负责用 wire identity 找到 Agent Context,并从该 Context 解析 descriptor 指定的 service key;Gateway 不知道 Agent Context 的内部结构。 Client 侧也注册 `agent` Context binder。binder 只负责从一次调用所在的 Context 取得 `SessionId`;它不枚举 Scope,也不逐个复制方法。scoped namespace 由 Cordis Service tracker 自动 rebind 到当前 Agent Context。 @@ -267,7 +267,7 @@ interface TypeRTRemoteNamespaceMap { goals: TypeRTRemoteNamespace$676f616c73 } -interface TypeRTRemoteContextMap { +interface TypeRTRemoteScopeMap { 'agent:goals/create': ( request: CreateGoalRequest, signal?: AbortSignal, @@ -277,14 +277,14 @@ interface TypeRTRemoteContextMap { `TypeRTRemoteMap` 保留规范 endpoint 签名,供协议类型和反射使用。根 Remote 类型直接读取 `TypeRTRemoteNamespaceMap`,不通过 key-remapped mapped type 间接推导方法;TypeScript Language Service 无法把这种间接属性稳定导航到 declaration map。namespace interface 名由 namespace 的 UTF-8 bytes 编成 hex,`goals` 因而稳定得到 `TypeRTRemoteNamespace$676f616c73`。不同 package 对同一 namespace 生成同名 interface,依靠 module augmentation 合并各自方法,且 `TypeRTRemoteNamespaceMap.goals` 始终引用同一类型。 -TypeRT 把 `TypeRTRemoteContextMap` 按 Context key 投影到专用 Scope 类型。最终编程界面保持: +TypeRT 把 `TypeRTRemoteScopeMap` 按 Context key 投影到专用 Scope 类型。最终编程界面保持: ```text ctx.remote.goals.create(agentId, request) agentCtx.remote.goals.create(request) ``` -Agent Scope 自动提供自己的 `SessionId`。因此带 `agent` lookup 的 `@Remote` 方法可以同时生成 root 和 scoped 两种消费端签名;`@RemoteContext('agent')` 方法也省略独立的 Context identity,但只生成 scoped 签名。根 `Context` 通过 `ctx.remote` 暴露 direct namespace,`AgentContext.remote` 则把该 direct surface 与 scoped surface 取交集。未来 TUI 复用时必须维持相同区分。 +Agent Scope 自动提供自己的 `SessionId`。因此带 `agent` lookup 的 `@Remote` 方法可以同时生成 root 和 scoped 两种消费端签名;`@RemoteScope('agent')` 方法也省略独立的 Scope identity,但只生成 scoped 签名。根 `Context` 通过 `ctx.remote` 暴露 direct namespace,`AgentContext.remote` 则把该 direct surface 与 scoped surface 取交集。未来 TUI 复用时必须维持相同区分。 `TypeRTClientRemote` 保持平台无关,Browser Client 通过 `ctx.remote` 暴露它。未来 TUI 若复用该类型,也必须通过专用 Remote 对象和 Agent Scope 使用它,不能把 Host `Context` 当成更宽的 Service 集合;未标记的 public Service 方法不会进入 Remote maps。 @@ -313,7 +313,7 @@ Client 业务包只引用 `@deepseek-ai/dsh-api-remotes/client`,不直接依 Client Remote Service 把 `@Remote` descriptor 实体化为 `remote.` 子 Service 上的真实函数。函数按 descriptor 的位置参数顺序构造具名 `args`,执行 Client strict codec,然后调用 `ctx.connection.rpc.call('/api', endpoint, { args }, signal)`。对于支持取消的 descriptor,生成的函数接受最后一个可选 signal,并将其与 contribution 的挂载生命周期合并;因此卸载会取消所有正在进行的 carrier 调用,而调用方也可以单独取消一次调用。 -带 `scope` 的 direct descriptor 和 `@RemoteContext` descriptor 都不为每个 Agent Scope 复制函数。Client Remote Service 为每个 namespace 创建一个注册为 `remote.` 的 Cordis 子 Service,并在其上实体化 direct 与 scoped 变体。通过 `agentCtx.remote.goals` 取得方法时,accessor 会在返回可调用句柄前捕获当前 Agent Context。方法再通过对应 Context binder 从该 Context 取得 identity。direct scoped 投影用 identity 替代 `scope.wire` 指定的 lookup 位置,Context descriptor 则把 identity 写入 receiver 的独立 wire 字段;两者都发起同一种 `/api` 调用。 +带 `scope` 的 direct descriptor 和 `@RemoteScope` descriptor 都不为每个 Agent Scope 复制函数。Client Remote Service 为每个 namespace 创建一个注册为 `remote.` 的 Cordis 子 Service,并在其上实体化 direct 与 scoped 变体。通过 `agentCtx.remote.goals` 取得方法时,accessor 会在返回可调用句柄前捕获当前 Agent Context。方法再通过对应 Context binder 从该 Context 取得 identity。direct scoped 投影用 identity 替代 `scope.wire` 指定的 lookup 位置,Remote Scope descriptor 则把 identity 写入 receiver 的独立 wire 字段;两者都发起同一种 `/api` 调用。 ```text root ctx.remote.goals.create(agentId, request) @@ -327,7 +327,7 @@ agentCtx.remote.goals.create(request) → ctx.connection.rpc.call('/api', 'goals/create', { args }) ``` -根 `Context` 只 merge direct `TypeRTClientRemote` surface;`AgentContext` 把该属性替换为 `TypeRTClientRemote` 与 `TypeRTRemoteContextApi<'agent'>` 的交叉,因而 scoped-only 方法不会暴露给 root 代码。若调用方绕过类型从 Root 动态调用 scoped-only 方法,binder 明确报错。若 Client 已有名为 `remote.` 的 Cordis service,或两个 contribution 冲突占用同一 namespace/method,mount 直接失败,不覆盖现有服务。 +根 `Context` 只 merge direct `TypeRTClientRemote` surface;`AgentContext` 把该属性替换为 `TypeRTClientRemote` 与 `TypeRTRemoteScopeApi<'agent'>` 的交叉,因而 scoped-only 方法不会暴露给 root 代码。若调用方绕过类型从 Root 动态调用 scoped-only 方法,binder 明确报错。若 Client 已有名为 `remote.` 的 Cordis service,或两个 contribution 冲突占用同一 namespace/method,mount 直接失败,不覆盖现有服务。 生成的 Remote JS 只包含 descriptor、symbol key 和 codec,不打包 Host Service 实现。Client Remote Service 据此创建真实函数,因此运行时不依赖 JavaScript Proxy;Proxy 可以作为实现选择,但不会成为类型或反射来源。 @@ -337,7 +337,7 @@ Remote API 是消费端能力,不等同于 Browser API。已交付的运行时 Remote DTS、Remote JS、`TypeRTClientRemote`、`InvocationDescriptor`、Remote RPC 数据协议和 Context binder 不得依赖 DOM、Browser module loader 或 HTTP。Browser Client 通过 Connection 把 descriptor 实体化的方法编码为 `/api` RPC 调用。 -未来 TUI 可以在不改变业务 decorator、Remote maps 和 API 调用形状的前提下接入同一调用抽象。届时 TUI 可见的 API 仍只能由 `@Remote` 和 `@RemoteContext` 生成,不能因为它与 Host 同进程就绕过 Remote 限制直接暴露 Service 方法。 +未来 TUI 可以在不改变业务 decorator、Remote maps 和 API 调用形状的前提下接入同一调用抽象。届时 TUI 可见的 API 仍只能由 `@Remote` 和 `@RemoteScope` 生成,不能因为它与 Host 同进程就绕过 Remote 限制直接暴露 Service 方法。 TUI 的 runtime 挂载、carrier、Agent Scope 关联和 SRC 启动接线均不属于本期实现。 @@ -345,7 +345,7 @@ Web 本身依赖 `lib/client.js` 等构建产物,因此启动 Web 前要求完 ## SRC 与 LIB 运行模式 -SRC 面向本地源码启动。`@Remote` 和 `@RemoteContext()` 的 WeakMap 记录给出方法名和调用模式,运行时从 JavaScript 函数签名读取顺序参数名,并结合已注册 lookup/Context provider 生成弱 descriptor。 +SRC 面向本地源码启动。`@Remote` 和 `@RemoteScope()` 的 WeakMap 记录给出方法名和调用模式,运行时从 JavaScript 函数签名读取顺序参数名,并结合已注册 lookup/Context provider 生成弱 descriptor。 例如 `@Remote('create') remoteExportCreate(agent, request, signal)` 解析为外部方法 `create`、实现成员 `remoteExportCreate`、两个顶层业务参数和一个取消注入点;lookup 注册把 `agent` 改写为 wire 字段 `agentId`,`request` 按同名 JSON 参数传递,最后一个 `signal` 则留在 payload 之外。SRC 不启动 `ts.Program`,不使用 preload、loader hook、源码生成或模块改写,也不检查普通 JSON 对象的内部结构。 @@ -365,7 +365,7 @@ Host Gateway 向 Connection 注册一个 `/api` interceptor,不维护第二份 普通 `@Remote` 调用保留原始 Service 实例作为 receiver。lookup 成功后,Gateway 按 descriptor 的参数顺序调用 `implementation ?? method` 指定的成员;若 descriptor 声明取消,则在这些参数之后追加 carrier signal。 -`@RemoteContext('agent')` 调用先由 Agent Context provider 解析 wire identity,再从该 Context 读取 descriptor 的 service key 并调用 scoped receiver。业务方法不会收到隐藏 Context 参数或 Agent ID。 +`@RemoteScope('agent')` 调用先由 Agent Context provider 解析 wire identity,再从该 Context 读取 descriptor 的 service key 并调用 scoped receiver。业务方法不会收到隐藏 Context 参数或 Agent ID。 ```text ctx.typertGateway.invoke({ namespace, method, args, signal }) @@ -448,7 +448,7 @@ Gateway 只向 Connection 注册 ownership matcher 和 RPC handler,不注册 H ## 包边界 -- `@deepseek-ai/dsh-type-meta`:轻量 decorator、binding、lookup、Remote Context 和 descriptor 协议。 +- `@deepseek-ai/dsh-type-meta`:轻量 decorator、binding、lookup、Remote Scope 和 descriptor 协议。 - TypeRT generator:分析 Host/Client Program,生成本地 face 和 Remote 消费端投影,并生成规范 symbol/Zod 信息。 - TypeRT runtime:分别保存当前环境的 local reflection 与导入的 Remote contribution。 - `@deepseek-ai/dsh-api-gateway`:默认入口关联 Host definition 与 Service,认领 Remote endpoint,执行 lookup、Context receiver 解析、调用和结果编码,并向 Connection 注册 `/api` interceptor;`/client` 入口挂载 Remote contribution,创建严格 Remote namespace Service 和方法,并把调用交给 `ctx.connection.rpc`。两个入口共享 Remote 协议,但不互相导入各自的 Cordis interface merge。 @@ -460,7 +460,7 @@ Gateway 只向 Connection 注册 ownership matcher 和 RPC handler,不注册 H ## 已交付范围与后续工作 -已交付的纵向链路是 `@deepseek-ai/dsh-goal/remote → Browser Client Remote → Connection RPC /api → Host Gateway → GoalService.remoteExportCreate()`。同一个带 Agent lookup 的 direct descriptor 同时支持 `ctx.remote.goals.create(agentId, request)` 与 `agentCtx.remote.goals.create(request)`。普通冷会话在 lookup 时通过 `agentFor()` 恢复,subagent-owned identity 保持既有 `agent-busy` fence;`@RemoteContext('agent')` 仍是独立的 scoped receiver 模式。 +已交付的纵向链路是 `@deepseek-ai/dsh-goal/remote → Browser Client Remote → Connection RPC /api → Host Gateway → GoalService.remoteExportCreate()`。同一个带 Agent lookup 的 direct descriptor 同时支持 `ctx.remote.goals.create(agentId, request)` 与 `agentCtx.remote.goals.create(request)`。普通冷会话在 lookup 时通过 `agentFor()` 恢复,subagent-owned identity 保持既有 `agent-busy` fence;`@RemoteScope('agent')` 仍是独立的 scoped receiver 模式。 Connection 提供共享 channel interceptor 与当前 HTTP carrier 映射。WebSocket 迁移、TUI runtime 与 carrier、TUI Agent Scope 接线、Permission/Approval 状态机、Session 事件流、调用授权、重试、幂等及跨版本协议兼容均不属于本决策。 diff --git a/docs/api-gateway.i18n.yaml b/docs/api-gateway.i18n.yaml index d07272c182..2a6ae0807b 100644 --- a/docs/api-gateway.i18n.yaml +++ b/docs/api-gateway.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/api-gateway.md -api-gateway.md: 90aa661cc86a4f419e173560c55511c969182990 -api-gateway.zh.md: 6fcbb562b204e71d00833042ee0632bda0217940 +api-gateway.md: ba95d429dd0c9f9f354baf0063197cea6e3ecbf8 +api-gateway.zh.md: 4e42ebea7a5db19c7df23079050b9488679a3a23 diff --git a/docs/api-gateway.md b/docs/api-gateway.md index 90aa661cc8..ba95d429dd 100644 --- a/docs/api-gateway.md +++ b/docs/api-gateway.md @@ -6,17 +6,17 @@ This is the current-state reference for the TypeRT API Gateway. It describes how ## Programming model -Business services use `@Remote` or `@RemoteContext` to select the methods exposed to the Client. Unmarked methods do not enter the generated Client types or runtime contributions and cannot be called through `ctx.remote`. +Business services use `@Remote` or `@RemoteScope` to select the methods exposed to the Client. Unmarked methods do not enter the generated Client types or runtime contributions and cannot be called through `ctx.remote`. `@Remote` denotes calling a Cordis service registered on the root Host Context. Complex Host objects cannot cross the wire directly; the business package must declare their association with a wire identity through `TypeRTLookupMap` and register a default resolution provider with `ctx.typert.lookups` at runtime. For example, an `Agent` parameter named `agent` in the Host signature produces an `agentId` wire field, and the Gateway resolves that id to a Host object before invoking the business method. Host composition can use `ctx.typert.lookups.configure()` to override the resolution policy for a lookup key without changing the parameter name, wire field, or canonical type symbol owned by the business package. -`@RemoteContext(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. +`@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. Services normally extend `GatewayService` 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 typertGateway = bindTypeRTGateway(this, serviceKey)`; both forms leave an inspectable public binding and do not depend on the compiler injecting a symbol into the constructor. ```ts import type { Agent } from '@deepseek-ai/dsh-agent' -import { GatewayService, Remote, RemoteContext } from '@deepseek-ai/dsh-type-meta' +import { GatewayService, Remote, RemoteScope } from '@deepseek-ai/dsh-type-meta' import type { Context } from 'cordis' export interface CreateGoalRequest { @@ -42,7 +42,7 @@ export class GoalService extends GatewayService { return this.create(agent, request) } - @RemoteContext('agent', 'current') + @RemoteScope('agent', 'current') currentForClient(): CreateGoalResult { return { accepted: true } } @@ -55,7 +55,7 @@ export class GoalService extends GatewayService { Remote methods may return a value synchronously or return a Promise. For cooperative cancellation, the final parameter in the Host signature must be `signal: AbortSignal` using the global type; it is recorded in the descriptor instead of entering `args`, while the generated Client method accepts an optional final `AbortSignal`. -The Client uses concrete functions on ordinary objects, not a JavaScript Proxy. Direct and scoped calls appear under `ctx.remote.` and `agentCtx.remote.`. Each namespace is a traced Cordis child Service registered as `remote.`; the Client assembly mounts contributions through `ctx.remote.$mount()`, consumers inject both `remote` and the namespace Service they call, and the namespace unloads after its last method is withdrawn. When an `@Remote` method has exactly one lookup parameter and a same-named `TypeRTContextMap` uses the same wire identity, the generated scoped signature omits that identity parameter. `@RemoteContext` generates only the scoped invocation interface. +The Client uses concrete functions on ordinary objects, not a JavaScript Proxy. Direct and scoped calls appear under `ctx.remote.` and `agentCtx.remote.`. Each namespace is a traced Cordis child Service registered as `remote.`; the Client assembly mounts contributions through `ctx.remote.$mount()`, consumers inject both `remote` and the namespace Service they call, and the namespace unloads after its last method is withdrawn. When an `@Remote` method has exactly one lookup parameter and a same-named `TypeRTContextMap` uses the same wire identity, the generated scoped signature omits that identity parameter. `@RemoteScope` generates only the scoped invocation interface. ```ts import type { SessionId } from '@deepseek-ai/dsh-session/types' @@ -101,7 +101,7 @@ Each contributing business package writes generated files to its own `lib/` dire | `typert.host.js` | Host Loader | Runtime reflection for the Host face, strict invocation descriptors, and schema registration values | | `typert.host.d.ts` | Host type system | Generated declarations for the Host face | | `typert.remote-client.js` | `api-remotes` | A mountable `TypeRTRemoteContribution` containing strict descriptors and runtime codecs | -| `typert.remote-client.d.ts` | Client type system | Declaration merges for `TypeRTRemoteNamespaceMap` and `TypeRTRemoteContextMap`, plus Client-safe type references | +| `typert.remote-client.d.ts` | Client type system | Declaration merges for `TypeRTRemoteNamespaceMap` and `TypeRTRemoteScopeMap`, plus Client-safe type references | | `typert.remote-client.d.ts.map` | Editor | Maps generated method properties back to Remote method declarations in the Host package | Business packages expose the Host Loader entry through `./typert` and the Host-for-Client entry through `./remote`. The generator also validates these package exports and published-file lists; it generates artifacts only for explicit contribution packages that provide the corresponding entry. @@ -126,7 +126,7 @@ Unloading a Client contribution removes its descriptors and concrete methods tog When the Host starts from source through `node --import tsx/esm`, it does not execute the TypeRT compiler plugin. Standard decorator initializers still record the method name and invocation mode in a module-private `WeakMap`, while `GatewayService` or `bindTypeRTGateway()` supplies the explicit service binding; the Gateway can therefore construct a weaker temporary descriptor without starting a `ts.Program`. -The SRC fallback parses simple parameter names from the live function. When a parameter name matches the `parameter` of a registered lookup, such as `agent` or `session`, it uses the lookup's `agentId` or `sessionId` wire field and resolves the object on the Host; other parameters are checked only for cycle-free, JSON-safe data with no special prototype. `@RemoteContext` directly uses the wire field of a registered Host Context provider. SRC does not read TypeScript types, generate Zod schemas, infer optional parameters, or support destructuring, default values, rest parameters, or duplicate parameter names. +The SRC fallback parses simple parameter names from the live function. When a parameter name matches the `parameter` of a registered lookup, such as `agent` or `session`, it uses the lookup's `agentId` or `sessionId` wire field and resolves the object on the Host; other parameters are checked only for cycle-free, JSON-safe data with no special prototype. `@RemoteScope` directly uses the wire field of a registered Host Context provider. SRC does not read TypeScript types, generate Zod schemas, infer optional parameters, or support destructuring, default values, rest parameters, or duplicate parameter names. SRC solves only dispatch for a Host process running from source. The Client does not discover decorators from the running Host, and the Client Remote refuses to mount SRC descriptors that lack strict codecs; its types, codecs, and Remote registration values always come from the most recently generated `lib/typert.remote-client.*` artifacts. diff --git a/docs/api-gateway.zh.md b/docs/api-gateway.zh.md index 6fcbb562b2..4e42ebea7a 100644 --- a/docs/api-gateway.zh.md +++ b/docs/api-gateway.zh.md @@ -6,17 +6,17 @@ ## 编程模型 -业务 Service 通过 `@Remote` 或 `@RemoteContext` 选择对 Client 开放的方法。未标记的方法不会进入生成的 Client 类型或运行时贡献,也不能通过 `ctx.remote` 调用。 +业务 Service 通过 `@Remote` 或 `@RemoteScope` 选择对 Client 开放的方法。未标记的方法不会进入生成的 Client 类型或运行时贡献,也不能通过 `ctx.remote` 调用。 `@Remote` 表示调用根 Host Context 中注册的 Cordis Service。复杂的 Host 对象不能直接跨 wire 传输;业务包必须通过 `TypeRTLookupMap` 声明它与 wire identity 的关联,并在运行时向 `ctx.typert.lookups` 注册默认解析提供方。例如 `Agent` 参数在 Host 签名中名为 `agent`,生成的 wire 字段为 `agentId`,Gateway 在调用业务方法前将 id 解析为 Host 对象。Host 组合可以用 `ctx.typert.lookups.configure()` 覆盖某个 lookup key 的解析策略,而不改变业务包拥有的参数名、wire 字段或规范类型 symbol。 -`@RemoteContext(key)` 表示先通过 `ctx.typert.contexts` 把 identity 解析为一个作用域 Context,再从该 Context 取得 Service 并调用方法。它适用于方法本身依赖作用域组合、而不需要显式接收 `Agent` 等对象的情形。 +`@RemoteScope(key)` 表示先通过 `ctx.typert.contexts` 把 identity 解析为一个作用域 Context,再从该 Context 取得 Service 并调用方法。它适用于方法本身依赖作用域组合、而不需要显式接收 `Agent` 等对象的情形。 Service 通常继承 `GatewayService`,让 Cordis service key 与默认 Remote namespace 在构造器中显式绑定。已有其他基类的 Service 可以改为声明 `readonly typertGateway = bindTypeRTGateway(this, serviceKey)`;两种方式都会留下可检查的公开 binding,不依赖编译器向构造函数注入 symbol。 ```ts import type { Agent } from '@deepseek-ai/dsh-agent' -import { GatewayService, Remote, RemoteContext } from '@deepseek-ai/dsh-type-meta' +import { GatewayService, Remote, RemoteScope } from '@deepseek-ai/dsh-type-meta' import type { Context } from 'cordis' export interface CreateGoalRequest { @@ -42,7 +42,7 @@ export class GoalService extends GatewayService { return this.create(agent, request) } - @RemoteContext('agent', 'current') + @RemoteScope('agent', 'current') currentForClient(): CreateGoalResult { return { accepted: true } } @@ -55,7 +55,7 @@ export class GoalService extends GatewayService { Remote 方法可以同步返回或返回 Promise。若需要协作式取消,Host 签名的最后一个参数必须是全局类型的 `signal: AbortSignal`;它记录在描述符中而不是进入 `args`,Client 生成的方法则接受最后一个可选的 `AbortSignal`。 -Client 使用普通对象上的具体函数,不使用 JavaScript Proxy。直接调用与作用域调用分别出现在 `ctx.remote.` 和 `agentCtx.remote.`。每个 namespace 都是注册为 `remote.` 的可追踪 Cordis 子 Service;Client assembly 通过 `ctx.remote.$mount()` 挂载贡献,消费方同时注入 `remote` 与所调用的 namespace Service,最后一个方法撤回后该 namespace 随即卸载。当一个 `@Remote` 方法恰好有一个 lookup 参数、且同名 `TypeRTContextMap` 使用相同 wire identity 时,生成的作用域签名会省略该 identity 参数。`@RemoteContext` 只生成作用域调用界面。 +Client 使用普通对象上的具体函数,不使用 JavaScript Proxy。直接调用与作用域调用分别出现在 `ctx.remote.` 和 `agentCtx.remote.`。每个 namespace 都是注册为 `remote.` 的可追踪 Cordis 子 Service;Client assembly 通过 `ctx.remote.$mount()` 挂载贡献,消费方同时注入 `remote` 与所调用的 namespace Service,最后一个方法撤回后该 namespace 随即卸载。当一个 `@Remote` 方法恰好有一个 lookup 参数、且同名 `TypeRTContextMap` 使用相同 wire identity 时,生成的作用域签名会省略该 identity 参数。`@RemoteScope` 只生成作用域调用界面。 ```ts import type { SessionId } from '@deepseek-ai/dsh-session/types' @@ -101,7 +101,7 @@ API Gateway 包同时拥有 Host dispatcher 与 Client Remote endpoint 两个对 | `typert.host.js` | Host Loader | Host face 的运行时反射、严格调用描述符和 schema 注册值 | | `typert.host.d.ts` | Host 类型系统 | Host face 的生成声明 | | `typert.remote-client.js` | `api-remotes` | 可挂载的 `TypeRTRemoteContribution`,包含严格描述符与运行时 codec | -| `typert.remote-client.d.ts` | Client 类型系统 | `TypeRTRemoteNamespaceMap` 与 `TypeRTRemoteContextMap` 的声明合并及 Client-safe 类型引用 | +| `typert.remote-client.d.ts` | Client 类型系统 | `TypeRTRemoteNamespaceMap` 与 `TypeRTRemoteScopeMap` 的声明合并及 Client-safe 类型引用 | | `typert.remote-client.d.ts.map` | 编辑器 | 将生成的方法属性映射回 Host 包中的 Remote 方法声明 | 业务包通过 `./typert` 暴露 Host Loader 入口,通过 `./remote` 暴露 Host-for-Client 入口。生成器同时校验这些 package export 及发布文件清单;只有具备相应入口的显式贡献包才会生成产物。 @@ -126,7 +126,7 @@ Client 卸载一个贡献时会一起移除描述符和具体方法,中止其 Host 通过 `node --import tsx/esm` 从源码启动时不会执行 TypeRT 编译插件。标准 decorator 初始化器仍会把方法名和调用模式记录到模块私有 `WeakMap`,`GatewayService` 或 `bindTypeRTGateway()` 则提供显式 service binding;Gateway 因而可以在不启动 `ts.Program` 的情况下构造一个较弱的临时描述符。 -SRC 回退从运行中函数解析简单参数名。参数名与某个已注册 lookup 的 `parameter` 相同,例如 `agent` 或 `session`,就使用其 `agentId` 或 `sessionId` wire 字段并在 Host 解析对象;其他参数只检查值是否为无循环、无特殊 prototype 的 JSON-safe 数据。`@RemoteContext` 直接使用已注册 Host Context provider 的 wire 字段。SRC 不读取 TypeScript 类型,不生成 Zod schema,不推断可选参数,也不支持解构、默认值、rest 或重复参数名。 +SRC 回退从运行中函数解析简单参数名。参数名与某个已注册 lookup 的 `parameter` 相同,例如 `agent` 或 `session`,就使用其 `agentId` 或 `sessionId` wire 字段并在 Host 解析对象;其他参数只检查值是否为无循环、无特殊 prototype 的 JSON-safe 数据。`@RemoteScope` 直接使用已注册 Host Context provider 的 wire 字段。SRC 不读取 TypeScript 类型,不生成 Zod schema,不推断可选参数,也不支持解构、默认值、rest 或重复参数名。 SRC 只解决 Host 源码进程的分发问题。Client 不会从运行中的 Host 发现 decorator,Client Remote 也拒绝挂载缺少严格 codec 的 SRC 描述符;其类型、codec 和 Remote 注册值始终来自最近一次生成的 `lib/typert.remote-client.*`。 diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index 933f204fa0..b66552b175 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/development.md -development.md: 37bc88c7c1cfedfbe1a93e08a4cbde833ac32372 -development.zh.md: a738e53cb3434d7930aa82107782a4c22aea1470 +development.md: b7ecab3536d739c105f11a640a07ea83a22f4398 +development.zh.md: 33ceba9f05c45c06acae7c83425a30c5e26ca433 diff --git a/docs/development.md b/docs/development.md index 37bc88c7c1..b7ecab3536 100644 --- a/docs/development.md +++ b/docs/development.md @@ -62,7 +62,7 @@ Host and client stay two aggregate programs because both sides declaration-merge Static analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. Decision record: [solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md); the tsc-first emit pipeline is the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md). -Business services declare callable methods on the Host with `@Remote` or `@RemoteContext`; the Host build generates Host-for-Client types and runtime contributions, and the Client's `api-remotes` composition loads those contributions under `ctx.remote` and scoped `agentCtx.remote` namespaces. See [API Gateway](api-gateway.md) for the generated artifacts on both sides, their assembly relationships, the SRC development fallback, and the Web build order. +Business services declare callable methods on the Host with `@Remote` or `@RemoteScope`; the Host build generates Host-for-Client types and runtime contributions, and the Client's `api-remotes` composition loads those contributions under `ctx.remote` and scoped `agentCtx.remote` namespaces. See [API Gateway](api-gateway.md) for the generated artifacts on both sides, their assembly relationships, the SRC development fallback, and the Web build order. If a relevant local check consumes built package output, build once first: diff --git a/docs/development.zh.md b/docs/development.zh.md index a738e53cb3..33ceba9f05 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -62,7 +62,7 @@ host 与 client 保持两个聚合 program,是因为两侧在相同键下以 静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。决策记录:[solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md);tsc-first 发射管线见 [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md)。 -业务 Service 在 Host 使用 `@Remote` 或 `@RemoteContext` 声明可调用方法;Host 构建生成 Host-for-Client 类型与运行时贡献,Client 的 `api-remotes` 组合加载这些贡献并挂到 `ctx.remote` 与作用域 `agentCtx.remote` namespace。两侧的生成产物、装配关系、SRC 开发回退和 Web 构建顺序见 [API Gateway](api-gateway.md)。 +业务 Service 在 Host 使用 `@Remote` 或 `@RemoteScope` 声明可调用方法;Host 构建生成 Host-for-Client 类型与运行时贡献,Client 的 `api-remotes` 组合加载这些贡献并挂到 `ctx.remote` 与作用域 `agentCtx.remote` namespace。两侧的生成产物、装配关系、SRC 开发回退和 Web 构建顺序见 [API Gateway](api-gateway.md)。 如果相关的本地检查需要使用构建后的包产物,请先构建一次: diff --git a/packages/api/gateway/README.i18n.yaml b/packages/api/gateway/README.i18n.yaml index 3a9a0ba50d..3f4cd32e4d 100644 --- a/packages/api/gateway/README.i18n.yaml +++ b/packages/api/gateway/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/api/gateway/README.md -README.md: e37359db71c1388667e9e61f538354711e90c0c1 -README.zh.md: 2054febb9a5423297c32b029b40a035062250aab +README.md: 0e1a03d2016b8cfbe165dbf1b0a9802290b29502 +README.zh.md: 6b5ccff2340405cc0045147239c5bd4f3eead7da diff --git a/packages/api/gateway/README.md b/packages/api/gateway/README.md index e37359db71..0e1a03d201 100644 --- a/packages/api/gateway/README.md +++ b/packages/api/gateway/README.md @@ -6,9 +6,9 @@ Two-sided TypeRT RPC endpoint for Host and Client Cordis environments. The Host ## Host service: `TypertGatewayService` (ctx key: `typertGateway`) -`ctx.typertGateway.invoke()` resolves the current descriptor and Cordis Service for each call, validates exact named arguments, resolves registered object or Context identities, invokes the public business method, and validates its result. Business Services extend `GatewayService` and mark methods with `@Remote` or `@RemoteContext` from [`dsh-type-meta`](../../typert/type-meta/README.md); `bindTypeRTGateway()` remains available when another base class owns inheritance. +`ctx.typertGateway.invoke()` resolves the current descriptor and Cordis Service for each call, validates exact named arguments, resolves registered object or Context identities, invokes the public business method, and validates its result. Business Services extend `GatewayService` and mark methods with `@Remote` or `@RemoteScope` from [`dsh-type-meta`](../../typert/type-meta/README.md); `bindTypeRTGateway()` remains available when another base class owns inheritance. -Strict mode reads generated invocation descriptors from `ctx.typert.local`. Lookup parameters use the currently active resolver in `ctx.typert.lookups`: the business package registers the stable declaration and default policy, while Host composition can override resolution behavior with effect-scoped `configure()`; `@RemoteContext` resolves its receiver through a registered Host Context provider. SRC mode is a development fallback for endpoints that have never had a strict definition; it parses simple parameter names and accepts only JSON-safe values for non-lookup parameters. Withdrawing an observed strict definition fails instead of weakening validation. +Strict mode reads generated invocation descriptors from `ctx.typert.local`. Lookup parameters use the currently active resolver in `ctx.typert.lookups`: the business package registers the stable declaration and default policy, while Host composition can override resolution behavior with effect-scoped `configure()`; `@RemoteScope` resolves its receiver through a registered Host Context provider. SRC mode is a development fallback for endpoints that have never had a strict definition; it parses simple parameter names and accepts only JSON-safe values for non-lookup parameters. Withdrawing an observed strict definition fails instead of weakening validation. The Host entry registers a trusted-host interceptor on Connection's shared `/api` FetchHandler. Connection passes this composite handler through its HTTP bridge; the handler dispatches claimed endpoints to Gateway and unclaimed endpoints to API Proxy. Direct `invoke()` calls preserve business errors; `TypertGatewayError` distinguishes failures owned by dispatch, binding, providers, lookup, Context, arguments, and codecs. A resolver may use `TypeRTLookupFailure` to carry an existing RPC error, preserving its original error code for policy rejections such as cold-resume failures or ownership fences. diff --git a/packages/api/gateway/README.zh.md b/packages/api/gateway/README.zh.md index 2054febb9a..6b5ccff234 100644 --- a/packages/api/gateway/README.zh.md +++ b/packages/api/gateway/README.zh.md @@ -6,9 +6,9 @@ ## Host 服务:`TypertGatewayService`(ctx key:`typertGateway`) -每次调用时,`ctx.typertGateway.invoke()` 都会解析当前的描述符和 Cordis 服务,校验具名参数是否完全匹配,解析已注册的对象或 Context 身份标识,调用公开的业务方法,并校验其结果。业务服务继承 [`dsh-type-meta`](../../typert/type-meta/README.md) 的 `GatewayService`,并用 `@Remote` 或 `@RemoteContext` 标记方法;已有其他基类时仍可改用 `bindTypeRTGateway()`。 +每次调用时,`ctx.typertGateway.invoke()` 都会解析当前的描述符和 Cordis 服务,校验具名参数是否完全匹配,解析已注册的对象或 Context 身份标识,调用公开的业务方法,并校验其结果。业务服务继承 [`dsh-type-meta`](../../typert/type-meta/README.md) 的 `GatewayService`,并用 `@Remote` 或 `@RemoteScope` 标记方法;已有其他基类时仍可改用 `bindTypeRTGateway()`。 -严格模式从 `ctx.typert.local` 读取生成的调用描述符。查找参数使用 `ctx.typert.lookups` 中当前有效的 resolver:业务包注册稳定声明与默认策略,Host 组合可用 effect-scoped `configure()` 覆盖解析行为;`@RemoteContext` 则通过已注册的 Host Context 提供方解析其接收者。SRC 模式是开发阶段的回退路径,适用于从未具备严格定义的端点;它解析简单参数名,并且只允许非查找参数使用可安全表示为 JSON 的值。已观测到的严格定义一旦撤回,系统会直接报错,而不会降低校验强度。 +严格模式从 `ctx.typert.local` 读取生成的调用描述符。查找参数使用 `ctx.typert.lookups` 中当前有效的 resolver:业务包注册稳定声明与默认策略,Host 组合可用 effect-scoped `configure()` 覆盖解析行为;`@RemoteScope` 则通过已注册的 Host Context 提供方解析其接收者。SRC 模式是开发阶段的回退路径,适用于从未具备严格定义的端点;它解析简单参数名,并且只允许非查找参数使用可安全表示为 JSON 的值。已观测到的严格定义一旦撤回,系统会直接报错,而不会降低校验强度。 Connection 可用时,Host 入口会在 Connection 共享的 `/api` FetchHandler 上注册 trusted-host interceptor。Connection 把这个复合 handler 交给 HTTP bridge;handler 将已认领 endpoint 分发给 Gateway,未认领 endpoint 则交给 API Proxy。直接调用 `invoke()` 会保留业务错误;`TypertGatewayError` 可区分分发、绑定、提供方、查找、Context、参数和编解码器各自负责的故障。resolver 可以用 `TypeRTLookupFailure` 携带既有 RPC error,使冷恢复失败或 ownership fence 等策略拒绝保持原错误码。 diff --git a/packages/api/gateway/tests/client.spec.ts b/packages/api/gateway/tests/client.spec.ts index 216f2359e7..1383175e73 100644 --- a/packages/api/gateway/tests/client.spec.ts +++ b/packages/api/gateway/tests/client.spec.ts @@ -5,7 +5,7 @@ import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client import type { InvocationDescriptor, TypeRTContext, - TypeRTRemoteContextApi, + TypeRTRemoteScopeApi, TypeRTRemoteNamespace, } from '@deepseek-ai/dsh-type-meta' import TypertRegistry from '@deepseek-ai/dsh-typert-registry' @@ -24,7 +24,7 @@ declare module '@deepseek-ai/dsh-type-meta' { ) => Promise<{ readonly ref: string }> } - interface TypeRTRemoteContextMap { + interface TypeRTRemoteScopeMap { 'fixture:goals/create': ( request: { readonly objective: string }, signal?: AbortSignal, @@ -38,7 +38,7 @@ declare module '@deepseek-ai/dsh-type-meta' { } -type FixtureContext = Omit & { readonly remote: TypeRTRemoteContextApi<'fixture'> } +type FixtureContext = Omit & { readonly remote: TypeRTRemoteScopeApi<'fixture'> } const idSchema = z.string().min(1) const requestSchema = z.object({ objective: z.string().min(1) }) diff --git a/packages/api/gateway/tests/gateway.spec.ts b/packages/api/gateway/tests/gateway.spec.ts index d298116b82..4fa0ea80ad 100644 --- a/packages/api/gateway/tests/gateway.spec.ts +++ b/packages/api/gateway/tests/gateway.spec.ts @@ -8,7 +8,7 @@ import type { HttpServerService, WebRoute } from '@deepseek-ai/dsh-host-webserve import { bindTypeRTGateway, Remote, - RemoteContext, + RemoteScope, TypeRTLookupFailure, type InvocationDescriptor, type TypeRTContext, @@ -65,7 +65,7 @@ class GoalService extends Service { } } - @RemoteContext('gatewayFixture') + @RemoteScope('gatewayFixture') rename(request: { readonly title: string }): unknown { this.calls.push('rename') return { title: request.title, scope: (this.ctx as MarkedContext).fixtureScope ?? 'root' } @@ -299,7 +299,7 @@ class ContextWireService extends Service { super(ctx, 'contextWire') } - @RemoteContext('gatewayFixture') + @RemoteScope('gatewayFixture') run(agentId: string): string { return agentId } @@ -389,7 +389,7 @@ describe('TypertGatewayService', () => { expect(service.lastSignal?.aborted).toBe(false) }) - it('resolves strict Remote Context identity without adding a business argument', async () => { + it('resolves strict Remote Scope identity without adding a business argument', async () => { const { ctx, service } = await setup() const scoped = ctx.extend({ fixtureScope: 'agent-scope' }) ctx.typert.contexts.registerHost('gatewayFixture', contextProvider(scoped)) @@ -432,7 +432,7 @@ describe('TypertGatewayService', () => { expect(service.calls).toEqual([]) }) - it('derives SRC Remote Context identity and preserves the scoped Proxy receiver', async () => { + it('derives SRC Remote Scope identity and preserves the scoped Proxy receiver', async () => { const { ctx } = await setup() const scoped = ctx.extend({ fixtureScope: 'agent-src' }) ctx.typert.contexts.registerHost('gatewayFixture', contextProvider(scoped)) diff --git a/packages/client/runtime/src/client/agents/scope.ts b/packages/client/runtime/src/client/agents/scope.ts index 1154d10feb..25644d24ba 100644 --- a/packages/client/runtime/src/client/agents/scope.ts +++ b/packages/client/runtime/src/client/agents/scope.ts @@ -18,11 +18,11 @@ import { Context as CordisContext } from 'cordis' import type { Context, Fiber } from 'cordis' import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' -import type { TypeRTClientRemote, TypeRTRemoteContextApi } from '@deepseek-ai/dsh-type-meta' +import type { TypeRTClientRemote, TypeRTRemoteScopeApi } from '@deepseek-ai/dsh-type-meta' /** Client Cordis Context carrying one Agent identity and its scoped Remote namespaces. */ export type AgentContext = Omit & { - readonly remote: TypeRTClientRemote & TypeRTRemoteContextApi<'agent'> + readonly remote: TypeRTClientRemote & TypeRTRemoteScopeApi<'agent'> } /** Context tag written by {@link createScope}. */ diff --git a/packages/typert/generator/src/analyzer.ts b/packages/typert/generator/src/analyzer.ts index bc5024a7d8..c5d89b3726 100644 --- a/packages/typert/generator/src/analyzer.ts +++ b/packages/typert/generator/src/analyzer.ts @@ -1032,10 +1032,10 @@ class FaceAnalyzer { if (invocation.kind === 'context') { const context = this.contextDeclarations().get(invocation.context) if (context === undefined) { - this.fail(method, `Remote Context ${invocation.context} has no TypeRTContextMap entry`) + this.fail(method, `Remote Scope ${invocation.context} has no TypeRTContextMap entry`) } const wire = `${invocation.context}Id` - if (wires.has(wire)) this.fail(method, `Remote Context wire field ${wire} conflicts with a method parameter`) + if (wires.has(wire)) this.fail(method, `Remote Scope wire field ${wire} conflicts with a method parameter`) receiver = { kind: 'context', context: invocation.context, @@ -1200,18 +1200,18 @@ class FaceAnalyzer { } marker = { kind: 'direct', exportName } } else if (ts.isCallExpression(expression) - && this.isTypeMetaSymbol(expression.expression, 'RemoteContext')) { + && this.isTypeMetaSymbol(expression.expression, 'RemoteScope')) { if (expression.arguments.length < 1 || expression.arguments.length > 2) { - this.fail(expression, 'RemoteContext() requires a Context key and optional exported method name') + this.fail(expression, 'RemoteScope() requires a Context key and optional exported method name') } const context = stringLiteralValue(expression.arguments[0]) if (context === undefined || !isRemoteSegment(context)) { - this.fail(expression.arguments[0] ?? expression, 'RemoteContext() key must be a string literal containing only RPC endpoint segment characters') + 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, 'RemoteContext() name must be a string literal containing only RPC endpoint segment characters') + this.fail(exportArgument, 'RemoteScope() name must be a string literal containing only RPC endpoint segment characters') } marker = { kind: 'context', context, ...exportName === undefined ? {} : { exportName } } } else { @@ -2529,7 +2529,7 @@ function sourceFileHasSurface(sourceFile: ts.SourceFile): boolean { ? decorator.expression.expression : decorator.expression const name = expressionName(expression) - if (name === 'Remote' || name === 'RemoteContext') return true + if (name === 'Remote' || name === 'RemoteScope') return true } } } diff --git a/packages/typert/generator/src/emitter.ts b/packages/typert/generator/src/emitter.ts index bb39959606..cbed0047c3 100644 --- a/packages/typert/generator/src/emitter.ts +++ b/packages/typert/generator/src/emitter.ts @@ -376,7 +376,7 @@ export class FaceModelEmitter { lines.push(' }') } if (scoped.length > 0) { - lines.push(' interface TypeRTRemoteContextMap {') + lines.push(' interface TypeRTRemoteScopeMap {') for (const invocation of scoped) { this.pushRemoteSignature(lines, sourceMap, packageModel, invocation, referenceNames, true) } diff --git a/packages/typert/generator/tests/fixtures/remote-model/packages/remote/src/index.ts b/packages/typert/generator/tests/fixtures/remote-model/packages/remote/src/index.ts index 4aa51ec433..e84d6fd142 100644 --- a/packages/typert/generator/tests/fixtures/remote-model/packages/remote/src/index.ts +++ b/packages/typert/generator/tests/fixtures/remote-model/packages/remote/src/index.ts @@ -1,4 +1,4 @@ -import { GatewayService, Remote, RemoteContext } from '@deepseek-ai/dsh-type-meta' +import { GatewayService, Remote, RemoteScope } from '@deepseek-ai/dsh-type-meta' import type { Agent } from '@fixture/domain' import type { CreateGoalRequest, @@ -19,7 +19,7 @@ export class GoalService extends GatewayService { return { ref: `${agent.id}:${request.title}` } } - @RemoteContext('agent') + @RemoteScope('agent') rename(request: RenameGoalRequest): RenameGoalResult { return { renamed: request.title.length > 0 } } diff --git a/packages/typert/generator/tests/fixtures/remote-model/type-meta.d.ts b/packages/typert/generator/tests/fixtures/remote-model/type-meta.d.ts index 5347a6b77e..707dc84ce9 100644 --- a/packages/typert/generator/tests/fixtures/remote-model/type-meta.d.ts +++ b/packages/typert/generator/tests/fixtures/remote-model/type-meta.d.ts @@ -11,7 +11,7 @@ declare module '@deepseek-ai/dsh-type-meta' { export interface TypeRTLookupMap {} export interface TypeRTContextMap {} export interface TypeRTRemoteMap {} - export interface TypeRTRemoteContextMap {} + export interface TypeRTRemoteScopeMap {} export type TypeRTRemoteNamespace = { [Endpoint in keyof TypeRTRemoteMap as Endpoint extends `${Namespace}/${infer Method}` @@ -56,7 +56,7 @@ declare module '@deepseek-ai/dsh-type-meta' { context: ClassMethodDecoratorContext Result>, ) => void - export function RemoteContext(key: Extract, exportName?: string): + export function RemoteScope(key: Extract, exportName?: string): ( 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 eaaf680cc6..0e62a56bf4 100644 --- a/packages/typert/generator/tests/remote-model.spec.ts +++ b/packages/typert/generator/tests/remote-model.spec.ts @@ -288,7 +288,7 @@ export interface BoxPayload { const root = copyFixture() editFile(root, 'packages/remote/src/index.ts', source => source .replace(' @Remote\n', '') - .replace(" @RemoteContext('agent')\n", '')) + .replace(" @RemoteScope('agent')\n", '')) editFile(root, 'packages/remote/src/types.ts', source => `${source} /** @typert schema */ @@ -377,8 +377,8 @@ export interface ClientMarker { name: 'duplicate GatewayService field binding', edit: (source: string) => source .replace( - 'import { GatewayService, Remote, RemoteContext }', - 'import { GatewayService, Remote, RemoteContext, bindTypeRTGateway }', + 'import { GatewayService, Remote, RemoteScope }', + 'import { GatewayService, Remote, RemoteScope, bindTypeRTGateway }', ) .replace( 'export class GoalService extends GatewayService {', @@ -495,11 +495,11 @@ export interface ClientMarker { expect(() => analyzeRemote(root)).not.toThrow() }) - it('rejects a Remote Context without a static Context declaration', () => { + it('rejects a Remote Scope without a static Context declaration', () => { const root = copyFixture() - editFile(root, 'packages/remote/src/index.ts', source => source.replace("@RemoteContext('agent')", "@RemoteContext('missing')")) + editFile(root, 'packages/remote/src/index.ts', source => source.replace("@RemoteScope('agent')", "@RemoteScope('missing')")) - expect(() => analyzeRemote(root, false)).toThrow(/Remote Context missing has no TypeRTContextMap entry/) + expect(() => analyzeRemote(root, false)).toThrow(/Remote Scope missing has no TypeRTContextMap entry/) }) it('rejects a direct scoped projection whose Context and lookup wire symbols differ', () => { @@ -579,7 +579,7 @@ function assertRemoteConsumerTypechecks( import remote from '@fixture/remote/remote' import type { TypeRTRemoteContribution, - TypeRTRemoteContextMap, + TypeRTRemoteScopeMap, TypeRTRemoteMap, TypeRTRemoteNamespaceMap, } from '@deepseek-ai/dsh-type-meta' @@ -587,8 +587,8 @@ import type { CreateGoalResult, RenameGoalResult } from '@fixture/remote/types' const contribution: TypeRTRemoteContribution = remote declare const create: TypeRTRemoteMap['goals/create'] -declare const createScoped: TypeRTRemoteContextMap['agent:goals/create'] -declare const rename: TypeRTRemoteContextMap['agent:goals/rename'] +declare const createScoped: TypeRTRemoteScopeMap['agent:goals/create'] +declare const rename: TypeRTRemoteScopeMap['agent:goals/rename'] const created: Promise = create('agent-1', { title: 'ship' }) const cancellable: Promise = create('agent-1', { title: 'ship' }, new AbortController().signal) const createdScoped: Promise = createScoped({ title: 'ship' }) diff --git a/packages/typert/type-meta/README.i18n.yaml b/packages/typert/type-meta/README.i18n.yaml index 6c21127e54..a61602c07b 100644 --- a/packages/typert/type-meta/README.i18n.yaml +++ b/packages/typert/type-meta/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/typert/type-meta/README.md -README.md: a76169742cb78d0d19814bcd0f978c71036a5a1c -README.zh.md: 6f2d2fd6e241441fae8102c0639608e9b27b9bec +README.md: 9bd475f8973ec54756fe0e63d5b7fa485381697d +README.zh.md: 10a6309bc47001d572abb3dd3f794ebfcf6252e8 diff --git a/packages/typert/type-meta/README.md b/packages/typert/type-meta/README.md index a76169742c..9bd475f897 100644 --- a/packages/typert/type-meta/README.md +++ b/packages/typert/type-meta/README.md @@ -7,7 +7,7 @@ Compiler-independent declarations shared by business packages, generated TypeRT ## Remote declarations - `@Remote` marks a public instance method for direct invocation on its registered Cordis Service. -- `@RemoteContext(key)` marks a method whose receiver is selected from a merge-declared scoped Context kind. +- `@RemoteScope(key)` marks a method whose receiver is selected from a merge-declared scoped Context kind. - `GatewayService` binds the Cordis key passed to `super(ctx, serviceKey, options?)` to the same default wire namespace. - `bindTypeRTGateway(this, serviceKey, options?)` provides the same visible, frozen binding for a Service that cannot inherit from `GatewayService`. - `remoteMethods(service)` returns a detached declaration-order snapshot used by the Gateway's SRC fallback. @@ -18,7 +18,7 @@ Decorator initializers retain markers in a module-private `WeakMap` keyed by the ## TypeRT protocol -Business packages extend `TypeRTLookupMap` and `TypeRTContextMap` to associate Host objects or scoped Contexts with their wire identities. Generated artifacts extend `TypeRTRemoteMap`, `TypeRTRemoteContextMap`, and `TypeRTRemoteNamespaceMap` so Client imports expose only selected Remote methods. `InvocationDescriptor` is the shared runtime form consumed by the registry, Gateway, and Client API. +Business packages extend `TypeRTLookupMap` and `TypeRTContextMap` to associate Host objects or scoped Contexts with their wire identities. Generated artifacts extend `TypeRTRemoteMap`, `TypeRTRemoteScopeMap`, and `TypeRTRemoteNamespaceMap` so Client imports expose only selected Remote methods. `InvocationDescriptor` is the shared runtime form consumed by the registry, Gateway, and Client Remote. Lookup and Context packages own both sides of their contract: declaration merging supplies the static association, while runtime providers register identity resolution with `ctx.typert`. A lookup or Host Context provider supplies the stable declaration and default resolver, while Host composition may separately configure a synchronous or asynchronous resolver; policy rejections may use `TypeRTLookupFailure` to carry a failure value owned by the boundary adapter. Strict codecs carry generated schemas; `src-json` codecs identify the weaker source-launch path. diff --git a/packages/typert/type-meta/README.zh.md b/packages/typert/type-meta/README.zh.md index 6f2d2fd6e2..10a6309bc4 100644 --- a/packages/typert/type-meta/README.zh.md +++ b/packages/typert/type-meta/README.zh.md @@ -7,7 +7,7 @@ ## Remote 声明 - `@Remote` 将公开实例方法标记为可在其注册的 Cordis 服务上直接调用。 -- `@RemoteContext(key)` 标记接收者选自合并声明的作用域 Context 类型的方法。 +- `@RemoteScope(key)` 标记接收者选自合并声明的作用域 Context 类型的方法。 - `GatewayService` 将 `super(ctx, serviceKey, options?)` 接收的 Cordis key 同时绑定为默认 wire namespace。 - `bindTypeRTGateway(this, serviceKey, options?)` 为无法继承 `GatewayService` 的 Service 提供同样可见且冻结的绑定。 - `remoteMethods(service)` 返回按声明顺序排列、与内部状态分离的快照,供 Gateway 的 SRC 回退路径使用。 @@ -18,7 +18,7 @@ Host 方法通过将 `signal: AbortSignal` 声明为最后一个参数来启用 ## TypeRT 协议 -业务包扩展 `TypeRTLookupMap` 和 `TypeRTContextMap`,以关联 Host 对象或作用域 Context 与其协议身份。生成的产物扩展 `TypeRTRemoteMap`、`TypeRTRemoteContextMap` 和 `TypeRTRemoteNamespaceMap`,使 Client 导入后仅暴露选定的 Remote 方法。`InvocationDescriptor` 是供注册表、Gateway 和 Client API 使用的共享运行时形式。 +业务包扩展 `TypeRTLookupMap` 和 `TypeRTContextMap`,以关联 Host 对象或作用域 Context 与其协议身份。生成的产物扩展 `TypeRTRemoteMap`、`TypeRTRemoteScopeMap` 和 `TypeRTRemoteNamespaceMap`,使 Client 导入后仅暴露选定的 Remote 方法。`InvocationDescriptor` 是供注册表、Gateway 和 Client Remote 使用的共享运行时形式。 查找包与 Context 包同时负责其契约的两侧:声明合并提供静态关联,运行时提供方则向 `ctx.typert` 注册身份解析。lookup 或 Host Context provider 提供稳定声明与默认 resolver,Host 组合可以另行配置同步或异步 resolver;策略拒绝可用 `TypeRTLookupFailure` 携带由边界适配器拥有的失败值。严格编解码器携带生成的 schema;`src-json` 编解码器标识约束更弱的源码启动路径。 diff --git a/packages/typert/type-meta/src/index.ts b/packages/typert/type-meta/src/index.ts index 774c6d3b32..1375d7872b 100644 --- a/packages/typert/type-meta/src/index.ts +++ b/packages/typert/type-meta/src/index.ts @@ -60,9 +60,9 @@ export type { TypeRTLookupResolver, TypeRTLookupRegistry, TypeRTLookupWire, - TypeRTRemoteContextApi, - TypeRTRemoteContextMap, - TypeRTRemoteContextNamespace, + TypeRTRemoteScopeApi, + TypeRTRemoteScopeMap, + TypeRTRemoteScopeNamespace, TypeRTRemoteContribution, TypeRTRemoteMap, TypeRTRemoteNamespace, @@ -191,16 +191,16 @@ export function Remote( } /** - * Create a decorator for a method resolved from one scoped Remote Context. - * @param key - merge-declared Context key. + * 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. * @returns a standard method decorator that records only private module state. */ -export function RemoteContext( +export function RemoteScope( key: Extract, exportName?: string, ): RemoteMethodDecorator { - validateName('Context key', key) + validateName('Scope key', key) if (exportName !== undefined) validateName('Remote export name', exportName) return function ( _method: (this: This, ...args: Args) => Result, diff --git a/packages/typert/type-meta/src/types.ts b/packages/typert/type-meta/src/types.ts index 5e7c20cd7c..c1d6b3dcf9 100644 --- a/packages/typert/type-meta/src/types.ts +++ b/packages/typert/type-meta/src/types.ts @@ -40,7 +40,7 @@ export interface TypeRTContextMap {} export interface TypeRTRemoteMap {} /** Merge-extensible scoped Remote method signatures generated for consumers. */ -export interface TypeRTRemoteContextMap {} +export interface TypeRTRemoteScopeMap {} /** * Resolve one direct Remote namespace from the generated flat endpoint map. @@ -57,24 +57,24 @@ export type TypeRTRemoteNamespace = { * The calling Cordis Context supplies the concrete identity at runtime. * @template Namespace - wire namespace between the Context prefix and method. */ -export type TypeRTRemoteContextNamespace< +export type TypeRTRemoteScopeNamespace< Namespace extends string, ContextKey extends string = string, > = { - [Endpoint in keyof TypeRTRemoteContextMap as Endpoint extends `${ContextKey}:${Namespace}/${infer Method}` + [Endpoint in keyof TypeRTRemoteScopeMap as Endpoint extends `${ContextKey}:${Namespace}/${infer Method}` ? Method - : never]: TypeRTRemoteContextMap[Endpoint] + : never]: TypeRTRemoteScopeMap[Endpoint] } -type TypeRTRemoteContextNamespaceKey< +type TypeRTRemoteScopeNamespaceKey< ContextKey extends string, - Endpoint = keyof TypeRTRemoteContextMap, + Endpoint = keyof TypeRTRemoteScopeMap, > = Endpoint extends `${ContextKey}:${infer Namespace}/${string}` ? Namespace : never /** Generated scoped Remote namespaces available to one Context kind. */ -export type TypeRTRemoteContextApi = { - [Namespace in TypeRTRemoteContextNamespaceKey]: - TypeRTRemoteContextNamespace +export type TypeRTRemoteScopeApi = { + [Namespace in TypeRTRemoteScopeNamespaceKey]: + TypeRTRemoteScopeNamespace } /** Merge-extensible direct namespace surface generated for Client Remote services. */ @@ -227,7 +227,7 @@ export interface TypeRTLookupDefinition { readonly wireTypeSymbol: string } -/** Host resolver for one scoped Remote Context kind. */ +/** Host resolver for one scoped Remote kind. */ export interface TypeRTHostContextProvider { /** Wire field carrying the Context identity. */ readonly wire: string diff --git a/packages/typert/type-meta/tests/fixtures/source-launch.ts b/packages/typert/type-meta/tests/fixtures/source-launch.ts index b13a80796d..14eec6610d 100644 --- a/packages/typert/type-meta/tests/fixtures/source-launch.ts +++ b/packages/typert/type-meta/tests/fixtures/source-launch.ts @@ -2,7 +2,7 @@ import { Context } from 'cordis' import { GatewayService, Remote, - RemoteContext, + RemoteScope, remoteMethods, } from '@deepseek-ai/dsh-type-meta' @@ -16,7 +16,7 @@ class Goals extends GatewayService { return value } - @RemoteContext('agent') + @RemoteScope('agent') scoped(value: string): string { return value } diff --git a/packages/typert/type-meta/tests/type-meta.spec.ts b/packages/typert/type-meta/tests/type-meta.spec.ts index b84b76300c..bfe99630b9 100644 --- a/packages/typert/type-meta/tests/type-meta.spec.ts +++ b/packages/typert/type-meta/tests/type-meta.spec.ts @@ -6,7 +6,7 @@ import { bindTypeRTGateway, GatewayService, Remote, - RemoteContext, + RemoteScope, remoteMethods, type TypeRTContext, } from '@deepseek-ai/dsh-type-meta' @@ -29,7 +29,7 @@ describe('type-meta Remote declarations', () => { return value } - @RemoteContext('metaFixture') + @RemoteScope('metaFixture') scoped(value: string): string { return value } @@ -84,7 +84,7 @@ describe('type-meta Remote declarations', () => { Reflect.get(Goals.prototype, 'create') as (this: Goals, ...args: unknown[]) => unknown, methodContext('create', initializers), ) - RemoteContext('metaFixture')( + RemoteScope('metaFixture')( Reflect.get(Goals.prototype, 'scoped') as (this: Goals, ...args: unknown[]) => unknown, methodContext('scoped', initializers), ) @@ -141,7 +141,7 @@ describe('type-meta Remote declarations', () => { Reflect.get(Service.prototype, 'run') as (this: Service, ...args: unknown[]) => unknown, methodContext('run', initializers), ) - RemoteContext('metaFixture', 'inspect')( + RemoteScope('metaFixture', 'inspect')( Reflect.get(Service.prototype, 'scoped') as (this: Service, ...args: unknown[]) => unknown, methodContext('scoped', initializers), ) @@ -166,8 +166,8 @@ describe('type-meta Remote declarations', () => { expect(() => Remote('bad name')).toThrow('export name') expect(() => Remote('.')).toThrow('export name') expect(() => Remote('..')).toThrow('export name') - expect(() => RemoteContext('' as 'metaFixture')).toThrow('Context key') - expect(() => RemoteContext('metaFixture', 'bad/name')).toThrow('export name') + expect(() => RemoteScope('' as 'metaFixture')).toThrow('Scope key') + expect(() => RemoteScope('metaFixture', 'bad/name')).toThrow('export name') for (const context of [ { ...methodContext('run', []), private: true }, @@ -195,7 +195,7 @@ describe('type-meta Remote declarations', () => { Reflect.get(Service.prototype, 'run'), methodContext('run', conflicting), ) - RemoteContext('metaFixture')( + RemoteScope('metaFixture')( Reflect.get(Service.prototype, 'run'), methodContext('run', conflicting), ) From 8bbbb6fe59d71e52f0aeefcd1c95c7bd84e55f91 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 7 Aug 2026 19:23:47 +0800 Subject: [PATCH 52/63] fix(api-gateway): compose scoped remote fixture types --- packages/api/gateway/tests/client.spec.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/api/gateway/tests/client.spec.ts b/packages/api/gateway/tests/client.spec.ts index 1383175e73..d253c38acc 100644 --- a/packages/api/gateway/tests/client.spec.ts +++ b/packages/api/gateway/tests/client.spec.ts @@ -4,6 +4,7 @@ import { z } from 'zod' import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' import type { InvocationDescriptor, + TypeRTClientRemote, TypeRTContext, TypeRTRemoteScopeApi, TypeRTRemoteNamespace, @@ -38,7 +39,9 @@ declare module '@deepseek-ai/dsh-type-meta' { } -type FixtureContext = Omit & { readonly remote: TypeRTRemoteScopeApi<'fixture'> } +type FixtureContext = Omit & { + readonly remote: TypeRTClientRemote & TypeRTRemoteScopeApi<'fixture'> +} const idSchema = z.string().min(1) const requestSchema = z.object({ objective: z.string().min(1) }) From 55ccfb5a48ab9d50b7953119036117c50057d650 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 7 Aug 2026 19:44:47 +0800 Subject: [PATCH 53/63] fix(api): preserve dynamic defaults after rebase --- packages/api/remotes/src/agent-lookup.ts | 6 +++--- packages/host/apiproxy/tests/api-proxy-cold.spec.ts | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/api/remotes/src/agent-lookup.ts b/packages/api/remotes/src/agent-lookup.ts index eb54ea9b0b..71d7a76379 100644 --- a/packages/api/remotes/src/agent-lookup.ts +++ b/packages/api/remotes/src/agent-lookup.ts @@ -20,8 +20,8 @@ export type ApiRemoteAgentResult = /** Resume configuration supplied by the owning Host composition. */ export interface ApiRemoteAgentOptions { - /** Per-Agent defaults used when a cold identity must resume. */ - readonly agentOptions?: AgentOptions + /** Read the per-Agent defaults when a cold identity must resume. */ + readonly agentOptions?: () => AgentOptions /** Host-specific Agent-scope composition completed before publication. */ readonly setup?: AgentSetup } @@ -144,7 +144,7 @@ export function createApiRemoteAgentResolver( } const handle = await ctx.agents.resume({ resumeSessionId: sessionId, - ...options.agentOptions === undefined ? {} : { agentOptions: options.agentOptions }, + ...options.agentOptions === undefined ? {} : { agentOptions: options.agentOptions() }, ...options.setup === undefined ? {} : { setup: options.setup }, }) return handle.agent diff --git a/packages/host/apiproxy/tests/api-proxy-cold.spec.ts b/packages/host/apiproxy/tests/api-proxy-cold.spec.ts index e5e137f0c4..8e79c641a8 100644 --- a/packages/host/apiproxy/tests/api-proxy-cold.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-cold.spec.ts @@ -206,7 +206,7 @@ describe('Remote Agent and Session lookup policy', () => { }) const defaultAgentLookup = ctx.typert.lookups.get('agent') const defaultSessionLookup = ctx.typert.lookups.get('session') - createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) await vi.waitFor(() => { expect(ctx.typert.lookups.get('agent')).not.toBe(defaultAgentLookup) expect(ctx.typert.lookups.get('session')).not.toBe(defaultSessionLookup) @@ -250,7 +250,7 @@ describe('Remote Agent and Session lookup policy', () => { const resume = vi.spyOn(ctx.agents, 'resume') const defaultAgentLookup = ctx.typert.lookups.get('agent') const defaultSessionLookup = ctx.typert.lookups.get('session') - createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) await vi.waitFor(() => { expect(ctx.typert.lookups.get('agent')).not.toBe(defaultAgentLookup) expect(ctx.typert.lookups.get('session')).not.toBe(defaultSessionLookup) From 5c2625c26eb6affb3732be713823ec655dabf5a0 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 7 Aug 2026 20:02:12 +0800 Subject: [PATCH 54/63] docs(typert): align client remote type mapping --- scripts/type-equiv.manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index ecb13f167c..095fa25b4f 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -1552,7 +1552,7 @@ }, { "doc": "docs/core-data-structures/typert.md", - "symbol": "TypeRTClientApi", + "symbol": "TypeRTClientRemote", "source": "packages/typert/type-meta/src/types.ts" } ] From 14ea7e134d0fe90b54218b651da7c00ca4e89a1f Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 7 Aug 2026 20:02:12 +0800 Subject: [PATCH 55/63] fix(api-remotes): await namespace assembly startup --- packages/api/remotes/src/client/index.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/api/remotes/src/client/index.ts b/packages/api/remotes/src/client/index.ts index ebd342300e..be92b02d77 100644 --- a/packages/api/remotes/src/client/index.ts +++ b/packages/api/remotes/src/client/index.ts @@ -20,7 +20,8 @@ export const inject = ['remote'] /** * Mount the Host capabilities explicitly selected for this Client assembly. * @param ctx - Client Cordis root carrying the typed API service. + * @returns disposer after every selected Remote namespace is ready. */ -export function apply(ctx: Context): Promise<() => Promise> { - return ctx.remote.$mount(goalsRemote) +export async function apply(ctx: Context): Promise<() => Promise> { + return await ctx.remote.$mount(goalsRemote) } From 8b51a1e95c4bc87d69ac5c060ec390ea571b08f4 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 7 Aug 2026 20:11:42 +0800 Subject: [PATCH 56/63] test(api-gateway): cover namespace rollback paths --- packages/api/gateway/src/client/index.ts | 58 +++++++++-------------- packages/api/gateway/tests/client.spec.ts | 40 ++++++++++++++++ 2 files changed, 62 insertions(+), 36 deletions(-) diff --git a/packages/api/gateway/src/client/index.ts b/packages/api/gateway/src/client/index.ts index d0429339c8..e49e9e5822 100644 --- a/packages/api/gateway/src/client/index.ts +++ b/packages/api/gateway/src/client/index.ts @@ -194,7 +194,7 @@ class ClientRemoteService extends Service implements TypeRTClientRemote { throw error } return async () => { - if (!namespace.service.remove('direct', descriptor.method, token)) return + namespace.service.remove('direct', descriptor.method, token) await this.disposeNamespace(descriptor.namespace, namespace) } } @@ -212,7 +212,7 @@ class ClientRemoteService extends Service implements TypeRTClientRemote { throw error } return async () => { - if (!namespace.service.remove('scoped', descriptor.method, token)) return + namespace.service.remove('scoped', descriptor.method, token) await this.disposeNamespace(descriptor.namespace, namespace) } } @@ -390,50 +390,36 @@ class RemoteNamespaceService extends Service { let record = this.methods.get(method) const fresh = record === undefined record ??= {} - if (record[kind] !== undefined) { - throw new Error(`client api: ${kind} method ${this.namespace}/${method} is already mounted`) - } - try { - if (fresh) { - Object.defineProperty(this, method, { - configurable: true, - enumerable: true, - get: function (this: RemoteNamespaceService): (...args: unknown[]) => Promise { - const callerCtx = this.ctx - const current = this.methods.get(method) - const direct = current?.direct - const scoped = current?.scoped - return (...args: unknown[]) => { - return this.invokeRemote(direct, scoped, callerCtx, args) - } - }, - }) - this.methods.set(method, record) - } - if (kind === 'direct') record.direct = value - else record.scoped = value as ScopedMethod - } catch (error) { - if (kind === 'direct') delete record.direct - else delete record.scoped - if (fresh) { - this.methods.delete(method) - Reflect.deleteProperty(this, method) - } - throw error + if (fresh) { + Object.defineProperty(this, method, { + configurable: true, + enumerable: true, + get: function (this: RemoteNamespaceService): (...args: unknown[]) => Promise { + const callerCtx = this.ctx + const current = this.methods.get(method) + const direct = current?.direct + const scoped = current?.scoped + return (...args: unknown[]) => { + return this.invokeRemote(direct, scoped, callerCtx, args) + } + }, + }) + this.methods.set(method, record) } + if (kind === 'direct') record.direct = value + else record.scoped = value as ScopedMethod } - remove(kind: 'direct' | 'scoped', method: string, token: MountToken): boolean { + remove(kind: 'direct' | 'scoped', method: string, token: MountToken): void { const record = this.methods.get(method) const current = record?.[kind] /* v8 ignore next -- duplicate live variants are rejected before installation, so no newer token can replace this one. */ - if (record === undefined || current?.token !== token) return false + if (record === undefined || current?.token !== token) return if (kind === 'direct') delete record.direct else delete record.scoped - if (record.direct !== undefined || record.scoped !== undefined) return true + if (record.direct !== undefined || record.scoped !== undefined) return this.methods.delete(method) Reflect.deleteProperty(this, method) - return true } } diff --git a/packages/api/gateway/tests/client.spec.ts b/packages/api/gateway/tests/client.spec.ts index d253c38acc..01bb9c53b7 100644 --- a/packages/api/gateway/tests/client.spec.ts +++ b/packages/api/gateway/tests/client.spec.ts @@ -316,6 +316,32 @@ describe('Client TypeRT API', () => { await retry() }) + it('rolls back a direct projection when its scoped projection fails to install', async () => { + const ctx = await bench(vi.fn()) + const disposeContext = await ctx.remote.$mount({ + package: '@fixture/context-anchor', + descriptors: [contextDescriptor()], + }) + const namespace = ctx.get('remote.goals') as unknown as { + installScoped: (...args: unknown[]) => void + readonly create?: unknown + } + const installScoped = vi.spyOn(namespace, 'installScoped').mockImplementation(() => { + throw new Error('fixture scoped projection failure') + }) + try { + await expect(ctx.remote.$mount({ + package: '@fixture/direct-projection-failure', + descriptors: [directDescriptor()], + })).rejects.toThrow('fixture scoped projection failure') + } finally { + installScoped.mockRestore() + } + + expect(namespace.create).toBeUndefined() + await disposeContext() + }) + it('rejects weak parameter and Context codecs plus malformed scope projections', async () => { const ctx = await bench(vi.fn()) const direct = directDescriptor() @@ -406,6 +432,20 @@ describe('Client TypeRT API', () => { expect((ctx.remote as unknown as Record).goals).toBeUndefined() }) + it('rejects a method obtained from a withdrawn namespace getter', async () => { + const ctx = await bench(vi.fn()) + const dispose = await ctx.remote.$mount({ package: '@fixture/goals', descriptors: [directDescriptor()] }) + const namespace = ctx.get('remote.goals') as unknown as object + const getter = Object.getOwnPropertyDescriptor(namespace, 'create')?.get + + await dispose() + + expect(getter).toBeTypeOf('function') + const withdrawn = getter?.call(namespace) as (...args: unknown[]) => Promise + await expect(withdrawn('agent-1', { objective: 'ship' })) + .rejects.toThrow('Remote method is no longer mounted') + }) + it('preserves a __proto__ wire parameter as an own named argument', async () => { const call = vi.fn() .mockResolvedValue({ ok: true, value: { ref: 'goal-1' } }) From 00a559bf2b97caf8f15b2e8303cbb17b061692de Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 7 Aug 2026 20:16:29 +0800 Subject: [PATCH 57/63] test(api-gateway): assert withdrawn method failure --- packages/api/gateway/tests/client.spec.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/api/gateway/tests/client.spec.ts b/packages/api/gateway/tests/client.spec.ts index 01bb9c53b7..2284fa662c 100644 --- a/packages/api/gateway/tests/client.spec.ts +++ b/packages/api/gateway/tests/client.spec.ts @@ -432,7 +432,7 @@ describe('Client TypeRT API', () => { expect((ctx.remote as unknown as Record).goals).toBeUndefined() }) - it('rejects a method obtained from a withdrawn namespace getter', async () => { + it('fails a method obtained from a withdrawn namespace getter', async () => { const ctx = await bench(vi.fn()) const dispose = await ctx.remote.$mount({ package: '@fixture/goals', descriptors: [directDescriptor()] }) const namespace = ctx.get('remote.goals') as unknown as object @@ -442,8 +442,8 @@ describe('Client TypeRT API', () => { expect(getter).toBeTypeOf('function') const withdrawn = getter?.call(namespace) as (...args: unknown[]) => Promise - await expect(withdrawn('agent-1', { objective: 'ship' })) - .rejects.toThrow('Remote method is no longer mounted') + expect(() => withdrawn('agent-1', { objective: 'ship' })) + .toThrow('Remote method is no longer mounted') }) it('preserves a __proto__ wire parameter as an own named argument', async () => { From ddd43ec3718eb4d97e1db072940e35073cad4d6c Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 7 Aug 2026 20:29:39 +0800 Subject: [PATCH 58/63] test(api-gateway): repair CI fixtures --- packages/api/gateway/tests/client.spec.ts | 6 +++--- .../translation-prompt-v4/request-response.expected.json | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/api/gateway/tests/client.spec.ts b/packages/api/gateway/tests/client.spec.ts index 2284fa662c..641ea81ebc 100644 --- a/packages/api/gateway/tests/client.spec.ts +++ b/packages/api/gateway/tests/client.spec.ts @@ -436,12 +436,12 @@ describe('Client TypeRT API', () => { const ctx = await bench(vi.fn()) const dispose = await ctx.remote.$mount({ package: '@fixture/goals', descriptors: [directDescriptor()] }) const namespace = ctx.get('remote.goals') as unknown as object - const getter = Object.getOwnPropertyDescriptor(namespace, 'create')?.get + const getWithdrawn = Object.getOwnPropertyDescriptor(namespace, 'create')?.get?.bind(namespace) await dispose() - expect(getter).toBeTypeOf('function') - const withdrawn = getter?.call(namespace) as (...args: unknown[]) => Promise + expect(getWithdrawn).toBeTypeOf('function') + const withdrawn = getWithdrawn?.() as (...args: unknown[]) => Promise expect(() => withdrawn('agent-1', { objective: 'ship' })) .toThrow('Remote method is no longer mounted') }) diff --git a/scripts/snapshots/translation-prompt-v4/request-response.expected.json b/scripts/snapshots/translation-prompt-v4/request-response.expected.json index 794cf18f49..91509f3267 100644 --- a/scripts/snapshots/translation-prompt-v4/request-response.expected.json +++ b/scripts/snapshots/translation-prompt-v4/request-response.expected.json @@ -16,11 +16,11 @@ }, { "role": "user", - "content": "# Development guide\n\nEnglish | [中文](development.zh.md)\n\nThe setup tutorial takes a new contributor from prerequisites to a checked checkout. The contributor reference that follows covers repository layout, daily workflow, and CI shape. Design rationale and implementation details belong to the linked Agent Notes and scripts.\n\n## Setup tutorial\n\n### Prerequisites\n\n- Node.js supports 22.19+ and 24+. CI covers 22.19, 24, and 26; see the [Node engine floor Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md).\n- Corepack-enabled pnpm. The repo pins `pnpm@11.7.0` in `package.json`; run `corepack enable` if `pnpm --version` does not resolve through Corepack.\n- Git 2.26 or newer; hook setup enables Git's worktree-specific configuration extension.\n- Optional: a DeepSeek API key for the Web, headless, and ACP automation demos and real-API e2e tests.\n\n### First-time setup\n\nInstall dependencies from the repo root:\n\n```sh\npnpm install\n```\n\nThe install also configures worktree-local lefthook hooks through `scripts/install-lefthook.mjs`. The [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) owns the safety and migration contract.\n\nIf hooks are missing because dependencies were restored from cache or `postinstall` was skipped, install them manually:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\nIf the wrapper rejects existing Git configuration or reports a stale lock, follow its diagnostic and the linked Agent Note rather than editing worktree metadata speculatively. After moving a checkout, rerun the wrapper to regenerate the owned path.\n\nRun typecheck once after a fresh clone:\n\n```sh\npnpm run typecheck\n```\n\nSetup is complete when `pnpm run typecheck` exits successfully.\n\n## Contributor reference\n\n### TypeScript project layout\n\nThe repository typecheck runs the whole-repo `tsc -b` graph: it emits every package/vendor `lib/types` and checks examples, tests, and scripts through two no-emit aggregates.\n\nThe repository's TypeScript configuration has exactly three roles; every tsconfig file plays one of them.\n\n| File | Role | Forms a program? |\n|---|---|---|\n| `tsconfig.json` | Solution root: `extends` base, `files: []`, references to the two aggregates. The whole-repo `tsc -b tsconfig.json` graph, the tsserver discovery entry, and — through the inherited `paths` — the resolution config for tsx running `examples/` and `scripts/` (their nearest tsconfig is this file). | No |\n| `tsconfig.host.json` | Host aggregate: host-side packages (via references), examples, tests, scripts, website. Excludes `packages/client`. | Yes |\n| `tsconfig.client.json` | Client aggregate: `packages/client/*` packages and their tests, `apps/web`. | Yes |\n| `tsconfig.base.json` | Shared compilerOptions and the source `paths` map. Also the resolution facade the vitest configs point vite-tsconfig-paths at: it has no `include`, so its `paths` apply to every importer. | No |\n| `tsconfig.base.client.json` | Browser compiler shape (`jsx`, DOM libs, `types: []`) extended by the client aggregate and every `packages/client/*` package. | No |\n\nHost and client stay two aggregate programs because both sides declaration-merge the cordis `Context` interface under the same keys with different services; one program seeing both merges reports a collision. The collision exists only inside a `ts.Program` — module resolution never triggers it — which is why the solution may reference both aggregates and one paths facade may span both sides. Two disciplines follow:\n\n- `tsconfig.base.json` never gains `include` or `files`: they would leak into every extending package project and narrow the facade's match-all scope.\n- A script that builds a repo-wide `ts.Program` seeds `tsconfig.host.json` or `tsconfig.client.json` explicitly — never the root solution, because flattening both aggregates into one program collides the `Context` merges. Program-backed generators and gates (`scripts/ts-project.ts` consumers, doc-typecheck standalone mode) are host-only by decision; the client side gains program-backed tooling only with a concrete need.\n\nStatic analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. Decision record: [solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md); the tsc-first emit pipeline is the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md).\n\nBusiness services declare callable methods on the Host with `@Remote` or `@RemoteContext`; the Host build generates Host-for-Client types and runtime contributions, and the Client's `api-remotes` composition loads those contributions and mounts concrete methods on `ctx.api` or the corresponding scoped Context. See [API Gateway](api-gateway.md) for the generated artifacts on both sides, their assembly relationships, the SRC development fallback, and the Web build order.\n\nIf a relevant local check consumes built package output, build once first:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` includes `publint`, which validates package entrypoints against the built `lib/*.js` files, and `verify-node-next-types`, which validates built declarations against a temporary NodeNext consumer. A fresh worktree has no bundled JS or declarations until `pnpm run build` runs; ordinary commits and pushes do not require that build unless their selected checks consume it.\n\n### Environment variables\n\nThe real DeepSeek adapter and key-backed agent demos read credentials from the environment or from a gitignored `.env` at the repo root:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` is optional and defaults to the public API. Never commit real credentials. The real-API e2e suites self-skip when `DEEPSEEK_API_KEY` is not set.\n\n### Git hooks\n\nlefthook is configured in `lefthook.yml` as a fast local checkpoint:\n\n- `pre-commit` applies formatting-only ESLint fixes, validates the staged files with Oxlint and applies its native fixes, regenerates `THIRD_PARTY_NOTICES.md` when a staged file is one of its inputs, checks the staged diff for whitespace errors, and runs the vendor manifest guard.\n- `pre-push` runs only the incremental repository typecheck (`tsc -b` over the root solution, covering both the host and client aggregates).\n\nThe vendor manifest guard checks that changes under `vendor/*/src` are staged with the matching `vendor/README.md` manifest update. See `vendor/README.md` before editing vendored code.\n\nThe hooks intentionally do not run tests, snapshots, documentation checks, builds, or hygiene. Contributors run the [checks relevant to the changed behavior](../AGENTS.md#run-relevant-checks-locally) once; CI owns exhaustive coverage, built-artifact smokes, and the Node 22.19, 24, and 26 compatibility matrix.\n\nContributors can opt into the comprehensive local gate set with `pnpm run check:all`. The command is independent of both Git hooks and is not an agent instruction.\n\n### CI gates\n\nThe keyless [CI workflow](../.github/workflows/ci.yml) groups independent gates into broad lanes and runs a smaller compatibility signal across supported Node versions. Artifact consumers wait for one build within their lane. The separate real-API workflow runs `pnpm run test:e2e` with its configured worker bound. See [scripts/run-gates.ts](../scripts/run-gates.ts) and the workflow files for the current gate and job inventory.\n\n### Daily commands\n\nThe root [contributor instructions](../AGENTS.md#commands) summarize common commands, while [`package.json`](../package.json) and [scripts/run-gates.ts](../scripts/run-gates.ts) own the current script and gate inventories. Select the smallest checks that cover the changed surface. Documentation changes use `pnpm run doc-sync`; package-public behavior changes also update the owning README or JSDoc, and built-artifact checks require `pnpm run build` first.\n\n### Demos\n\nThe one-shot Headless coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\nThe self-referential cordis demo can inspect and modify its live plugin runtime and needs the same credentials (`web` by default, or `acp`):\n\n```sh\npnpm run demo:cordis\n```\n\nThe ACP automation server exposes fresh agent sessions over JSON-RPC stdio and also needs `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n### TODO markers\n\nUse one of three comment tags to flag known issues in the code, ordered by urgency:\n\n- `FIXME` — an issue that should block a new release. A release should not ship with an open `FIXME` unless reviewers explicitly agree the change can be merged anyway.\n- `TODO` — an issue that should be fixed soon, once we have the resources.\n- `XXX` — an issue that we may fix someday; lowest priority, no commitment.\n\nPick the tag that matches the urgency so anyone scanning the code can tell a release blocker from a someday-maybe.\n\n### Documenting types verbatim (`ts type-equiv`)\n\nThe [core data structures](core-data-structures/core.md) docs paste source-equivalent declarations together with their original JSDoc so a reader sees the exact shape and source contract. To keep a paste from drifting when source changes, fence it as ` ```ts type-equiv ` (instead of ` ```ts `) and register it in `scripts/type-equiv.manifest.json` with the source file and symbol it mirrors:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration and attached JSDoc from source via the TypeScript parser and asserts the block matches both. For a class whose implementation bodies do not belong in the catalog, use ` ```ts public-api ` and set `\"projection\": \"public-api\"`; the checked projection retains the public fields, constructor, accessors, methods, and original class/member JSDoc while omitting bodies and private or protected members. Comparison ignores whitespace and non-JSDoc comments but requires every original JSDoc comment, including member documentation, so readers see the source contract beside the exact shape. The gate enforces a 1:1 correspondence by document, symbol, and projection between primary blocks and manifest entries; a paired `.zh.md` block reuses its unsuffixed sibling's entry only when the whole tracked fence sequence is byte-identical and ordered identically. `doc-typecheck` applies the same derivative rule to compilable fences, while skipping both source-equivalence fence kinds from compilation and its opt-out ratio. When you change a documented declaration or its JSDoc, the gate fails until you update the paste; when you add or remove a primary block, update the manifest in the same change.\n" + "content": "# Development guide\n\nEnglish | [中文](development.zh.md)\n\nThe setup tutorial takes a new contributor from prerequisites to a checked checkout. The contributor reference that follows covers repository layout, daily workflow, and CI shape. Design rationale and implementation details belong to the linked Agent Notes and scripts.\n\n## Setup tutorial\n\n### Prerequisites\n\n- Node.js supports 22.19+ and 24+. CI covers 22.19, 24, and 26; see the [Node engine floor Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md).\n- Corepack-enabled pnpm. The repo pins `pnpm@11.7.0` in `package.json`; run `corepack enable` if `pnpm --version` does not resolve through Corepack.\n- Git 2.26 or newer; hook setup enables Git's worktree-specific configuration extension.\n- Optional: a DeepSeek API key for the Web, headless, and ACP automation demos and real-API e2e tests.\n\n### First-time setup\n\nInstall dependencies from the repo root:\n\n```sh\npnpm install\n```\n\nThe install also configures worktree-local lefthook hooks through `scripts/install-lefthook.mjs`. The [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) owns the safety and migration contract.\n\nIf hooks are missing because dependencies were restored from cache or `postinstall` was skipped, install them manually:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\nIf the wrapper rejects existing Git configuration or reports a stale lock, follow its diagnostic and the linked Agent Note rather than editing worktree metadata speculatively. After moving a checkout, rerun the wrapper to regenerate the owned path.\n\nRun typecheck once after a fresh clone:\n\n```sh\npnpm run typecheck\n```\n\nSetup is complete when `pnpm run typecheck` exits successfully.\n\n## Contributor reference\n\n### TypeScript project layout\n\nThe repository typecheck runs the whole-repo `tsc -b` graph: it emits every package/vendor `lib/types` and checks examples, tests, and scripts through two no-emit aggregates.\n\nThe repository's TypeScript configuration has exactly three roles; every tsconfig file plays one of them.\n\n| File | Role | Forms a program? |\n|---|---|---|\n| `tsconfig.json` | Solution root: `extends` base, `files: []`, references to the two aggregates. The whole-repo `tsc -b tsconfig.json` graph, the tsserver discovery entry, and — through the inherited `paths` — the resolution config for tsx running `examples/` and `scripts/` (their nearest tsconfig is this file). | No |\n| `tsconfig.host.json` | Host aggregate: host-side packages (via references), examples, tests, scripts, website. Excludes `packages/client`. | Yes |\n| `tsconfig.client.json` | Client aggregate: `packages/client/*` packages and their tests, `apps/web`. | Yes |\n| `tsconfig.base.json` | Shared compilerOptions and the source `paths` map. Also the resolution facade the vitest configs point vite-tsconfig-paths at: it has no `include`, so its `paths` apply to every importer. | No |\n| `tsconfig.base.client.json` | Browser compiler shape (`jsx`, DOM libs, `types: []`) extended by the client aggregate and every `packages/client/*` package. | No |\n\nHost and client stay two aggregate programs because both sides declaration-merge the cordis `Context` interface under the same keys with different services; one program seeing both merges reports a collision. The collision exists only inside a `ts.Program` — module resolution never triggers it — which is why the solution may reference both aggregates and one paths facade may span both sides. Two disciplines follow:\n\n- `tsconfig.base.json` never gains `include` or `files`: they would leak into every extending package project and narrow the facade's match-all scope.\n- A script that builds a repo-wide `ts.Program` seeds `tsconfig.host.json` or `tsconfig.client.json` explicitly — never the root solution, because flattening both aggregates into one program collides the `Context` merges. Program-backed generators and gates (`scripts/ts-project.ts` consumers, doc-typecheck standalone mode) are host-only by decision; the client side gains program-backed tooling only with a concrete need.\n\nStatic analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. Decision record: [solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md); the tsc-first emit pipeline is the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md).\n\nBusiness services declare callable methods on the Host with `@Remote` or `@RemoteScope`; the Host build generates Host-for-Client types and runtime contributions, and the Client's `api-remotes` composition loads those contributions under `ctx.remote` and scoped `agentCtx.remote` namespaces. See [API Gateway](api-gateway.md) for the generated artifacts on both sides, their assembly relationships, the SRC development fallback, and the Web build order.\n\nIf a relevant local check consumes built package output, build once first:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` includes `publint`, which validates package entrypoints against the built `lib/*.js` files, and `verify-node-next-types`, which validates built declarations against a temporary NodeNext consumer. A fresh worktree has no bundled JS or declarations until `pnpm run build` runs; ordinary commits and pushes do not require that build unless their selected checks consume it.\n\n### Environment variables\n\nThe real DeepSeek adapter and key-backed agent demos read credentials from the environment or from a gitignored `.env` at the repo root:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` is optional and defaults to the public API. Never commit real credentials. The real-API e2e suites self-skip when `DEEPSEEK_API_KEY` is not set.\n\n### Git hooks\n\nlefthook is configured in `lefthook.yml` as a fast local checkpoint:\n\n- `pre-commit` applies formatting-only ESLint fixes, validates the staged files with Oxlint and applies its native fixes, regenerates `THIRD_PARTY_NOTICES.md` when a staged file is one of its inputs, checks the staged diff for whitespace errors, and runs the vendor manifest guard.\n- `pre-push` runs only the incremental repository typecheck (`tsc -b` over the root solution, covering both the host and client aggregates).\n\nThe vendor manifest guard checks that changes under `vendor/*/src` are staged with the matching `vendor/README.md` manifest update. See `vendor/README.md` before editing vendored code.\n\nThe hooks intentionally do not run tests, snapshots, documentation checks, builds, or hygiene. Contributors run the [checks relevant to the changed behavior](../AGENTS.md#run-relevant-checks-locally) once; CI owns exhaustive coverage, built-artifact smokes, and the Node 22.19, 24, and 26 compatibility matrix.\n\nContributors can opt into the comprehensive local gate set with `pnpm run check:all`. The command is independent of both Git hooks and is not an agent instruction.\n\n### CI gates\n\nThe keyless [CI workflow](../.github/workflows/ci.yml) groups independent gates into broad lanes and runs a smaller compatibility signal across supported Node versions. Artifact consumers wait for one build within their lane. The separate real-API workflow runs `pnpm run test:e2e` with its configured worker bound. See [scripts/run-gates.ts](../scripts/run-gates.ts) and the workflow files for the current gate and job inventory.\n\n### Daily commands\n\nThe root [contributor instructions](../AGENTS.md#commands) summarize common commands, while [`package.json`](../package.json) and [scripts/run-gates.ts](../scripts/run-gates.ts) own the current script and gate inventories. Select the smallest checks that cover the changed surface. Documentation changes use `pnpm run doc-sync`; package-public behavior changes also update the owning README or JSDoc, and built-artifact checks require `pnpm run build` first.\n\n### Demos\n\nThe one-shot Headless coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\nThe self-referential cordis demo can inspect and modify its live plugin runtime and needs the same credentials (`web` by default, or `acp`):\n\n```sh\npnpm run demo:cordis\n```\n\nThe ACP automation server exposes fresh agent sessions over JSON-RPC stdio and also needs `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n### TODO markers\n\nUse one of three comment tags to flag known issues in the code, ordered by urgency:\n\n- `FIXME` — an issue that should block a new release. A release should not ship with an open `FIXME` unless reviewers explicitly agree the change can be merged anyway.\n- `TODO` — an issue that should be fixed soon, once we have the resources.\n- `XXX` — an issue that we may fix someday; lowest priority, no commitment.\n\nPick the tag that matches the urgency so anyone scanning the code can tell a release blocker from a someday-maybe.\n\n### Documenting types verbatim (`ts type-equiv`)\n\nThe [core data structures](core-data-structures/core.md) docs paste source-equivalent declarations together with their original JSDoc so a reader sees the exact shape and source contract. To keep a paste from drifting when source changes, fence it as ` ```ts type-equiv ` (instead of ` ```ts `) and register it in `scripts/type-equiv.manifest.json` with the source file and symbol it mirrors:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration and attached JSDoc from source via the TypeScript parser and asserts the block matches both. For a class whose implementation bodies do not belong in the catalog, use ` ```ts public-api ` and set `\"projection\": \"public-api\"`; the checked projection retains the public fields, constructor, accessors, methods, and original class/member JSDoc while omitting bodies and private or protected members. Comparison ignores whitespace and non-JSDoc comments but requires every original JSDoc comment, including member documentation, so readers see the source contract beside the exact shape. The gate enforces a 1:1 correspondence by document, symbol, and projection between primary blocks and manifest entries; a paired `.zh.md` block reuses its unsuffixed sibling's entry only when the whole tracked fence sequence is byte-identical and ordered identically. `doc-typecheck` applies the same derivative rule to compilable fences, while skipping both source-equivalence fence kinds from compilation and its opt-out ratio. When you change a documented declaration or its JSDoc, the gate fails until you update the paste; when you add or remove a primary block, update the manifest in the same change.\n" }, { "role": "assistant", - "content": "# 开发指南\n\n[English](development.md) | 中文\n\n搭建教程引导新贡献者从准备前置条件开始,直到检出通过检查。后面的贡献者参考介绍仓库布局、日常工作流和 CI 形态。设计依据与实现细节属于链接的 Agent Note 和脚本。\n\n## 搭建教程\n\n### 前置条件\n\n- Node.js 支持 22.19+ 与 24+。CI 覆盖 22.19、24 和 26;见 [Node 引擎下限 Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md)。\n- 启用了 Corepack 的 pnpm。仓库在 `package.json` 中固定使用 `pnpm@11.7.0`;如果 `pnpm --version` 无法通过 Corepack 解析,请先运行 `corepack enable`。\n- Git 2.26 或更高版本;钩子设置会启用 Git 的 worktree 专属配置扩展。\n- 可选:一个 DeepSeek API key,用于 Web、headless 和 ACP(Agent Client Protocol)自动化 agent(智能体)演示以及真实 API 的 e2e 测试。\n\n### 首次搭建\n\n在仓库根目录安装依赖:\n\n```sh\npnpm install\n```\n\n安装过程还会通过 `scripts/install-lefthook.mjs` 配置 worktree 本地的 lefthook 钩子。其安全与迁移契约由 [worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) 负责。\n\n如果依赖是从缓存恢复或 `postinstall` 被跳过而导致缺少钩子,请手动安装:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\n如果包装脚本拒绝现有 Git 配置或报告陈旧锁,请遵循其诊断和所链接的 Agent Note,不要凭猜测编辑 worktree 元数据。移动检出目录后,请重新运行包装脚本以重新生成自有路径。\n\n新克隆后请先运行一次类型检查:\n\n```sh\npnpm run typecheck\n```\n\n`pnpm run typecheck` 成功退出即表示搭建完成。\n\n## 贡献者参考\n\n### TypeScript 项目布局\n\n仓库类型检查会执行全仓 `tsc -b` 图:它会发射每个 package/vendor 的 `lib/types`,并通过两个 no-emit 聚合检查示例、测试和脚本。\n\n仓库的 TypeScript 配置只有三种角色;每个 tsconfig 文件恰好扮演其中一种。\n\n| 文件 | 角色 | 是否构成 program? |\n|---|---|---|\n| `tsconfig.json` | solution 根:`extends` base、`files: []`、引用两个聚合。全仓 `tsc -b tsconfig.json` 图、tsserver 发现入口,并经继承的 `paths` 充当 tsx 运行 `examples/` 与 `scripts/` 时的解析配置(它们最近的 tsconfig 就是此文件)。 | 否 |\n| `tsconfig.host.json` | host 聚合:host 侧各包(经 references)、示例、测试、脚本、website。排除 `packages/client`。 | 是 |\n| `tsconfig.client.json` | client 聚合:`packages/client/*` 各包及其测试、`apps/web`。 | 是 |\n| `tsconfig.base.json` | 共享 compilerOptions 与源码 `paths` 映射。同时是各 vitest 配置让 vite-tsconfig-paths 指向的解析门面:它没有 `include`,因此其 `paths` 适用于任何 importer。 | 否 |\n| `tsconfig.base.client.json` | 浏览器编译形状(`jsx`、DOM lib、`types: []`),由 client 聚合和每个 `packages/client/*` 包 extends。 | 否 |\n\nhost 与 client 保持两个聚合 program,是因为两侧在相同键下以不同服务对 cordis `Context` 接口做声明合并;单一 program 同时看到两份合并会报冲突。这种冲突只存在于 `ts.Program` 内部——模块解析永远不会触发它——所以 solution 可以同时引用两个聚合,一个 paths 门面也可以横跨两侧。由此推出两条纪律:\n\n- `tsconfig.base.json` 永不添加 `include` 或 `files`:它们会泄漏进每个 extends 它的包项目,并收窄门面的全匹配范围。\n- 构造全仓 `ts.Program` 的脚本显式种子 `tsconfig.host.json` 或 `tsconfig.client.json`——永不种子根 solution,因为把两个聚合展平进一个 program 会撞上 `Context` 合并冲突。基于 program 的生成器与门禁(`scripts/ts-project.ts` 的消费者、doc-typecheck standalone 模式)按决策仅覆盖 host 侧;client 侧只在出现真实需求时再获得基于 program 的工具。\n\n静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。决策记录:[solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md);tsc-first 发射管线见 [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md)。\n\n业务 Service 在 Host 使用 `@Remote` 或 `@RemoteContext` 声明可调用方法;Host 构建生成 Host-for-Client 类型与运行时贡献,Client 的 `api-remotes` 组合加载这些贡献并将具体方法挂载到 `ctx.api` 或对应的作用域 Context。两侧的生成产物、装配关系、SRC 开发回退和 Web 构建顺序见 [API Gateway](api-gateway.md)。\n\n如果相关的本地检查需要使用构建后的包产物,请先构建一次:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` 包含 `publint`(用构建出的 `lib/*.js` 文件校验 package 入口点)和 `verify-node-next-types`(用一个临时的 NodeNext 消费方校验构建出的声明文件)。新 worktree 在 `pnpm run build` 运行之前没有打包的 JS 和声明文件;普通提交和推送无需构建,除非所选检查会使用这些产物。\n\n### 环境变量\n\n真实的 DeepSeek 适配器和需要密钥的 agent 演示从环境变量或仓库根目录一个被 gitignore 的 `.env` 文件读取凭证:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` 可选,默认为公开 API。请勿提交真实凭证。未设置 `DEEPSEEK_API_KEY` 时,真实 API 的 e2e 套件会自动跳过。\n\n### Git 钩子\n\nlefthook 在 `lefthook.yml` 中配置,作为快速的本地检查点:\n\n- `pre-commit` 应用仅用于格式化的 ESLint 修复,使用 Oxlint 验证暂存文件并应用其原生修复,在暂存文件属于 `THIRD_PARTY_NOTICES.md` 的输入时重新生成该文件,然后检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫;\n- `pre-push` 只运行仓库增量类型检查(对根 solution 执行 `tsc -b`,覆盖 host 与 client 两个聚合)。\n\nvendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。请在编辑 vendor 代码前先阅读 `vendor/README.md`。\n\n这些钩子有意不运行测试、快照、文档检查、构建或 `hygiene`。贡献者只运行一次[与改动行为相关的检查](../AGENTS.md#run-relevant-checks-locally);CI 负责全量覆盖率门禁、构建产物冒烟测试,以及 Node 22.19、24 和 26 兼容性矩阵。\n\n贡献者可以选择运行 `pnpm run check:all`,执行全面的本地门禁集。该命令独立于两个 Git 钩子,也不是对 agent 的指令。\n\n### CI 门禁\n\nkeyless [CI 工作流](../.github/workflows/ci.yml) 将独立门禁分组到若干宽粒度 lane,并在受支持的 Node 版本上运行一组较小的兼容性检查。产物消费方在各自 lane 内等待一次 build。单独的真实 API 工作流按其配置的 worker 上限运行 `pnpm run test:e2e`。当前门禁和 job 清单以 [scripts/run-gates.ts](../scripts/run-gates.ts) 和工作流文件为准。\n\n### 日常命令\n\n根目录的[贡献者说明](../AGENTS.md#commands)概述常用命令,[`package.json`](../package.json) 与 [scripts/run-gates.ts](../scripts/run-gates.ts) 则负责当前脚本和门禁清单。请选择覆盖变更表面的最小检查集。文档变更使用 `pnpm run doc-sync`;package 公开行为变更还需更新所属 README 或 JSDoc,而基于构建产物的检查需要先运行 `pnpm run build`。\n\n### 演示\n\n单次运行的 Headless coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\n自指的 cordis 演示可以检查并修改其实时插件运行时,并需要相同的凭证(默认 `web`,也可用 `acp`):\n\n```sh\npnpm run demo:cordis\n```\n\nACP 自动化服务器通过 JSON-RPC stdio 提供全新 agent 会话,同样需要 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n### TODO 标记\n\n请使用以下三种注释标签之一标记代码中的已知问题,按紧急程度排序:\n\n- `FIXME`:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 `FIXME`;\n- `TODO`:应当尽快修复的问题,等资源到位即可处理;\n- `XXX`:也许某天会修复的问题,优先级最低,不作承诺。\n\n请选择与紧急程度匹配的标签,让浏览代码的人一眼分清「发布阻塞」和「有空再说」。\n\n### 逐字记录类型(`ts type-equiv`)\n\n[核心数据结构](core-data-structures/core.md)文档会把与源码等价的声明及其原始 JSDoc 一并粘贴,让读者看到确切形状和源码契约。为防止粘贴内容在源码变化时漂移,请将其围栏为 ` ```ts type-equiv `(而不是 ` ```ts `),并在 `scripts/type-equiv.manifest.json` 中登记它镜像的源文件和符号:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明及其附带的 JSDoc,并断言代码块同时匹配两者。对于不应把实现体写进目录的类,请使用 ` ```ts public-api ` 并设置 `\"projection\": \"public-api\"`;门禁检查的投影会保留公共字段、构造函数、访问器、方法以及类和成员的原始 JSDoc,同时省略实现体和私有或受保护成员。比对会忽略空白和非 JSDoc 注释,但要求保留每条原始 JSDoc(包括成员文档),让读者同时看到源码契约和确切形状。该门禁按文档、符号和投影,在主块与 manifest 条目之间强制 1:1 对应;只有当配对 `.zh.md` 块的完整受跟踪围栏序列与其无后缀兄弟文件按字节一致且顺序相同时,才会复用后者的条目。`doc-typecheck` 对可编译围栏应用同一派生规则,同时跳过两种源码等价围栏的编译,并将其排除在 opt-out 比例之外。当你改动一个已记录的类型声明或其 JSDoc 时,门禁会失败直到你更新粘贴内容;当你增删一个主块时,请在同一个变更里更新 manifest。\n" + "content": "# 开发指南\n\n[English](development.md) | 中文\n\n搭建教程引导新贡献者从准备前置条件开始,直到检出通过检查。后面的贡献者参考介绍仓库布局、日常工作流和 CI 形态。设计依据与实现细节属于链接的 Agent Note 和脚本。\n\n## 搭建教程\n\n### 前置条件\n\n- Node.js 支持 22.19+ 与 24+。CI 覆盖 22.19、24 和 26;见 [Node 引擎下限 Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md)。\n- 启用了 Corepack 的 pnpm。仓库在 `package.json` 中固定使用 `pnpm@11.7.0`;如果 `pnpm --version` 无法通过 Corepack 解析,请先运行 `corepack enable`。\n- Git 2.26 或更高版本;钩子设置会启用 Git 的 worktree 专属配置扩展。\n- 可选:一个 DeepSeek API key,用于 Web、headless 和 ACP(Agent Client Protocol)自动化 agent(智能体)演示以及真实 API 的 e2e 测试。\n\n### 首次搭建\n\n在仓库根目录安装依赖:\n\n```sh\npnpm install\n```\n\n安装过程还会通过 `scripts/install-lefthook.mjs` 配置 worktree 本地的 lefthook 钩子。其安全与迁移契约由 [worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) 负责。\n\n如果依赖是从缓存恢复或 `postinstall` 被跳过而导致缺少钩子,请手动安装:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\n如果包装脚本拒绝现有 Git 配置或报告陈旧锁,请遵循其诊断和所链接的 Agent Note,不要凭猜测编辑 worktree 元数据。移动检出目录后,请重新运行包装脚本以重新生成自有路径。\n\n新克隆后请先运行一次类型检查:\n\n```sh\npnpm run typecheck\n```\n\n`pnpm run typecheck` 成功退出即表示搭建完成。\n\n## 贡献者参考\n\n### TypeScript 项目布局\n\n仓库类型检查会执行全仓 `tsc -b` 图:它会发射每个 package/vendor 的 `lib/types`,并通过两个 no-emit 聚合检查示例、测试和脚本。\n\n仓库的 TypeScript 配置只有三种角色;每个 tsconfig 文件恰好扮演其中一种。\n\n| 文件 | 角色 | 是否构成 program? |\n|---|---|---|\n| `tsconfig.json` | solution 根:`extends` base、`files: []`、引用两个聚合。全仓 `tsc -b tsconfig.json` 图、tsserver 发现入口,并经继承的 `paths` 充当 tsx 运行 `examples/` 与 `scripts/` 时的解析配置(它们最近的 tsconfig 就是此文件)。 | 否 |\n| `tsconfig.host.json` | host 聚合:host 侧各包(经 references)、示例、测试、脚本、website。排除 `packages/client`。 | 是 |\n| `tsconfig.client.json` | client 聚合:`packages/client/*` 各包及其测试、`apps/web`。 | 是 |\n| `tsconfig.base.json` | 共享 compilerOptions 与源码 `paths` 映射。同时是各 vitest 配置让 vite-tsconfig-paths 指向的解析门面:它没有 `include`,因此其 `paths` 适用于任何 importer。 | 否 |\n| `tsconfig.base.client.json` | 浏览器编译形状(`jsx`、DOM lib、`types: []`),由 client 聚合和每个 `packages/client/*` 包 extends。 | 否 |\n\nhost 与 client 保持两个聚合 program,是因为两侧在相同键下以不同服务对 cordis `Context` 接口做声明合并;单一 program 同时看到两份合并会报冲突。这种冲突只存在于 `ts.Program` 内部——模块解析永远不会触发它——所以 solution 可以同时引用两个聚合,一个 paths 门面也可以横跨两侧。由此推出两条纪律:\n\n- `tsconfig.base.json` 永不添加 `include` 或 `files`:它们会泄漏进每个 extends 它的包项目,并收窄门面的全匹配范围。\n- 构造全仓 `ts.Program` 的脚本显式种子 `tsconfig.host.json` 或 `tsconfig.client.json`——永不种子根 solution,因为把两个聚合展平进一个 program 会撞上 `Context` 合并冲突。基于 program 的生成器与门禁(`scripts/ts-project.ts` 的消费者、doc-typecheck standalone 模式)按决策仅覆盖 host 侧;client 侧只在出现真实需求时再获得基于 program 的工具。\n\n静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。决策记录:[solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md);tsc-first 发射管线见 [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md)。\n\n业务 Service 在 Host 使用 `@Remote` 或 `@RemoteScope` 声明可调用方法;Host 构建生成 Host-for-Client 类型与运行时贡献,Client 的 `api-remotes` 组合加载这些贡献并挂到 `ctx.remote` 与作用域 `agentCtx.remote` namespace。两侧的生成产物、装配关系、SRC 开发回退和 Web 构建顺序见 [API Gateway](api-gateway.md)。\n\n如果相关的本地检查需要使用构建后的包产物,请先构建一次:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` 包含 `publint`(用构建出的 `lib/*.js` 文件校验 package 入口点)和 `verify-node-next-types`(用一个临时的 NodeNext 消费方校验构建出的声明文件)。新 worktree 在 `pnpm run build` 运行之前没有打包的 JS 和声明文件;普通提交和推送无需构建,除非所选检查会使用这些产物。\n\n### 环境变量\n\n真实的 DeepSeek 适配器和需要密钥的 agent 演示从环境变量或仓库根目录一个被 gitignore 的 `.env` 文件读取凭证:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` 可选,默认为公开 API。请勿提交真实凭证。未设置 `DEEPSEEK_API_KEY` 时,真实 API 的 e2e 套件会自动跳过。\n\n### Git 钩子\n\nlefthook 在 `lefthook.yml` 中配置,作为快速的本地检查点:\n\n- `pre-commit` 应用仅用于格式化的 ESLint 修复,使用 Oxlint 验证暂存文件并应用其原生修复,在暂存文件属于 `THIRD_PARTY_NOTICES.md` 的输入时重新生成该文件,然后检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫;\n- `pre-push` 只运行仓库增量类型检查(对根 solution 执行 `tsc -b`,覆盖 host 与 client 两个聚合)。\n\nvendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。请在编辑 vendor 代码前先阅读 `vendor/README.md`。\n\n这些钩子有意不运行测试、快照、文档检查、构建或 `hygiene`。贡献者只运行一次[与改动行为相关的检查](../AGENTS.md#run-relevant-checks-locally);CI 负责全量覆盖率门禁、构建产物冒烟测试,以及 Node 22.19、24 和 26 兼容性矩阵。\n\n贡献者可以选择运行 `pnpm run check:all`,执行全面的本地门禁集。该命令独立于两个 Git 钩子,也不是对 agent 的指令。\n\n### CI 门禁\n\nkeyless [CI 工作流](../.github/workflows/ci.yml) 将独立门禁分组到若干宽粒度 lane,并在受支持的 Node 版本上运行一组较小的兼容性检查。产物消费方在各自 lane 内等待一次 build。单独的真实 API 工作流按其配置的 worker 上限运行 `pnpm run test:e2e`。当前门禁和 job 清单以 [scripts/run-gates.ts](../scripts/run-gates.ts) 和工作流文件为准。\n\n### 日常命令\n\n根目录的[贡献者说明](../AGENTS.md#commands)概述常用命令,[`package.json`](../package.json) 与 [scripts/run-gates.ts](../scripts/run-gates.ts) 则负责当前脚本和门禁清单。请选择覆盖变更表面的最小检查集。文档变更使用 `pnpm run doc-sync`;package 公开行为变更还需更新所属 README 或 JSDoc,而基于构建产物的检查需要先运行 `pnpm run build`。\n\n### 演示\n\n单次运行的 Headless coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\n自指的 cordis 演示可以检查并修改其实时插件运行时,并需要相同的凭证(默认 `web`,也可用 `acp`):\n\n```sh\npnpm run demo:cordis\n```\n\nACP 自动化服务器通过 JSON-RPC stdio 提供全新 agent 会话,同样需要 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n### TODO 标记\n\n请使用以下三种注释标签之一标记代码中的已知问题,按紧急程度排序:\n\n- `FIXME`:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 `FIXME`;\n- `TODO`:应当尽快修复的问题,等资源到位即可处理;\n- `XXX`:也许某天会修复的问题,优先级最低,不作承诺。\n\n请选择与紧急程度匹配的标签,让浏览代码的人一眼分清「发布阻塞」和「有空再说」。\n\n### 逐字记录类型(`ts type-equiv`)\n\n[核心数据结构](core-data-structures/core.md)文档会把与源码等价的声明及其原始 JSDoc 一并粘贴,让读者看到确切形状和源码契约。为防止粘贴内容在源码变化时漂移,请将其围栏为 ` ```ts type-equiv `(而不是 ` ```ts `),并在 `scripts/type-equiv.manifest.json` 中登记它镜像的源文件和符号:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明及其附带的 JSDoc,并断言代码块同时匹配两者。对于不应把实现体写进目录的类,请使用 ` ```ts public-api ` 并设置 `\"projection\": \"public-api\"`;门禁检查的投影会保留公共字段、构造函数、访问器、方法以及类和成员的原始 JSDoc,同时省略实现体和私有或受保护成员。比对会忽略空白和非 JSDoc 注释,但要求保留每条原始 JSDoc(包括成员文档),让读者同时看到源码契约和确切形状。该门禁按文档、符号和投影,在主块与 manifest 条目之间强制 1:1 对应;只有当配对 `.zh.md` 块的完整受跟踪围栏序列与其无后缀兄弟文件按字节一致且顺序相同时,才会复用后者的条目。`doc-typecheck` 对可编译围栏应用同一派生规则,同时跳过两种源码等价围栏的编译,并将其排除在 opt-out 比例之外。当你改动一个已记录的类型声明或其 JSDoc 时,门禁会失败直到你更新粘贴内容;当你增删一个主块时,请在同一个变更里更新 manifest。\n" }, { "role": "user", From 71ebeaa55985a4033c86793f6c91fc0fe65cf8b0 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 7 Aug 2026 21:43:34 +0800 Subject: [PATCH 59/63] fix(client-runtime): localize remote namespace dependency --- docs/api-gateway.i18n.yaml | 4 ++-- docs/api-gateway.md | 4 +++- docs/api-gateway.zh.md | 4 +++- packages/client/runtime/src/client/index.ts | 4 ++-- packages/client/runtime/tests/client-apply.spec.ts | 1 - packages/client/runtime/tests/wire-events.spec.ts | 1 - 6 files changed, 10 insertions(+), 8 deletions(-) diff --git a/docs/api-gateway.i18n.yaml b/docs/api-gateway.i18n.yaml index 2a6ae0807b..074644ff3e 100644 --- a/docs/api-gateway.i18n.yaml +++ b/docs/api-gateway.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/api-gateway.md -api-gateway.md: ba95d429dd0c9f9f354baf0063197cea6e3ecbf8 -api-gateway.zh.md: 4e42ebea7a5db19c7df23079050b9488679a3a23 +api-gateway.md: 33dfb30c9da25e46b660a3fa54ef37f587cbda08 +api-gateway.zh.md: 633eb10c0f2f065ecf27545813cc17d79f391865 diff --git a/docs/api-gateway.md b/docs/api-gateway.md index ba95d429dd..33dfb30c9d 100644 --- a/docs/api-gateway.md +++ b/docs/api-gateway.md @@ -55,7 +55,7 @@ export class GoalService extends GatewayService { Remote methods may return a value synchronously or return a Promise. For cooperative cancellation, the final parameter in the Host signature must be `signal: AbortSignal` using the global type; it is recorded in the descriptor instead of entering `args`, while the generated Client method accepts an optional final `AbortSignal`. -The Client uses concrete functions on ordinary objects, not a JavaScript Proxy. Direct and scoped calls appear under `ctx.remote.` and `agentCtx.remote.`. Each namespace is a traced Cordis child Service registered as `remote.`; the Client assembly mounts contributions through `ctx.remote.$mount()`, consumers inject both `remote` and the namespace Service they call, and the namespace unloads after its last method is withdrawn. When an `@Remote` method has exactly one lookup parameter and a same-named `TypeRTContextMap` uses the same wire identity, the generated scoped signature omits that identity parameter. `@RemoteScope` generates only the scoped invocation interface. +The Client uses concrete functions on ordinary objects, not a JavaScript Proxy. Direct and scoped calls appear under `ctx.remote.` and `agentCtx.remote.`. Each namespace is a traced Cordis child Service registered as `remote.`; the Client assembly mounts contributions through `ctx.remote.$mount()`, and the namespace unloads after its last method is withdrawn. Dependency declarations belong to the actual caller: only a business package that reads `ctx.remote.` or `agentCtx.remote.` declares both `remote` and `remote.` in its own `inject`; assemblies that only mount contributions and higher-level runtimes that do not call that namespace do not declare the namespace dependency on the business package's behalf. When an `@Remote` method has exactly one lookup parameter and a same-named `TypeRTContextMap` uses the same wire identity, the generated scoped signature omits that identity parameter. `@RemoteScope` generates only the scoped invocation interface. ```ts import type { SessionId } from '@deepseek-ai/dsh-session/types' @@ -63,6 +63,8 @@ import type { AgentContext } from '@deepseek-ai/dsh-client-runtime/client' import type { Context } from 'cordis' import type {} from '@deepseek-ai/dsh-api-remotes/client' +export const inject = ['remote', 'remote.goals'] + declare const ctx: Context declare const agentCtx: AgentContext declare const agentId: SessionId diff --git a/docs/api-gateway.zh.md b/docs/api-gateway.zh.md index 4e42ebea7a..633eb10c0f 100644 --- a/docs/api-gateway.zh.md +++ b/docs/api-gateway.zh.md @@ -55,7 +55,7 @@ export class GoalService extends GatewayService { Remote 方法可以同步返回或返回 Promise。若需要协作式取消,Host 签名的最后一个参数必须是全局类型的 `signal: AbortSignal`;它记录在描述符中而不是进入 `args`,Client 生成的方法则接受最后一个可选的 `AbortSignal`。 -Client 使用普通对象上的具体函数,不使用 JavaScript Proxy。直接调用与作用域调用分别出现在 `ctx.remote.` 和 `agentCtx.remote.`。每个 namespace 都是注册为 `remote.` 的可追踪 Cordis 子 Service;Client assembly 通过 `ctx.remote.$mount()` 挂载贡献,消费方同时注入 `remote` 与所调用的 namespace Service,最后一个方法撤回后该 namespace 随即卸载。当一个 `@Remote` 方法恰好有一个 lookup 参数、且同名 `TypeRTContextMap` 使用相同 wire identity 时,生成的作用域签名会省略该 identity 参数。`@RemoteScope` 只生成作用域调用界面。 +Client 使用普通对象上的具体函数,不使用 JavaScript Proxy。直接调用与作用域调用分别出现在 `ctx.remote.` 和 `agentCtx.remote.`。每个 namespace 都是注册为 `remote.` 的可追踪 Cordis 子 Service;Client assembly 通过 `ctx.remote.$mount()` 挂载贡献,最后一个方法撤回后该 namespace 随即卸载。依赖声明归实际调用方所有:只有读取 `ctx.remote.` 或 `agentCtx.remote.` 的业务包才在自己的 `inject` 中同时声明 `remote` 与 `remote.`;只负责挂载 contribution 的 assembly,以及不调用该 namespace 的上层 runtime,不代业务包声明 namespace 依赖。当一个 `@Remote` 方法恰好有一个 lookup 参数、且同名 `TypeRTContextMap` 使用相同 wire identity 时,生成的作用域签名会省略该 identity 参数。`@RemoteScope` 只生成作用域调用界面。 ```ts import type { SessionId } from '@deepseek-ai/dsh-session/types' @@ -63,6 +63,8 @@ import type { AgentContext } from '@deepseek-ai/dsh-client-runtime/client' import type { Context } from 'cordis' import type {} from '@deepseek-ai/dsh-api-remotes/client' +export const inject = ['remote', 'remote.goals'] + declare const ctx: Context declare const agentCtx: AgentContext declare const agentId: SessionId diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index b772e315a3..5a1677df96 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -179,8 +179,8 @@ declare module 'cordis' { } } -/** Required services: the Remote root and Goal namespace, wire handle, and Client TypeRT registry. */ -export const inject = ['remote', 'remote.goals', 'connection', 'typert'] +/** Required services: the Remote root, wire handle, and Client TypeRT registry. */ +export const inject = ['remote', 'connection', 'typert'] /** Mounts the browser runtime services and connection stream. * @param ctx - Client Cordis context. diff --git a/packages/client/runtime/tests/client-apply.spec.ts b/packages/client/runtime/tests/client-apply.spec.ts index e9b387fb00..b700c4c066 100644 --- a/packages/client/runtime/tests/client-apply.spec.ts +++ b/packages/client/runtime/tests/client-apply.spec.ts @@ -39,7 +39,6 @@ async function mount(): Promise { } ctx.reflect.provide('connection', handle) ctx.reflect.provide('remote', {}) - ctx.reflect.provide('remote.goals', {}) await ctx.plugin(RuntimeClient).await() return bench } diff --git a/packages/client/runtime/tests/wire-events.spec.ts b/packages/client/runtime/tests/wire-events.spec.ts index 703c5b1728..dfafcd07aa 100644 --- a/packages/client/runtime/tests/wire-events.spec.ts +++ b/packages/client/runtime/tests/wire-events.spec.ts @@ -33,7 +33,6 @@ async function mount(): Promise { } ctx.reflect.provide('connection', handle) ctx.reflect.provide('remote', {}) - ctx.reflect.provide('remote.goals', {}) await ctx.plugin(RuntimeClient).await() return bench } From 38c373af65923abcd8fff7c200f5a64ca93b617b Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 7 Aug 2026 22:41:09 +0800 Subject: [PATCH 60/63] fix(ci): keep issue policy test discovery focused --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 9698d0d2fa..81cb40acad 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "test": "vitest run", "test:coverage": "vitest run --coverage", "test:e2e": "vitest run --config vitest.e2e.config.ts", - "test:issue-management": "node --test .github/issue-management/policy.test.mjs", + "test:issue-management": "node .github/issue-management/policy.test.mjs", "test:snapshot": "vitest run --config vitest.snapshot.config.ts", "test:snapshot:record": "DSH_SNAPSHOT=record vitest run --config vitest.snapshot.config.ts --update", "test:snapshot:refresh": "DSH_SNAPSHOT=refresh vitest run --config vitest.snapshot.config.ts", From db0133b4c713d7e40f79e82db1e1480b997f27df Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 7 Aug 2026 22:52:19 +0800 Subject: [PATCH 61/63] docs(notes): archive low-value records --- ...-native-typescript-source-launch.i18n.yaml | 6 ++ ...-28-dsh-native-typescript-source-launch.md | 1 + ...-dsh-native-typescript-source-launch.zh.md | 1 + ...tion-composer-rows-do-not-shrink.i18n.yaml | 6 ++ ...27-question-composer-rows-do-not-shrink.md | 1 + ...question-composer-rows-do-not-shrink.zh.md | 1 + ...28-web-conversation-polish-sweep.i18n.yaml | 6 ++ ...026-07-28-web-conversation-polish-sweep.md | 1 + ...-07-28-web-conversation-polish-sweep.zh.md | 1 + ...07-30-web-details-default-closed.i18n.yaml | 6 ++ .../2026-07-30-web-details-default-closed.md | 1 + ...026-07-30-web-details-default-closed.zh.md | 1 + ...isible-while-blank-session-opens.i18n.yaml | 6 ++ ...-hero-visible-while-blank-session-opens.md | 1 + ...ro-visible-while-blank-session-opens.zh.md | 1 + ...versation-column-one-axis-scroll.i18n.yaml | 6 ++ ...-04-conversation-column-one-axis-scroll.md | 1 + ...-conversation-column-one-axis-scroll.zh.md | 5 +- .../2026-07-22-docked-web-goal-bar.i18n.yaml | 6 ++ .../feature/2026-07-22-docked-web-goal-bar.md | 1 + .../2026-07-22-docked-web-goal-bar.zh.md | 1 + ...b-message-icon-actions-and-clock.i18n.yaml | 6 ++ ...7-29-web-message-icon-actions-and-clock.md | 1 + ...9-web-message-icon-actions-and-clock.zh.md | 1 + .../2026-07-30-dsh-dump-config.i18n.yaml | 6 ++ .../feature/2026-07-30-dsh-dump-config.md | 1 + .../feature/2026-07-30-dsh-dump-config.zh.md | 1 + ...-composer-stats-and-input-polish.i18n.yaml | 6 ++ ...-30-web-composer-stats-and-input-polish.md | 1 + ...-web-composer-stats-and-input-polish.zh.md | 1 + ...web-context-injection-disclosure.i18n.yaml | 6 ++ ...-07-30-web-context-injection-disclosure.md | 1 + ...-30-web-context-injection-disclosure.zh.md | 1 + ...2026-07-31-hover-card-click-copy.i18n.yaml | 6 ++ .../2026-07-31-hover-card-click-copy.md | 1 + .../2026-07-31-hover-card-click-copy.zh.md | 1 + .../2026-07-31-web-cards-toolrow.i18n.yaml | 6 ++ .../feature/2026-07-31-web-cards-toolrow.md | 1 + .../2026-07-31-web-cards-toolrow.zh.md | 1 + .agents/notes/archived/manifest.json | 59 ++++++++++++++++++- ...6-06-20-generated-cordis-catalog.i18n.yaml | 6 ++ .../2026-06-20-generated-cordis-catalog.md | 1 + .../2026-06-20-generated-cordis-catalog.zh.md | 1 + ...ntsource-parser-for-deepseek-sse.i18n.yaml | 6 ++ ...-26-eventsource-parser-for-deepseek-sse.md | 1 + ...-eventsource-parser-for-deepseek-sse.zh.md | 1 + ...ndown-for-tool-web-html-markdown.i18n.yaml | 6 ++ ...-26-turndown-for-tool-web-html-markdown.md | 1 + ...-turndown-for-tool-web-html-markdown.zh.md | 1 + ...ebar-resize-without-visible-pill.i18n.yaml | 6 ++ ...-30-sidebar-resize-without-visible-pill.md | 1 + ...-sidebar-resize-without-visible-pill.zh.md | 1 + ...eer-entry-or-interjection-chrome.i18n.yaml | 6 ++ ...i-no-steer-entry-or-interjection-chrome.md | 1 + ...o-steer-entry-or-interjection-chrome.zh.md | 1 + ...eca-for-test-subprocess-plumbing.i18n.yaml | 6 ++ ...7-26-execa-for-test-subprocess-plumbing.md | 1 + ...6-execa-for-test-subprocess-plumbing.zh.md | 1 + .../2026-06-13-twin-llm-adapters.i18n.yaml | 4 +- .../2026-06-13-twin-llm-adapters.md | 2 +- .../2026-06-13-twin-llm-adapters.zh.md | 2 +- ...-native-typescript-source-launch.i18n.yaml | 6 -- ...-07-29-dsh-source-launch-tsx-esm.i18n.yaml | 4 +- .../2026-07-29-dsh-source-launch-tsx-esm.md | 4 +- ...2026-07-29-dsh-source-launch-tsx-esm.zh.md | 4 +- ...30-session-end-seed-log-boundary.i18n.yaml | 4 +- ...026-07-30-session-end-seed-log-boundary.md | 2 +- ...-07-30-session-end-seed-log-boundary.zh.md | 2 +- ...tion-composer-rows-do-not-shrink.i18n.yaml | 6 -- ...28-web-conversation-polish-sweep.i18n.yaml | 6 -- ...29-web-details-session-lifecycle.i18n.yaml | 4 +- ...026-07-29-web-details-session-lifecycle.md | 2 +- ...-07-29-web-details-session-lifecycle.zh.md | 2 +- ...07-30-web-details-default-closed.i18n.yaml | 6 -- ...isible-while-blank-session-opens.i18n.yaml | 6 -- ...versation-column-one-axis-scroll.i18n.yaml | 6 -- ...actions-require-a-completed-turn.i18n.yaml | 4 +- ...n-tail-actions-require-a-completed-turn.md | 2 +- ...ail-actions-require-a-completed-turn.zh.md | 2 +- ...26-07-20-dsh-cli-personal-config.i18n.yaml | 4 +- .../2026-07-20-dsh-cli-personal-config.md | 2 +- .../2026-07-20-dsh-cli-personal-config.zh.md | 2 +- .../2026-07-22-docked-web-goal-bar.i18n.yaml | 6 -- ...b-message-icon-actions-and-clock.i18n.yaml | 6 -- .../2026-07-30-dsh-dump-config.i18n.yaml | 6 -- ...-composer-stats-and-input-polish.i18n.yaml | 6 -- ...web-context-injection-disclosure.i18n.yaml | 6 -- ...2026-07-31-hover-card-click-copy.i18n.yaml | 6 -- .../2026-07-31-web-cards-toolrow.i18n.yaml | 6 -- ...b-context-source-and-steer-marks.i18n.yaml | 4 +- ...8-04-web-context-source-and-steer-marks.md | 4 +- ...4-web-context-source-and-steer-marks.zh.md | 4 +- ...-20-core-data-structures-catalog.i18n.yaml | 4 +- ...2026-06-20-core-data-structures-catalog.md | 4 +- ...6-06-20-core-data-structures-catalog.zh.md | 4 +- ...6-06-20-generated-cordis-catalog.i18n.yaml | 6 -- ...ntsource-parser-for-deepseek-sse.i18n.yaml | 6 -- ...ndown-for-tool-web-html-markdown.i18n.yaml | 6 -- ...ebar-resize-without-visible-pill.i18n.yaml | 6 -- ...eer-entry-or-interjection-chrome.i18n.yaml | 6 -- ...3-explicit-config-dsh-entrypoint.i18n.yaml | 4 +- ...26-08-03-explicit-config-dsh-entrypoint.md | 2 +- ...08-03-explicit-config-dsh-entrypoint.zh.md | 2 +- ...eca-for-test-subprocess-plumbing.i18n.yaml | 6 -- ...-29-session-resumed-log-boundary.i18n.yaml | 6 -- ...2026-07-29-session-resumed-log-boundary.md | 51 ---------------- ...6-07-29-session-resumed-log-boundary.zh.md | 51 ---------------- ...nimplemented-subagent-vocabulary.i18n.yaml | 6 -- ...prune-unimplemented-subagent-vocabulary.md | 39 ------------ ...ne-unimplemented-subagent-vocabulary.zh.md | 39 ------------ ...ency-swaps-rejected-by-nih-audit.i18n.yaml | 4 +- ...-dependency-swaps-rejected-by-nih-audit.md | 4 +- ...pendency-swaps-rejected-by-nih-audit.zh.md | 4 +- .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 2 +- packages/client/ui-primitives/README.zh.md | 2 +- packages/web/tool-web/README.i18n.yaml | 4 +- packages/web/tool-web/README.md | 2 +- packages/web/tool-web/README.zh.md | 2 +- 122 files changed, 272 insertions(+), 369 deletions(-) create mode 100644 .agents/notes/archived/architecture/2026-07-28-dsh-native-typescript-source-launch.i18n.yaml rename .agents/notes/{implemented => archived}/architecture/2026-07-28-dsh-native-typescript-source-launch.md (99%) rename .agents/notes/{implemented => archived}/architecture/2026-07-28-dsh-native-typescript-source-launch.zh.md (99%) create mode 100644 .agents/notes/archived/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.i18n.yaml rename .agents/notes/{implemented => archived}/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.md (99%) rename .agents/notes/{implemented => archived}/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.zh.md (99%) create mode 100644 .agents/notes/archived/bug-fix/2026-07-28-web-conversation-polish-sweep.i18n.yaml rename .agents/notes/{implemented => archived}/bug-fix/2026-07-28-web-conversation-polish-sweep.md (99%) rename .agents/notes/{implemented => archived}/bug-fix/2026-07-28-web-conversation-polish-sweep.zh.md (99%) create mode 100644 .agents/notes/archived/bug-fix/2026-07-30-web-details-default-closed.i18n.yaml rename .agents/notes/{implemented => archived}/bug-fix/2026-07-30-web-details-default-closed.md (99%) rename .agents/notes/{implemented => archived}/bug-fix/2026-07-30-web-details-default-closed.zh.md (99%) create mode 100644 .agents/notes/archived/bug-fix/2026-07-31-hero-visible-while-blank-session-opens.i18n.yaml rename .agents/notes/{implemented => archived}/bug-fix/2026-07-31-hero-visible-while-blank-session-opens.md (99%) rename .agents/notes/{implemented => archived}/bug-fix/2026-07-31-hero-visible-while-blank-session-opens.zh.md (99%) create mode 100644 .agents/notes/archived/bug-fix/2026-08-04-conversation-column-one-axis-scroll.i18n.yaml rename .agents/notes/{implemented => archived}/bug-fix/2026-08-04-conversation-column-one-axis-scroll.md (99%) rename .agents/notes/{implemented => archived}/bug-fix/2026-08-04-conversation-column-one-axis-scroll.zh.md (97%) create mode 100644 .agents/notes/archived/feature/2026-07-22-docked-web-goal-bar.i18n.yaml rename .agents/notes/{implemented => archived}/feature/2026-07-22-docked-web-goal-bar.md (99%) rename .agents/notes/{implemented => archived}/feature/2026-07-22-docked-web-goal-bar.zh.md (99%) create mode 100644 .agents/notes/archived/feature/2026-07-29-web-message-icon-actions-and-clock.i18n.yaml rename .agents/notes/{implemented => archived}/feature/2026-07-29-web-message-icon-actions-and-clock.md (99%) rename .agents/notes/{implemented => archived}/feature/2026-07-29-web-message-icon-actions-and-clock.zh.md (99%) create mode 100644 .agents/notes/archived/feature/2026-07-30-dsh-dump-config.i18n.yaml rename .agents/notes/{implemented => archived}/feature/2026-07-30-dsh-dump-config.md (99%) rename .agents/notes/{implemented => archived}/feature/2026-07-30-dsh-dump-config.zh.md (99%) create mode 100644 .agents/notes/archived/feature/2026-07-30-web-composer-stats-and-input-polish.i18n.yaml rename .agents/notes/{implemented => archived}/feature/2026-07-30-web-composer-stats-and-input-polish.md (99%) rename .agents/notes/{implemented => archived}/feature/2026-07-30-web-composer-stats-and-input-polish.zh.md (99%) create mode 100644 .agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.i18n.yaml rename .agents/notes/{implemented => archived}/feature/2026-07-30-web-context-injection-disclosure.md (99%) rename .agents/notes/{implemented => archived}/feature/2026-07-30-web-context-injection-disclosure.zh.md (99%) create mode 100644 .agents/notes/archived/feature/2026-07-31-hover-card-click-copy.i18n.yaml rename .agents/notes/{implemented => archived}/feature/2026-07-31-hover-card-click-copy.md (99%) rename .agents/notes/{implemented => archived}/feature/2026-07-31-hover-card-click-copy.zh.md (99%) create mode 100644 .agents/notes/archived/feature/2026-07-31-web-cards-toolrow.i18n.yaml rename .agents/notes/{implemented => archived}/feature/2026-07-31-web-cards-toolrow.md (99%) rename .agents/notes/{implemented => archived}/feature/2026-07-31-web-cards-toolrow.zh.md (99%) create mode 100644 .agents/notes/archived/process/2026-06-20-generated-cordis-catalog.i18n.yaml rename .agents/notes/{implemented => archived}/process/2026-06-20-generated-cordis-catalog.md (99%) rename .agents/notes/{implemented => archived}/process/2026-06-20-generated-cordis-catalog.zh.md (99%) create mode 100644 .agents/notes/archived/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.i18n.yaml rename .agents/notes/{implemented => archived}/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.md (99%) rename .agents/notes/{implemented => archived}/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.zh.md (99%) create mode 100644 .agents/notes/archived/simplification/2026-07-26-turndown-for-tool-web-html-markdown.i18n.yaml rename .agents/notes/{implemented => archived}/simplification/2026-07-26-turndown-for-tool-web-html-markdown.md (99%) rename .agents/notes/{implemented => archived}/simplification/2026-07-26-turndown-for-tool-web-html-markdown.zh.md (99%) create mode 100644 .agents/notes/archived/simplification/2026-07-30-sidebar-resize-without-visible-pill.i18n.yaml rename .agents/notes/{implemented => archived}/simplification/2026-07-30-sidebar-resize-without-visible-pill.md (98%) rename .agents/notes/{implemented => archived}/simplification/2026-07-30-sidebar-resize-without-visible-pill.zh.md (98%) create mode 100644 .agents/notes/archived/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.i18n.yaml rename .agents/notes/{implemented => archived}/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md (99%) rename .agents/notes/{implemented => archived}/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.zh.md (99%) create mode 100644 .agents/notes/archived/testing/2026-07-26-execa-for-test-subprocess-plumbing.i18n.yaml rename .agents/notes/{implemented => archived}/testing/2026-07-26-execa-for-test-subprocess-plumbing.md (99%) rename .agents/notes/{implemented => archived}/testing/2026-07-26-execa-for-test-subprocess-plumbing.zh.md (99%) delete mode 100644 .agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.i18n.yaml delete mode 100644 .agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.i18n.yaml delete mode 100644 .agents/notes/implemented/bug-fix/2026-07-28-web-conversation-polish-sweep.i18n.yaml delete mode 100644 .agents/notes/implemented/bug-fix/2026-07-30-web-details-default-closed.i18n.yaml delete mode 100644 .agents/notes/implemented/bug-fix/2026-07-31-hero-visible-while-blank-session-opens.i18n.yaml delete mode 100644 .agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.i18n.yaml delete mode 100644 .agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.i18n.yaml delete mode 100644 .agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.i18n.yaml delete mode 100644 .agents/notes/implemented/feature/2026-07-30-dsh-dump-config.i18n.yaml delete mode 100644 .agents/notes/implemented/feature/2026-07-30-web-composer-stats-and-input-polish.i18n.yaml delete mode 100644 .agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.i18n.yaml delete mode 100644 .agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.i18n.yaml delete mode 100644 .agents/notes/implemented/feature/2026-07-31-web-cards-toolrow.i18n.yaml delete mode 100644 .agents/notes/implemented/process/2026-06-20-generated-cordis-catalog.i18n.yaml delete mode 100644 .agents/notes/implemented/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.i18n.yaml delete mode 100644 .agents/notes/implemented/simplification/2026-07-26-turndown-for-tool-web-html-markdown.i18n.yaml delete mode 100644 .agents/notes/implemented/simplification/2026-07-30-sidebar-resize-without-visible-pill.i18n.yaml delete mode 100644 .agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.i18n.yaml delete mode 100644 .agents/notes/implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.i18n.yaml delete mode 100644 .agents/notes/rejected/architecture/2026-07-29-session-resumed-log-boundary.i18n.yaml delete mode 100644 .agents/notes/rejected/architecture/2026-07-29-session-resumed-log-boundary.md delete mode 100644 .agents/notes/rejected/architecture/2026-07-29-session-resumed-log-boundary.zh.md delete mode 100644 .agents/notes/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.i18n.yaml delete mode 100644 .agents/notes/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.md delete mode 100644 .agents/notes/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.zh.md diff --git a/.agents/notes/archived/architecture/2026-07-28-dsh-native-typescript-source-launch.i18n.yaml b/.agents/notes/archived/architecture/2026-07-28-dsh-native-typescript-source-launch.i18n.yaml new file mode 100644 index 0000000000..c0fcdd30f9 --- /dev/null +++ b/.agents/notes/archived/architecture/2026-07-28-dsh-native-typescript-source-launch.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/archived/architecture/2026-07-28-dsh-native-typescript-source-launch.md +2026-07-28-dsh-native-typescript-source-launch.md: b5e8ed2a18bb0cbb3ab54cf5ed4a427efaa9dfeb +2026-07-28-dsh-native-typescript-source-launch.zh.md: 05a4f92d2d3be553bb9ca363bf08e3ebf8e3f5b8 diff --git a/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.md b/.agents/notes/archived/architecture/2026-07-28-dsh-native-typescript-source-launch.md similarity index 99% rename from .agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.md rename to .agents/notes/archived/architecture/2026-07-28-dsh-native-typescript-source-launch.md index 773f831ec2..b5e8ed2a18 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.md +++ b/.agents/notes/archived/architecture/2026-07-28-dsh-native-typescript-source-launch.md @@ -1,6 +1,7 @@ # Agent Note: Native TypeScript source launch for dsh Status: implemented +Archived: 2026-08-07 English | [中文](2026-07-28-dsh-native-typescript-source-launch.zh.md) diff --git a/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.zh.md b/.agents/notes/archived/architecture/2026-07-28-dsh-native-typescript-source-launch.zh.md similarity index 99% rename from .agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.zh.md rename to .agents/notes/archived/architecture/2026-07-28-dsh-native-typescript-source-launch.zh.md index 0e40a7e32b..05a4f92d2d 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.zh.md +++ b/.agents/notes/archived/architecture/2026-07-28-dsh-native-typescript-source-launch.zh.md @@ -1,6 +1,7 @@ # Agent Note: dsh 原生 TypeScript 源码启动 Status: implemented +Archived: 2026-08-07 [English](2026-07-28-dsh-native-typescript-source-launch.md) | 中文 diff --git a/.agents/notes/archived/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.i18n.yaml b/.agents/notes/archived/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.i18n.yaml new file mode 100644 index 0000000000..3520cf4974 --- /dev/null +++ b/.agents/notes/archived/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/archived/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.md +2026-07-27-question-composer-rows-do-not-shrink.md: 47e581a23caaeb9368b075fa84a01d2bc945ab46 +2026-07-27-question-composer-rows-do-not-shrink.zh.md: a1b72cab41c51c268540dcec60e85a0edf2fe9bc diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.md b/.agents/notes/archived/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.md similarity index 99% rename from .agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.md rename to .agents/notes/archived/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.md index 2e0e9b9ca6..47e581a23c 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.md +++ b/.agents/notes/archived/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.md @@ -1,6 +1,7 @@ # Agent Note: Question-composer option rows are scroll content, not the slack absorber Status: implemented +Archived: 2026-08-07 English | [中文](2026-07-27-question-composer-rows-do-not-shrink.zh.md) diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.zh.md b/.agents/notes/archived/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.zh.md similarity index 99% rename from .agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.zh.md rename to .agents/notes/archived/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.zh.md index 73e3e7614c..a1b72cab41 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.zh.md +++ b/.agents/notes/archived/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.zh.md @@ -1,6 +1,7 @@ # Agent Note: 提问 composer 的选项行是滚动内容,而非空间不足时的吸收方 Status: implemented +Archived: 2026-08-07 [English](2026-07-27-question-composer-rows-do-not-shrink.md) | 中文 diff --git a/.agents/notes/archived/bug-fix/2026-07-28-web-conversation-polish-sweep.i18n.yaml b/.agents/notes/archived/bug-fix/2026-07-28-web-conversation-polish-sweep.i18n.yaml new file mode 100644 index 0000000000..e77beba866 --- /dev/null +++ b/.agents/notes/archived/bug-fix/2026-07-28-web-conversation-polish-sweep.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/archived/bug-fix/2026-07-28-web-conversation-polish-sweep.md +2026-07-28-web-conversation-polish-sweep.md: 338b687c041c585c1d490fdad9b8bbcf88fc3912 +2026-07-28-web-conversation-polish-sweep.zh.md: 166662070b67837b9f4755e6457578f74af771ed diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-web-conversation-polish-sweep.md b/.agents/notes/archived/bug-fix/2026-07-28-web-conversation-polish-sweep.md similarity index 99% rename from .agents/notes/implemented/bug-fix/2026-07-28-web-conversation-polish-sweep.md rename to .agents/notes/archived/bug-fix/2026-07-28-web-conversation-polish-sweep.md index cae52217d6..338b687c04 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-28-web-conversation-polish-sweep.md +++ b/.agents/notes/archived/bug-fix/2026-07-28-web-conversation-polish-sweep.md @@ -1,6 +1,7 @@ # Agent Note: Web conversation UI polish sweep Status: implemented +Archived: 2026-08-07 English | [中文](2026-07-28-web-conversation-polish-sweep.zh.md) diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-web-conversation-polish-sweep.zh.md b/.agents/notes/archived/bug-fix/2026-07-28-web-conversation-polish-sweep.zh.md similarity index 99% rename from .agents/notes/implemented/bug-fix/2026-07-28-web-conversation-polish-sweep.zh.md rename to .agents/notes/archived/bug-fix/2026-07-28-web-conversation-polish-sweep.zh.md index 0f352f066d..166662070b 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-28-web-conversation-polish-sweep.zh.md +++ b/.agents/notes/archived/bug-fix/2026-07-28-web-conversation-polish-sweep.zh.md @@ -1,6 +1,7 @@ # Agent Note: Web 对话 UI 视觉优化 Status: implemented +Archived: 2026-08-07 [English](2026-07-28-web-conversation-polish-sweep.md) | 中文 diff --git a/.agents/notes/archived/bug-fix/2026-07-30-web-details-default-closed.i18n.yaml b/.agents/notes/archived/bug-fix/2026-07-30-web-details-default-closed.i18n.yaml new file mode 100644 index 0000000000..286ed2a62e --- /dev/null +++ b/.agents/notes/archived/bug-fix/2026-07-30-web-details-default-closed.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/archived/bug-fix/2026-07-30-web-details-default-closed.md +2026-07-30-web-details-default-closed.md: e4271917b998c9a916d8c67b30627022587547b8 +2026-07-30-web-details-default-closed.zh.md: 3b9432067c944ea604e039ee1b305a927a1c206e diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-web-details-default-closed.md b/.agents/notes/archived/bug-fix/2026-07-30-web-details-default-closed.md similarity index 99% rename from .agents/notes/implemented/bug-fix/2026-07-30-web-details-default-closed.md rename to .agents/notes/archived/bug-fix/2026-07-30-web-details-default-closed.md index 658b6fc2c1..e4271917b9 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-web-details-default-closed.md +++ b/.agents/notes/archived/bug-fix/2026-07-30-web-details-default-closed.md @@ -1,6 +1,7 @@ # Agent Note: Web details default closed Status: implemented +Archived: 2026-08-07 English | [中文](2026-07-30-web-details-default-closed.zh.md) diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-web-details-default-closed.zh.md b/.agents/notes/archived/bug-fix/2026-07-30-web-details-default-closed.zh.md similarity index 99% rename from .agents/notes/implemented/bug-fix/2026-07-30-web-details-default-closed.zh.md rename to .agents/notes/archived/bug-fix/2026-07-30-web-details-default-closed.zh.md index 5a1d0e4713..3b9432067c 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-web-details-default-closed.zh.md +++ b/.agents/notes/archived/bug-fix/2026-07-30-web-details-default-closed.zh.md @@ -1,6 +1,7 @@ # Agent Note: Web 详情栏默认关闭 Status: implemented +Archived: 2026-08-07 [English](2026-07-30-web-details-default-closed.md) | 中文 diff --git a/.agents/notes/archived/bug-fix/2026-07-31-hero-visible-while-blank-session-opens.i18n.yaml b/.agents/notes/archived/bug-fix/2026-07-31-hero-visible-while-blank-session-opens.i18n.yaml new file mode 100644 index 0000000000..0cc1847c1a --- /dev/null +++ b/.agents/notes/archived/bug-fix/2026-07-31-hero-visible-while-blank-session-opens.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/archived/bug-fix/2026-07-31-hero-visible-while-blank-session-opens.md +2026-07-31-hero-visible-while-blank-session-opens.md: 1b1f35d82731675978585d718e4ef837f0c78aa5 +2026-07-31-hero-visible-while-blank-session-opens.zh.md: 91c1a372ebc6341632820450d9e54d58c9d36916 diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-hero-visible-while-blank-session-opens.md b/.agents/notes/archived/bug-fix/2026-07-31-hero-visible-while-blank-session-opens.md similarity index 99% rename from .agents/notes/implemented/bug-fix/2026-07-31-hero-visible-while-blank-session-opens.md rename to .agents/notes/archived/bug-fix/2026-07-31-hero-visible-while-blank-session-opens.md index 6afa5d0ee2..1b1f35d827 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-hero-visible-while-blank-session-opens.md +++ b/.agents/notes/archived/bug-fix/2026-07-31-hero-visible-while-blank-session-opens.md @@ -1,6 +1,7 @@ # Agent Note: Hero stays visible while a blank session opens Status: implemented +Archived: 2026-08-07 English | [中文](2026-07-31-hero-visible-while-blank-session-opens.zh.md) diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-hero-visible-while-blank-session-opens.zh.md b/.agents/notes/archived/bug-fix/2026-07-31-hero-visible-while-blank-session-opens.zh.md similarity index 99% rename from .agents/notes/implemented/bug-fix/2026-07-31-hero-visible-while-blank-session-opens.zh.md rename to .agents/notes/archived/bug-fix/2026-07-31-hero-visible-while-blank-session-opens.zh.md index f21e549b58..91c1a372eb 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-hero-visible-while-blank-session-opens.zh.md +++ b/.agents/notes/archived/bug-fix/2026-07-31-hero-visible-while-blank-session-opens.zh.md @@ -1,6 +1,7 @@ # Agent Note: 空白会话打开期间保持 hero 可见 Status: implemented +Archived: 2026-08-07 [English](2026-07-31-hero-visible-while-blank-session-opens.md) | 中文 diff --git a/.agents/notes/archived/bug-fix/2026-08-04-conversation-column-one-axis-scroll.i18n.yaml b/.agents/notes/archived/bug-fix/2026-08-04-conversation-column-one-axis-scroll.i18n.yaml new file mode 100644 index 0000000000..cb05519fde --- /dev/null +++ b/.agents/notes/archived/bug-fix/2026-08-04-conversation-column-one-axis-scroll.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/archived/bug-fix/2026-08-04-conversation-column-one-axis-scroll.md +2026-08-04-conversation-column-one-axis-scroll.md: e8f80c23a2ac2230079802fb6c85fec6c8b8e807 +2026-08-04-conversation-column-one-axis-scroll.zh.md: a7378b2d5ec026d6a054a080347b155cc476a57a diff --git a/.agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.md b/.agents/notes/archived/bug-fix/2026-08-04-conversation-column-one-axis-scroll.md similarity index 99% rename from .agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.md rename to .agents/notes/archived/bug-fix/2026-08-04-conversation-column-one-axis-scroll.md index 9a487c506a..e8f80c23a2 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.md +++ b/.agents/notes/archived/bug-fix/2026-08-04-conversation-column-one-axis-scroll.md @@ -1,6 +1,7 @@ # Agent Note: The conversation column scrolls on one axis Status: implemented +Archived: 2026-08-07 English | [中文](2026-08-04-conversation-column-one-axis-scroll.zh.md) diff --git a/.agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.zh.md b/.agents/notes/archived/bug-fix/2026-08-04-conversation-column-one-axis-scroll.zh.md similarity index 97% rename from .agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.zh.md rename to .agents/notes/archived/bug-fix/2026-08-04-conversation-column-one-axis-scroll.zh.md index 23441a7c86..a7378b2d5e 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.zh.md +++ b/.agents/notes/archived/bug-fix/2026-08-04-conversation-column-one-axis-scroll.zh.md @@ -1,6 +1,7 @@ -# Agent Note:会话列只在一个轴上滚动 +# Agent Note: 会话列只在一个轴上滚动 -状态:已实现 +Status: implemented +Archived: 2026-08-07 [English](2026-08-04-conversation-column-one-axis-scroll.md) | 中文 diff --git a/.agents/notes/archived/feature/2026-07-22-docked-web-goal-bar.i18n.yaml b/.agents/notes/archived/feature/2026-07-22-docked-web-goal-bar.i18n.yaml new file mode 100644 index 0000000000..535a0a8ff3 --- /dev/null +++ b/.agents/notes/archived/feature/2026-07-22-docked-web-goal-bar.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/archived/feature/2026-07-22-docked-web-goal-bar.md +2026-07-22-docked-web-goal-bar.md: decf40996b51a0f2358a943bbb928f00d4db2026 +2026-07-22-docked-web-goal-bar.zh.md: 8ebb106b260a6107084c07b51b1763adb1df2da8 diff --git a/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.md b/.agents/notes/archived/feature/2026-07-22-docked-web-goal-bar.md similarity index 99% rename from .agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.md rename to .agents/notes/archived/feature/2026-07-22-docked-web-goal-bar.md index ffddef6cec..decf40996b 100644 --- a/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.md +++ b/.agents/notes/archived/feature/2026-07-22-docked-web-goal-bar.md @@ -1,6 +1,7 @@ # Agent Note: Docked web goal bar Status: implemented +Archived: 2026-08-07 English | [中文](2026-07-22-docked-web-goal-bar.zh.md) diff --git a/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.zh.md b/.agents/notes/archived/feature/2026-07-22-docked-web-goal-bar.zh.md similarity index 99% rename from .agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.zh.md rename to .agents/notes/archived/feature/2026-07-22-docked-web-goal-bar.zh.md index b732f71cfc..8ebb106b26 100644 --- a/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.zh.md +++ b/.agents/notes/archived/feature/2026-07-22-docked-web-goal-bar.zh.md @@ -1,6 +1,7 @@ # Agent Note: 停靠式 Web 目标条 Status: implemented +Archived: 2026-08-07 [English](2026-07-22-docked-web-goal-bar.md) | 中文 diff --git a/.agents/notes/archived/feature/2026-07-29-web-message-icon-actions-and-clock.i18n.yaml b/.agents/notes/archived/feature/2026-07-29-web-message-icon-actions-and-clock.i18n.yaml new file mode 100644 index 0000000000..47400cc87e --- /dev/null +++ b/.agents/notes/archived/feature/2026-07-29-web-message-icon-actions-and-clock.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/archived/feature/2026-07-29-web-message-icon-actions-and-clock.md +2026-07-29-web-message-icon-actions-and-clock.md: a95c7d33a917026c882f17d30264cf9ec743dee5 +2026-07-29-web-message-icon-actions-and-clock.zh.md: b64c14aaa7056e19ccc4d512db3d24714e11a309 diff --git a/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.md b/.agents/notes/archived/feature/2026-07-29-web-message-icon-actions-and-clock.md similarity index 99% rename from .agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.md rename to .agents/notes/archived/feature/2026-07-29-web-message-icon-actions-and-clock.md index feced6aeb1..a95c7d33a9 100644 --- a/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.md +++ b/.agents/notes/archived/feature/2026-07-29-web-message-icon-actions-and-clock.md @@ -1,6 +1,7 @@ # Agent Note: Web message IconActions and clocks Status: implemented +Archived: 2026-08-07 English | [中文](2026-07-29-web-message-icon-actions-and-clock.zh.md) diff --git a/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.zh.md b/.agents/notes/archived/feature/2026-07-29-web-message-icon-actions-and-clock.zh.md similarity index 99% rename from .agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.zh.md rename to .agents/notes/archived/feature/2026-07-29-web-message-icon-actions-and-clock.zh.md index 5e33182421..b64c14aaa7 100644 --- a/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.zh.md +++ b/.agents/notes/archived/feature/2026-07-29-web-message-icon-actions-and-clock.zh.md @@ -1,6 +1,7 @@ # Agent Note: Web 消息 IconActions 与时钟 Status: implemented +Archived: 2026-08-07 [English](2026-07-29-web-message-icon-actions-and-clock.md) | 中文 diff --git a/.agents/notes/archived/feature/2026-07-30-dsh-dump-config.i18n.yaml b/.agents/notes/archived/feature/2026-07-30-dsh-dump-config.i18n.yaml new file mode 100644 index 0000000000..1e8be61bca --- /dev/null +++ b/.agents/notes/archived/feature/2026-07-30-dsh-dump-config.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/archived/feature/2026-07-30-dsh-dump-config.md +2026-07-30-dsh-dump-config.md: cc16f11d79b536a661d67811c6fd50705f6009e3 +2026-07-30-dsh-dump-config.zh.md: 185a2b8f37cef102b48d4dea1b3ed0a96958d220 diff --git a/.agents/notes/implemented/feature/2026-07-30-dsh-dump-config.md b/.agents/notes/archived/feature/2026-07-30-dsh-dump-config.md similarity index 99% rename from .agents/notes/implemented/feature/2026-07-30-dsh-dump-config.md rename to .agents/notes/archived/feature/2026-07-30-dsh-dump-config.md index bc6504541c..cc16f11d79 100644 --- a/.agents/notes/implemented/feature/2026-07-30-dsh-dump-config.md +++ b/.agents/notes/archived/feature/2026-07-30-dsh-dump-config.md @@ -1,6 +1,7 @@ # Agent Note: dsh --dump-config prints the composed config tree Status: implemented +Archived: 2026-08-07 English | [中文](2026-07-30-dsh-dump-config.zh.md) diff --git a/.agents/notes/implemented/feature/2026-07-30-dsh-dump-config.zh.md b/.agents/notes/archived/feature/2026-07-30-dsh-dump-config.zh.md similarity index 99% rename from .agents/notes/implemented/feature/2026-07-30-dsh-dump-config.zh.md rename to .agents/notes/archived/feature/2026-07-30-dsh-dump-config.zh.md index 5e173305a6..185a2b8f37 100644 --- a/.agents/notes/implemented/feature/2026-07-30-dsh-dump-config.zh.md +++ b/.agents/notes/archived/feature/2026-07-30-dsh-dump-config.zh.md @@ -1,6 +1,7 @@ # Agent Note: dsh --dump-config 打印合成后的配置树 Status: implemented +Archived: 2026-08-07 [English](2026-07-30-dsh-dump-config.md) | 中文 diff --git a/.agents/notes/archived/feature/2026-07-30-web-composer-stats-and-input-polish.i18n.yaml b/.agents/notes/archived/feature/2026-07-30-web-composer-stats-and-input-polish.i18n.yaml new file mode 100644 index 0000000000..5105501452 --- /dev/null +++ b/.agents/notes/archived/feature/2026-07-30-web-composer-stats-and-input-polish.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/archived/feature/2026-07-30-web-composer-stats-and-input-polish.md +2026-07-30-web-composer-stats-and-input-polish.md: 3c80b74c564a1779c69ef525f8d1f194e8915f6b +2026-07-30-web-composer-stats-and-input-polish.zh.md: eabb174e78f3171997b9103650c7fe326793ce1b diff --git a/.agents/notes/implemented/feature/2026-07-30-web-composer-stats-and-input-polish.md b/.agents/notes/archived/feature/2026-07-30-web-composer-stats-and-input-polish.md similarity index 99% rename from .agents/notes/implemented/feature/2026-07-30-web-composer-stats-and-input-polish.md rename to .agents/notes/archived/feature/2026-07-30-web-composer-stats-and-input-polish.md index 78f286cb0e..3c80b74c56 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-composer-stats-and-input-polish.md +++ b/.agents/notes/archived/feature/2026-07-30-web-composer-stats-and-input-polish.md @@ -1,6 +1,7 @@ # Agent Note: Web composer stats detail and input-zone polish Status: implemented +Archived: 2026-08-07 English | [中文](2026-07-30-web-composer-stats-and-input-polish.zh.md) diff --git a/.agents/notes/implemented/feature/2026-07-30-web-composer-stats-and-input-polish.zh.md b/.agents/notes/archived/feature/2026-07-30-web-composer-stats-and-input-polish.zh.md similarity index 99% rename from .agents/notes/implemented/feature/2026-07-30-web-composer-stats-and-input-polish.zh.md rename to .agents/notes/archived/feature/2026-07-30-web-composer-stats-and-input-polish.zh.md index eeba56d9f3..eabb174e78 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-composer-stats-and-input-polish.zh.md +++ b/.agents/notes/archived/feature/2026-07-30-web-composer-stats-and-input-polish.zh.md @@ -1,6 +1,7 @@ # Agent Note: Web composer stats detail and input-zone polish Status: implemented +Archived: 2026-08-07 [English](2026-07-30-web-composer-stats-and-input-polish.md) | 中文 diff --git a/.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.i18n.yaml b/.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.i18n.yaml new file mode 100644 index 0000000000..09f2268a0f --- /dev/null +++ b/.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.md +2026-07-30-web-context-injection-disclosure.md: e9551cacdcd5b3e45ba35eeb76db6c70e5bbe368 +2026-07-30-web-context-injection-disclosure.zh.md: a4937ba880df30b911f7c8a1eceaab7e2bb74026 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md b/.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.md similarity index 99% rename from .agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md rename to .agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.md index 84c3259f3f..e9551cacdc 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md +++ b/.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.md @@ -1,6 +1,7 @@ # Agent Note: Web context injection disclosure Status: implemented +Archived: 2026-08-07 English | [中文](2026-07-30-web-context-injection-disclosure.zh.md) diff --git a/.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.zh.md b/.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.zh.md similarity index 99% rename from .agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.zh.md rename to .agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.zh.md index 4d77e06e27..a4937ba880 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.zh.md +++ b/.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.zh.md @@ -1,6 +1,7 @@ # Agent Note: Web 上下文注入展开项 Status: implemented +Archived: 2026-08-07 [English](2026-07-30-web-context-injection-disclosure.md) | 中文 diff --git a/.agents/notes/archived/feature/2026-07-31-hover-card-click-copy.i18n.yaml b/.agents/notes/archived/feature/2026-07-31-hover-card-click-copy.i18n.yaml new file mode 100644 index 0000000000..d0bc2ac45e --- /dev/null +++ b/.agents/notes/archived/feature/2026-07-31-hover-card-click-copy.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/archived/feature/2026-07-31-hover-card-click-copy.md +2026-07-31-hover-card-click-copy.md: 906f64129ee9ba767859260a3288faada21ea4de +2026-07-31-hover-card-click-copy.zh.md: 0359b2edacde9e048db900d315b6a7befd361075 diff --git a/.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.md b/.agents/notes/archived/feature/2026-07-31-hover-card-click-copy.md similarity index 99% rename from .agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.md rename to .agents/notes/archived/feature/2026-07-31-hover-card-click-copy.md index c87734fe32..906f64129e 100644 --- a/.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.md +++ b/.agents/notes/archived/feature/2026-07-31-hover-card-click-copy.md @@ -1,6 +1,7 @@ # Agent Note: Hover cards copy their primary value on activation Status: implemented +Archived: 2026-08-07 English | [中文](2026-07-31-hover-card-click-copy.zh.md) diff --git a/.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.zh.md b/.agents/notes/archived/feature/2026-07-31-hover-card-click-copy.zh.md similarity index 99% rename from .agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.zh.md rename to .agents/notes/archived/feature/2026-07-31-hover-card-click-copy.zh.md index 2d3bc893dd..0359b2edac 100644 --- a/.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.zh.md +++ b/.agents/notes/archived/feature/2026-07-31-hover-card-click-copy.zh.md @@ -1,6 +1,7 @@ # Agent Note: 悬浮卡片激活时复制主要值 Status: implemented +Archived: 2026-08-07 [English](2026-07-31-hover-card-click-copy.md) | 中文 diff --git a/.agents/notes/archived/feature/2026-07-31-web-cards-toolrow.i18n.yaml b/.agents/notes/archived/feature/2026-07-31-web-cards-toolrow.i18n.yaml new file mode 100644 index 0000000000..fcd4878180 --- /dev/null +++ b/.agents/notes/archived/feature/2026-07-31-web-cards-toolrow.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/archived/feature/2026-07-31-web-cards-toolrow.md +2026-07-31-web-cards-toolrow.md: 9bdf5d4e8917178ec27ea5f5d24af753c0d10eab +2026-07-31-web-cards-toolrow.zh.md: ce473a8a34ba8b1bb022d681244508c56a1084f3 diff --git a/.agents/notes/implemented/feature/2026-07-31-web-cards-toolrow.md b/.agents/notes/archived/feature/2026-07-31-web-cards-toolrow.md similarity index 99% rename from .agents/notes/implemented/feature/2026-07-31-web-cards-toolrow.md rename to .agents/notes/archived/feature/2026-07-31-web-cards-toolrow.md index caa18563a9..9bdf5d4e89 100644 --- a/.agents/notes/implemented/feature/2026-07-31-web-cards-toolrow.md +++ b/.agents/notes/archived/feature/2026-07-31-web-cards-toolrow.md @@ -1,6 +1,7 @@ # Agent Note: Card tool rows collapse through one ToolRow Status: implemented +Archived: 2026-08-07 English | [中文](2026-07-31-web-cards-toolrow.zh.md) diff --git a/.agents/notes/implemented/feature/2026-07-31-web-cards-toolrow.zh.md b/.agents/notes/archived/feature/2026-07-31-web-cards-toolrow.zh.md similarity index 99% rename from .agents/notes/implemented/feature/2026-07-31-web-cards-toolrow.zh.md rename to .agents/notes/archived/feature/2026-07-31-web-cards-toolrow.zh.md index 7eb53a163f..ce473a8a34 100644 --- a/.agents/notes/implemented/feature/2026-07-31-web-cards-toolrow.zh.md +++ b/.agents/notes/archived/feature/2026-07-31-web-cards-toolrow.zh.md @@ -1,6 +1,7 @@ # Agent Note: 卡片工具行通过同一个 ToolRow 折叠 Status: implemented +Archived: 2026-08-07 [English](2026-07-31-web-cards-toolrow.md) | 中文 diff --git a/.agents/notes/archived/manifest.json b/.agents/notes/archived/manifest.json index c5c0ce85a7..6fa5f06ceb 100644 --- a/.agents/notes/archived/manifest.json +++ b/.agents/notes/archived/manifest.json @@ -40,6 +40,9 @@ "architecture/2026-07-28-consolidated-tui-presentation.i18n.yaml": "sha256:1eb43c420a21b7a3adf0aa5274d9aa597187630a29d7e535c5e266f82e803665", "architecture/2026-07-28-consolidated-tui-presentation.md": "sha256:e6fa4ea0c9d1d94942ab98de47c554f4e8aa3b639a1cce52113107b1dbb0f4b0", "architecture/2026-07-28-consolidated-tui-presentation.zh.md": "sha256:01814434482a84ebd7f672eb5c26fc468b568e773452563bf39ba52ad25d054a", + "architecture/2026-07-28-dsh-native-typescript-source-launch.i18n.yaml": "sha256:af071e07bce5d9bc8f3df65fed9dcd9b3779a98c5864badbd530363bda021b55", + "architecture/2026-07-28-dsh-native-typescript-source-launch.md": "sha256:1b56e3454277ace713e2a01c4da538c756c45bf633fd24d7b16443d584afac5d", + "architecture/2026-07-28-dsh-native-typescript-source-launch.zh.md": "sha256:8c0f97472c2c89d2c19ae5cfa68c6e67f32b50960b08b60b46496f78ea6ffad1", "bug-fix/2026-07-20-code-mode-result-card-completeness.i18n.yaml": "sha256:1035dae11d049d32ab09fd7d4f950eceae44bf46ba498b3cfaf3c75102b9fb64", "bug-fix/2026-07-20-code-mode-result-card-completeness.md": "sha256:6ca2c9d4df98be18813ef38b7462db880900b5bcd6944fbcd1b8f2258006b93e", "bug-fix/2026-07-20-code-mode-result-card-completeness.zh.md": "sha256:ed85fa7f935e5f525d566bc37a92014614983e649c75de9a9f244939097a7991", @@ -61,6 +64,9 @@ "bug-fix/2026-07-26-intent-draft-same-tick-echo.i18n.yaml": "sha256:c623947c4fa00e6d4b51792c7972ba09582bbcb7605beb373725c0dd666f2c81", "bug-fix/2026-07-26-intent-draft-same-tick-echo.md": "sha256:fa8b1417b2cdd3deecbf8e55bdddd73dd3a8c6e3486fd399b0b8bdf317e56373", "bug-fix/2026-07-26-intent-draft-same-tick-echo.zh.md": "sha256:00ce72552dbaa11562fbc541343a5d33f9449edabbe6dd354eb879a7d4d530f8", + "bug-fix/2026-07-27-question-composer-rows-do-not-shrink.i18n.yaml": "sha256:9b8fd6c3fc5f6527890d74a70372de90ade6db5fa957246d4bbdc06ee06e072c", + "bug-fix/2026-07-27-question-composer-rows-do-not-shrink.md": "sha256:0411033becc2835ce53cd268c9fa149830274c9f61016514087bea89562cfbb8", + "bug-fix/2026-07-27-question-composer-rows-do-not-shrink.zh.md": "sha256:41f024f8b7a4587a92026b4d36d77879086bc86716b0b28bf2131b85db35ee75", "bug-fix/2026-07-27-tool-card-single-row-fields-inline.i18n.yaml": "sha256:4b94aded16c60628d22414dce524e8a98a8af4fff298805ee7efc63cae02c90d", "bug-fix/2026-07-27-tool-card-single-row-fields-inline.md": "sha256:40adcd522a9a2eeacc6f2b0196d1f24888a4f57830b7490a3be3d78c86c4e968", "bug-fix/2026-07-27-tool-card-single-row-fields-inline.zh.md": "sha256:a79d56c9b781442ee596b47707d1a8c80abcd6466094b01802189c8e55f16da7", @@ -70,15 +76,27 @@ "bug-fix/2026-07-27-tui-step-timing-trails-tool-cards.i18n.yaml": "sha256:280b93ece72662501f65edd58a00cdafb5b5941e4ef1314d7198fab18950cb03", "bug-fix/2026-07-27-tui-step-timing-trails-tool-cards.md": "sha256:112bdbde16b6023eeb5b8a79cd2a711385e7198d51bbfc0520e9612acaa95c8a", "bug-fix/2026-07-27-tui-step-timing-trails-tool-cards.zh.md": "sha256:fc4e7f778ea63c4583cf81132c264cf6c4b9cc3e1818778061b0497ff16b8ef6", + "bug-fix/2026-07-28-web-conversation-polish-sweep.i18n.yaml": "sha256:27e60a7d822b201dee65eebc8335da8edd416ee19b9b89c056302bae69c830fd", + "bug-fix/2026-07-28-web-conversation-polish-sweep.md": "sha256:92647f06202f8711107918da9d9947767385a8b6307e772b1e868b0b1cb07412", + "bug-fix/2026-07-28-web-conversation-polish-sweep.zh.md": "sha256:95ccc18f4b35396bdcabee8b8745e75008f6c07f829fd8719bbb35d77ac0fd95", "bug-fix/2026-07-30-tui-adapter-registration-race.i18n.yaml": "sha256:50b7a32e11591719c249258ecc2ec0f45e58f1a04050d2e53f6e2650f58ba137", "bug-fix/2026-07-30-tui-adapter-registration-race.md": "sha256:7e17eb1dd8f92e1efb7a18477df277b13580840b473ffe8a5309fc70ec3cfa3e", "bug-fix/2026-07-30-tui-adapter-registration-race.zh.md": "sha256:efcbd3d82af6a58677efe1a0580edd715945b6a93418fde47badac9c01a29866", + "bug-fix/2026-07-30-web-details-default-closed.i18n.yaml": "sha256:2af5559d727f3e4afdd4946eaf89ac212c81db611db78dbd9bfabb1c4661db17", + "bug-fix/2026-07-30-web-details-default-closed.md": "sha256:27a280a817c8048718bb22927e7d9572cf99ffd0c044631e99e0fd6ea236876f", + "bug-fix/2026-07-30-web-details-default-closed.zh.md": "sha256:e047c7d02cf4b95b0c7f78f4b79af254091294b05cc75e98a8bb860ae2074189", + "bug-fix/2026-07-31-hero-visible-while-blank-session-opens.i18n.yaml": "sha256:42218a762ce0141d3cb43deb6c688d3705cdc4405e03851d486c78f3d25b70ef", + "bug-fix/2026-07-31-hero-visible-while-blank-session-opens.md": "sha256:a40992e89736131f5c487e5357848f14accd06e135dbec9ce242c968a5b11d43", + "bug-fix/2026-07-31-hero-visible-while-blank-session-opens.zh.md": "sha256:e0cc576bc1c196affc9220ddabf15d735c347029c530c56454f0e585979101e1", "bug-fix/2026-07-31-tui-diff-context-line-accounting.i18n.yaml": "sha256:cd39ae2646fdc6827bf29a63953b5463faa37d5b404ae8cc3c0913c47bc92d0c", "bug-fix/2026-07-31-tui-diff-context-line-accounting.md": "sha256:57066bccd22c2dc2c3546b363de73d13b55ff8683ee12b17a81ed2bcf536645b", "bug-fix/2026-07-31-tui-diff-context-line-accounting.zh.md": "sha256:a658d886c5eb203f5f30a6fac70ad18e4a24cf756746254723d8f1d144653c04", "bug-fix/2026-08-03-tui-long-session-render-costs.i18n.yaml": "sha256:f65f7bf8fc84c7a1f022ee393c8d969c06d9bde8bed3a0206de86fb35b246ac6", "bug-fix/2026-08-03-tui-long-session-render-costs.md": "sha256:6ecf2ef831f527f361ade18a882d79bc6eccf15cc676d05728e7753f41cde051", "bug-fix/2026-08-03-tui-long-session-render-costs.zh.md": "sha256:5f44e707b332e13fa06d625212173ea055c1c3c0aee60888435a0ff099ec6037", + "bug-fix/2026-08-04-conversation-column-one-axis-scroll.i18n.yaml": "sha256:ec2ab13c899d2f138cdad0fcbbba3565395ca13bb2c6925ac0fee6518c7b1a2b", + "bug-fix/2026-08-04-conversation-column-one-axis-scroll.md": "sha256:7866cb16460aa47a958b81e904161aa655d54ac331b32f585d6429fffb5c700c", + "bug-fix/2026-08-04-conversation-column-one-axis-scroll.zh.md": "sha256:e01af7c18cad86dac88720014eaeb1f5491eb7feac1e542c5a3d0fd2cc3afee5", "feature/2026-06-14-acp-agent-client-protocol.i18n.yaml": "sha256:006795baa43ae962a8d125cc0f1e9f134bc2ee9fb758b6e7669e3fa0126e1918", "feature/2026-06-14-acp-agent-client-protocol.md": "sha256:6828c0af74bb3fb96206ca6b21c0e56a000b50e4744aad4bc2c05092f3a5a31b", "feature/2026-06-14-acp-agent-client-protocol.zh.md": "sha256:ba104e841a1fb84edbd3b6c8119d50445b7785255a7a8d13bb9ac8a2cb4d2e69", @@ -157,6 +175,9 @@ "feature/2026-07-21-tui-verbose-status-line.i18n.yaml": "sha256:4371b9a46d713d4180aa5d0b1ecde1ff3cae948380a8f56474c895e6113d7824", "feature/2026-07-21-tui-verbose-status-line.md": "sha256:9dcba19ee725b1593e9413a1da5398c205a258aff2e384acd406bb618e86c7f0", "feature/2026-07-21-tui-verbose-status-line.zh.md": "sha256:203c2abac99cedf7afa2540c925367ba66f00b61b926d1cc86472a603ad2bb07", + "feature/2026-07-22-docked-web-goal-bar.i18n.yaml": "sha256:4fae22f5b921ae37feda632addace14bab8d1578c861228dd1df89a7b579f057", + "feature/2026-07-22-docked-web-goal-bar.md": "sha256:94a4b00afc231eddd0fdcf6a494157d12a12b3ae40e8d732c3c05c6811b1c1f8", + "feature/2026-07-22-docked-web-goal-bar.zh.md": "sha256:90dfa4f855a810bce6d57157049eacd6b0d9aa45e99fc296d9024adae4ebac7a", "feature/2026-07-23-trajectory-step-cell.i18n.yaml": "sha256:fe2e935a0affdef877902a40d9861ef5f55b30f40650469f6a52a4d45a92793f", "feature/2026-07-23-trajectory-step-cell.md": "sha256:185e3b87174cb6d2f2d2271fd2a74b1517d03e8570be602570d027bf6002d106", "feature/2026-07-23-trajectory-step-cell.zh.md": "sha256:51f46be43d2f5c4f78a05ed9aeec92d1f33ac988f45cf24d35528e9c43828ef3", @@ -205,24 +226,45 @@ "feature/2026-07-29-tui-hidden-mode-assistant-fold.i18n.yaml": "sha256:0865835802348b730542adbe6b7db613750f3786993c6a14dbb2f47686c13c70", "feature/2026-07-29-tui-hidden-mode-assistant-fold.md": "sha256:a5fefebd802e2d9c3c79c7852c1c34c7bbef3f2ac2150d224608b9ec44e966ad", "feature/2026-07-29-tui-hidden-mode-assistant-fold.zh.md": "sha256:21bccd1e07ec8dc73b618f428461848bb90b6235afe0b842afb0afab2d5cc575", + "feature/2026-07-29-web-message-icon-actions-and-clock.i18n.yaml": "sha256:59b09ed3c94f9b706b5ba8b567e264d9a6f4ff396245a8f4435ddfe5f4af3620", + "feature/2026-07-29-web-message-icon-actions-and-clock.md": "sha256:c4f56f6681f7fa5fdef5354cdd1d0d00ae7e77f4ebcfae6015579b8a4e9ad712", + "feature/2026-07-29-web-message-icon-actions-and-clock.zh.md": "sha256:6f8522382f644467ba39c7c0f15583de30a79ac17305cdba94c4195169cb0dc3", "feature/2026-07-30-compaction-progress-visibility.i18n.yaml": "sha256:4c2267054ad5d73aecc8d39d138a0cb532981b33175252e67252d07c3314b0f5", "feature/2026-07-30-compaction-progress-visibility.md": "sha256:2dfe07244cd784f27a9e5850801d40e96eae21a20ba10aaf56f9a793cdf5b505", "feature/2026-07-30-compaction-progress-visibility.zh.md": "sha256:6180b8aff0536147ab6ed6a78ecdbe1448fd12d89407746ecb1c7c05c73d4d60", + "feature/2026-07-30-dsh-dump-config.i18n.yaml": "sha256:b400c8cce902328989e5493301f66451de7a36e966d69c43b20935fc635aca0f", + "feature/2026-07-30-dsh-dump-config.md": "sha256:85b81dd517aaa6bb7510780acd961739c6da223bf2ddf6747d62f7a74e652d1f", + "feature/2026-07-30-dsh-dump-config.zh.md": "sha256:d0d55947bcb0ef53d534844c15928ae26eebe3fb6d43f98ab176ec36a8eac640", "feature/2026-07-30-tui-details-command.i18n.yaml": "sha256:033cea6df0a16fc68cbdb435babdc6e75c1199a8e70e1a71d87c800c40f5a044", "feature/2026-07-30-tui-details-command.md": "sha256:a13478d4e55ec6d358209b51b541413ec75d0e20dfc22196ace28020f03f0c2d", "feature/2026-07-30-tui-details-command.zh.md": "sha256:de9c449b98468cef34ce4f9a9d2a854a5d8905eecd61f80e27a9a0e4495e9901", "feature/2026-07-30-versioned-tui-first-run-welcome.i18n.yaml": "sha256:4c3fc380b0512ad7c00baacd0ac610e1a78ae45374311d9bd43bab6b5e29e630", "feature/2026-07-30-versioned-tui-first-run-welcome.md": "sha256:296f153e6c839f3743078e4f5aab3b2befc211c934835238668c57bdeae52231", "feature/2026-07-30-versioned-tui-first-run-welcome.zh.md": "sha256:82871a9cca1fec46bb08a5b39daad28a44bb2419dea367b4ae41af3cf07bfa65", + "feature/2026-07-30-web-composer-stats-and-input-polish.i18n.yaml": "sha256:eb0f02b1e15cb127618c82871fcea1b3ba4a9a5db670615072f15c71864729ea", + "feature/2026-07-30-web-composer-stats-and-input-polish.md": "sha256:68d0c0486219d8e886db07c4cde2285e515adf9756c1e4bc94e4bbd5cbcc2c93", + "feature/2026-07-30-web-composer-stats-and-input-polish.zh.md": "sha256:679565f7a2e183ac71132bd1cbd9d2418d77fe0e5472224b15bd310dd3931f4b", + "feature/2026-07-30-web-context-injection-disclosure.i18n.yaml": "sha256:83a8b78b0140afc8f18034674b594b998099c162ed9b9d939cb99e49cea2272b", + "feature/2026-07-30-web-context-injection-disclosure.md": "sha256:00f869b29861ff8f30e64ea3ca65aa9f8a06b33be205453ea3b679e5dfe05c4d", + "feature/2026-07-30-web-context-injection-disclosure.zh.md": "sha256:9bca10469e4d77b20c3785ce7ab6cbc628a03b8919de8a47c14466c1dbc94d36", "feature/2026-07-31-experimental-subcommand-gate.i18n.yaml": "sha256:d223669bebbf6ea65b4ec636e8e7ed618eff389117335946be713897151c6968", "feature/2026-07-31-experimental-subcommand-gate.md": "sha256:8fdee37340f7e72397cf2f440a2ca70639a987d07f0c2102e02e79c0fec4bfeb", "feature/2026-07-31-experimental-subcommand-gate.zh.md": "sha256:bcdec0f82319670a1d1de54a27b103b5e2d86306b884a5f9f415b89cd5a373f4", + "feature/2026-07-31-hover-card-click-copy.i18n.yaml": "sha256:2b95987c23e13a4499f5f3851770e8f97aa6f6df457a36b6aa818a8db08785c9", + "feature/2026-07-31-hover-card-click-copy.md": "sha256:f9a85c1603dcbdd36d26f730bf2a1f7bfaaa2267c7c30bee08cb9a94a7ce774e", + "feature/2026-07-31-hover-card-click-copy.zh.md": "sha256:b01e6edda5c6b031b5265ca0d868583fecce04cbc817fa7e8cc4433d10056e64", + "feature/2026-07-31-web-cards-toolrow.i18n.yaml": "sha256:f9a6ab72a77934cdcc02167c7313f08d7e9925362017b34bed7ad56c8c70fbaa", + "feature/2026-07-31-web-cards-toolrow.md": "sha256:5058f7cec4497d1cb0a5c8e77b88fddacac6eead034f3edec88e8514919b8a3e", + "feature/2026-07-31-web-cards-toolrow.zh.md": "sha256:ba84ef2e1be61211ab5ba6950b78ede3d3a979f252bc068d3e04e2c025f7bc03", "process/2026-06-11-doc-sync-enforcement.i18n.yaml": "sha256:33b6d5874427bd7a2bd82e7e2f4f482b12448b2464aef15a9c57975edb48554d", "process/2026-06-11-doc-sync-enforcement.md": "sha256:aa2fe83d519fc30d48dff19e596e83c8922aacc9e063e14fe2cc35b769b9100e", "process/2026-06-11-doc-sync-enforcement.zh.md": "sha256:698017bd35f030fdea3eac51df9e43138c48140f504739d687b7251d13fced2b", "process/2026-06-11-tsdown-over-dumble.i18n.yaml": "sha256:22791adb84a4b6c545173d4f1708eea51151d57e426d875e0e5423be9b6e0212", "process/2026-06-11-tsdown-over-dumble.md": "sha256:8d3c35dddd8869cc3361059dfe4b7b8ab6716d29dda232c97c2f37e92c841dc0", "process/2026-06-11-tsdown-over-dumble.zh.md": "sha256:cf11c651c13f5ffef5474e7795006ba3653c5eb08eae75a879be6499353455dc", + "process/2026-06-20-generated-cordis-catalog.i18n.yaml": "sha256:5250aaec698b25bdf5e3a02f67e531793fc968f9c38348f0c8bd11418571967f", + "process/2026-06-20-generated-cordis-catalog.md": "sha256:1f3190b759bf1445b35f25f2f18ac1c8b16d7f2e9263e2627bf3c94fac54d275", + "process/2026-06-20-generated-cordis-catalog.zh.md": "sha256:5d46da71bd73bba62ba15a9f11b21da422dbecc8ed68b6f530ceb302e1935ddb", "process/2026-07-03-documentation-graph-atlas.i18n.yaml": "sha256:b1e1ed4b7865d87f939dbf8c94c0ea1069fdf7af6fa68f695e6c9d6eccbeb123", "process/2026-07-03-documentation-graph-atlas.md": "sha256:b62e92bb12123bfa4c4dac806f584aabb6b60af4c5a6a4ab88f84bb9153e766d", "process/2026-07-03-documentation-graph-atlas.zh.md": "sha256:3485ede4a5e695643bcf9e744a62f8914cff788ae35717dac5eb6bf77e0d65cf", @@ -322,9 +364,21 @@ "simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.i18n.yaml": "sha256:7acf002ea8c1533f052c7bfc0c4e3da013ecf43c5872866a3ee4a8c2691c5e33", "simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.md": "sha256:f18a913096b7defd2192c4bac888a33f68075c3662703a0e28a6146897d17777", "simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.zh.md": "sha256:ff48a37673c97059536fe5b61aff746133eac682145550badb049eb5c83b097c", + "simplification/2026-07-26-eventsource-parser-for-deepseek-sse.i18n.yaml": "sha256:534abd90ddde9ccd35ab7e595de4242d8fa30a75908a638e5b3290f749553e5a", + "simplification/2026-07-26-eventsource-parser-for-deepseek-sse.md": "sha256:ce449c72ed09238ba5dbe6068689db13bd33225694b2ad2c7696541f32dc0eec", + "simplification/2026-07-26-eventsource-parser-for-deepseek-sse.zh.md": "sha256:2f66b407b626f1f8c661d849b3b359c959e690715e0ac3f61f7e3d57d59e89c3", + "simplification/2026-07-26-turndown-for-tool-web-html-markdown.i18n.yaml": "sha256:6df56f5f5639847f0fac445abb5fea8a07f3cf9a0703d9022e50728c8f5055ca", + "simplification/2026-07-26-turndown-for-tool-web-html-markdown.md": "sha256:344c5cc2a1e79287eeda6996ae417dd2e02a7545987df0f2c9016b20ae094d93", + "simplification/2026-07-26-turndown-for-tool-web-html-markdown.zh.md": "sha256:8c4f2ac12ccd23f7ada90694502f5da689344e6bba72080a89672aa9b4f1903a", "simplification/2026-07-27-copyable-transcript-no-gutter-bar.i18n.yaml": "sha256:821f96f3e203e03b80553c07b10a511926bb5014be95c7df6bffb30c8e226d31", "simplification/2026-07-27-copyable-transcript-no-gutter-bar.md": "sha256:4b6aa150bbc8a4da0acac4d20f5fb8c2b77fef7e9c4c4dba8fd8e84dec36d619", "simplification/2026-07-27-copyable-transcript-no-gutter-bar.zh.md": "sha256:5225e627ff301be171434a5b9f18905fe1578f50eb2d4bf9998e126aba6cc3e3", + "simplification/2026-07-30-sidebar-resize-without-visible-pill.i18n.yaml": "sha256:ad42430fef4a5db610f8c56acfcda79b40396418694f664a5b0fb093bff1f114", + "simplification/2026-07-30-sidebar-resize-without-visible-pill.md": "sha256:6f2cfc5121371ec19c7178b31777c223e16bebdc9b63b7654a61cadc4a765b63", + "simplification/2026-07-30-sidebar-resize-without-visible-pill.zh.md": "sha256:ab859eeb12c6a74da3c37d411af52fce37bed3195941570b2f23ccd9a00d55fe", + "simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.i18n.yaml": "sha256:531c446f0e95054f8ced17be9a180f8b0a823f7e9d5ce466c94c2f9cff90a111", + "simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md": "sha256:a35a6372aabdf7cbc211f1bd5820d85d3467c9ed50f84e05caa3339382379ce7", + "simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.zh.md": "sha256:a6ed9530289a783c3d7a1ddb038fba6b7daf7feb773298a57e811791e354d438", "testing/2026-06-20-remove-redundant-snapshot-log-expected-output.i18n.yaml": "sha256:4177012c0821a8c22499852ecdf096af56d7263cb91c5d9d1bcd552cc26a3e00", "testing/2026-06-20-remove-redundant-snapshot-log-expected-output.md": "sha256:45234e7cc04b6010c6141f8d5924c04547300098f96262d423c50108e7c7011a", "testing/2026-06-20-remove-redundant-snapshot-log-expected-output.zh.md": "sha256:15e5a4ad3dee0bb711480cabe45cd97ec37bbdba19c2c2b47d1e9c203b07a48b", @@ -345,6 +399,9 @@ "testing/2026-07-08-shared-acp-snapshot-package.zh.md": "sha256:02da3f910c2060f70038a0d86a7ddae4a8890905600440e1373412f54fbdcea8", "testing/2026-07-18-tui-terminal-state-snapshots.i18n.yaml": "sha256:c1a22174274b9f34ef4368039b3547f221507b040bd51b87af73a2722ee6b4d2", "testing/2026-07-18-tui-terminal-state-snapshots.md": "sha256:9a7fdcbeafc34376cb049b9668e0f4e9e541f523116fb11c3af9d35c2963e908", - "testing/2026-07-18-tui-terminal-state-snapshots.zh.md": "sha256:26750f240f6c8a7b28746f62fe161b357e9c5dd52867cc7037399f1ed6ff37fa" + "testing/2026-07-18-tui-terminal-state-snapshots.zh.md": "sha256:26750f240f6c8a7b28746f62fe161b357e9c5dd52867cc7037399f1ed6ff37fa", + "testing/2026-07-26-execa-for-test-subprocess-plumbing.i18n.yaml": "sha256:dd45cddb591b892739b75b0c180bde7f14008f4769227b863571475be295e1e0", + "testing/2026-07-26-execa-for-test-subprocess-plumbing.md": "sha256:1f45a69d0a7367ec5afbf112a77b355339b35270af8ff52696bee879cdf770d3", + "testing/2026-07-26-execa-for-test-subprocess-plumbing.zh.md": "sha256:8a24bdc8376373d7a97f65cefc07078824bf918d6a9934056a025ecfafe8634b" } } diff --git a/.agents/notes/archived/process/2026-06-20-generated-cordis-catalog.i18n.yaml b/.agents/notes/archived/process/2026-06-20-generated-cordis-catalog.i18n.yaml new file mode 100644 index 0000000000..3eb6be2fb1 --- /dev/null +++ b/.agents/notes/archived/process/2026-06-20-generated-cordis-catalog.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/archived/process/2026-06-20-generated-cordis-catalog.md +2026-06-20-generated-cordis-catalog.md: 8d013a5b0c7e1b8df9f607215384f6c26a83b5b8 +2026-06-20-generated-cordis-catalog.zh.md: 2550bc805db7bea95106d444ecf9b0ad75ef91cc diff --git a/.agents/notes/implemented/process/2026-06-20-generated-cordis-catalog.md b/.agents/notes/archived/process/2026-06-20-generated-cordis-catalog.md similarity index 99% rename from .agents/notes/implemented/process/2026-06-20-generated-cordis-catalog.md rename to .agents/notes/archived/process/2026-06-20-generated-cordis-catalog.md index 5005e50a2e..8d013a5b0c 100644 --- a/.agents/notes/implemented/process/2026-06-20-generated-cordis-catalog.md +++ b/.agents/notes/archived/process/2026-06-20-generated-cordis-catalog.md @@ -1,6 +1,7 @@ # Agent Note: Generated cordis events + services catalog Status: implemented +Archived: 2026-08-07 English | [中文](2026-06-20-generated-cordis-catalog.zh.md) diff --git a/.agents/notes/implemented/process/2026-06-20-generated-cordis-catalog.zh.md b/.agents/notes/archived/process/2026-06-20-generated-cordis-catalog.zh.md similarity index 99% rename from .agents/notes/implemented/process/2026-06-20-generated-cordis-catalog.zh.md rename to .agents/notes/archived/process/2026-06-20-generated-cordis-catalog.zh.md index 384e00d23a..2550bc805d 100644 --- a/.agents/notes/implemented/process/2026-06-20-generated-cordis-catalog.zh.md +++ b/.agents/notes/archived/process/2026-06-20-generated-cordis-catalog.zh.md @@ -1,6 +1,7 @@ # Agent Note: 生成的 Cordis 事件与服务目录 Status: implemented +Archived: 2026-08-07 [English](2026-06-20-generated-cordis-catalog.md) | 中文 diff --git a/.agents/notes/archived/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.i18n.yaml b/.agents/notes/archived/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.i18n.yaml new file mode 100644 index 0000000000..cfb5b17afd --- /dev/null +++ b/.agents/notes/archived/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/archived/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.md +2026-07-26-eventsource-parser-for-deepseek-sse.md: 9e716cf9556c9c2d8cdf6cb85c6908d45a127a0f +2026-07-26-eventsource-parser-for-deepseek-sse.zh.md: 16c63ddc9646f6309654910bd80801bd20005545 diff --git a/.agents/notes/implemented/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.md b/.agents/notes/archived/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.md similarity index 99% rename from .agents/notes/implemented/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.md rename to .agents/notes/archived/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.md index e7835bc738..9e716cf955 100644 --- a/.agents/notes/implemented/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.md +++ b/.agents/notes/archived/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.md @@ -1,6 +1,7 @@ # Agent Note: Replace the hand-rolled SSE parser in llm-deepseek with eventsource-parser Status: implemented +Archived: 2026-08-07 English | [中文](2026-07-26-eventsource-parser-for-deepseek-sse.zh.md) diff --git a/.agents/notes/implemented/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.zh.md b/.agents/notes/archived/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.zh.md similarity index 99% rename from .agents/notes/implemented/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.zh.md rename to .agents/notes/archived/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.zh.md index 7c746079aa..16c63ddc96 100644 --- a/.agents/notes/implemented/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.zh.md +++ b/.agents/notes/archived/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.zh.md @@ -1,6 +1,7 @@ # Agent Note: 用 eventsource-parser 替换 llm-deepseek 中手写的 SSE 解析器 Status: implemented +Archived: 2026-08-07 [English](2026-07-26-eventsource-parser-for-deepseek-sse.md) | 中文 diff --git a/.agents/notes/archived/simplification/2026-07-26-turndown-for-tool-web-html-markdown.i18n.yaml b/.agents/notes/archived/simplification/2026-07-26-turndown-for-tool-web-html-markdown.i18n.yaml new file mode 100644 index 0000000000..f79936b9fa --- /dev/null +++ b/.agents/notes/archived/simplification/2026-07-26-turndown-for-tool-web-html-markdown.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/archived/simplification/2026-07-26-turndown-for-tool-web-html-markdown.md +2026-07-26-turndown-for-tool-web-html-markdown.md: 46c4eba12c782146aa32df245c5f69567723935a +2026-07-26-turndown-for-tool-web-html-markdown.zh.md: 077ea89c54c63e41bec2aabd15e744b7b8a764da diff --git a/.agents/notes/implemented/simplification/2026-07-26-turndown-for-tool-web-html-markdown.md b/.agents/notes/archived/simplification/2026-07-26-turndown-for-tool-web-html-markdown.md similarity index 99% rename from .agents/notes/implemented/simplification/2026-07-26-turndown-for-tool-web-html-markdown.md rename to .agents/notes/archived/simplification/2026-07-26-turndown-for-tool-web-html-markdown.md index 0e387021e3..46c4eba12c 100644 --- a/.agents/notes/implemented/simplification/2026-07-26-turndown-for-tool-web-html-markdown.md +++ b/.agents/notes/archived/simplification/2026-07-26-turndown-for-tool-web-html-markdown.md @@ -1,6 +1,7 @@ # Agent Note: Replace tool-web's regex HTML-to-markdown converter with turndown Status: implemented +Archived: 2026-08-07 English | [中文](2026-07-26-turndown-for-tool-web-html-markdown.zh.md) diff --git a/.agents/notes/implemented/simplification/2026-07-26-turndown-for-tool-web-html-markdown.zh.md b/.agents/notes/archived/simplification/2026-07-26-turndown-for-tool-web-html-markdown.zh.md similarity index 99% rename from .agents/notes/implemented/simplification/2026-07-26-turndown-for-tool-web-html-markdown.zh.md rename to .agents/notes/archived/simplification/2026-07-26-turndown-for-tool-web-html-markdown.zh.md index 6c9b9a22db..077ea89c54 100644 --- a/.agents/notes/implemented/simplification/2026-07-26-turndown-for-tool-web-html-markdown.zh.md +++ b/.agents/notes/archived/simplification/2026-07-26-turndown-for-tool-web-html-markdown.zh.md @@ -1,6 +1,7 @@ # Agent Note: 用 turndown 替换 tool-web 的正则 HTML 转 markdown 转换器 Status: implemented +Archived: 2026-08-07 [English](2026-07-26-turndown-for-tool-web-html-markdown.md) | 中文 diff --git a/.agents/notes/archived/simplification/2026-07-30-sidebar-resize-without-visible-pill.i18n.yaml b/.agents/notes/archived/simplification/2026-07-30-sidebar-resize-without-visible-pill.i18n.yaml new file mode 100644 index 0000000000..4f211e5ed8 --- /dev/null +++ b/.agents/notes/archived/simplification/2026-07-30-sidebar-resize-without-visible-pill.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/archived/simplification/2026-07-30-sidebar-resize-without-visible-pill.md +2026-07-30-sidebar-resize-without-visible-pill.md: 50bf43564675540df2db8e88530a82177f551407 +2026-07-30-sidebar-resize-without-visible-pill.zh.md: 41a33e039aaa29813d4232e5a6d36141b55ce947 diff --git a/.agents/notes/implemented/simplification/2026-07-30-sidebar-resize-without-visible-pill.md b/.agents/notes/archived/simplification/2026-07-30-sidebar-resize-without-visible-pill.md similarity index 98% rename from .agents/notes/implemented/simplification/2026-07-30-sidebar-resize-without-visible-pill.md rename to .agents/notes/archived/simplification/2026-07-30-sidebar-resize-without-visible-pill.md index cc41898990..50bf435646 100644 --- a/.agents/notes/implemented/simplification/2026-07-30-sidebar-resize-without-visible-pill.md +++ b/.agents/notes/archived/simplification/2026-07-30-sidebar-resize-without-visible-pill.md @@ -1,6 +1,7 @@ # Agent Note: Sidebar resize without a visible pill Status: implemented +Archived: 2026-08-07 English | [中文](2026-07-30-sidebar-resize-without-visible-pill.zh.md) diff --git a/.agents/notes/implemented/simplification/2026-07-30-sidebar-resize-without-visible-pill.zh.md b/.agents/notes/archived/simplification/2026-07-30-sidebar-resize-without-visible-pill.zh.md similarity index 98% rename from .agents/notes/implemented/simplification/2026-07-30-sidebar-resize-without-visible-pill.zh.md rename to .agents/notes/archived/simplification/2026-07-30-sidebar-resize-without-visible-pill.zh.md index 9f1f521df2..41a33e039a 100644 --- a/.agents/notes/implemented/simplification/2026-07-30-sidebar-resize-without-visible-pill.zh.md +++ b/.agents/notes/archived/simplification/2026-07-30-sidebar-resize-without-visible-pill.zh.md @@ -1,6 +1,7 @@ # Agent Note: 侧边栏缩放不显示胶囊 Status: implemented +Archived: 2026-08-07 [English](2026-07-30-sidebar-resize-without-visible-pill.md) | 中文 diff --git a/.agents/notes/archived/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.i18n.yaml b/.agents/notes/archived/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.i18n.yaml new file mode 100644 index 0000000000..32c335c431 --- /dev/null +++ b/.agents/notes/archived/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/archived/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md +2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md: 8ef2b7deb103f2e2a9147b4b50c7d39936ed2381 +2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.zh.md: 875e44a06e1cebf4f2b1731909c479b2f06fb06a diff --git a/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md b/.agents/notes/archived/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md similarity index 99% rename from .agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md rename to .agents/notes/archived/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md index e2d821f395..8ef2b7deb1 100644 --- a/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md +++ b/.agents/notes/archived/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md @@ -1,6 +1,7 @@ # Agent Note: Web UI drops steer entry and interjection chrome Status: implemented +Archived: 2026-08-07 English | [中文](2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.zh.md) diff --git a/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.zh.md b/.agents/notes/archived/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.zh.md similarity index 99% rename from .agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.zh.md rename to .agents/notes/archived/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.zh.md index b55d4a271e..875e44a06e 100644 --- a/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.zh.md +++ b/.agents/notes/archived/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.zh.md @@ -1,6 +1,7 @@ # Agent Note: Web UI 去掉 steer 入口与插话 chrome Status: implemented +Archived: 2026-08-07 [English](2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md) | 中文 diff --git a/.agents/notes/archived/testing/2026-07-26-execa-for-test-subprocess-plumbing.i18n.yaml b/.agents/notes/archived/testing/2026-07-26-execa-for-test-subprocess-plumbing.i18n.yaml new file mode 100644 index 0000000000..2fb23f6ad5 --- /dev/null +++ b/.agents/notes/archived/testing/2026-07-26-execa-for-test-subprocess-plumbing.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/archived/testing/2026-07-26-execa-for-test-subprocess-plumbing.md +2026-07-26-execa-for-test-subprocess-plumbing.md: ca5edc50bf17a34809037462f8e9603b8ed28e74 +2026-07-26-execa-for-test-subprocess-plumbing.zh.md: 186e5cd560b6500364c855438d6c3ff18206b52b diff --git a/.agents/notes/implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.md b/.agents/notes/archived/testing/2026-07-26-execa-for-test-subprocess-plumbing.md similarity index 99% rename from .agents/notes/implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.md rename to .agents/notes/archived/testing/2026-07-26-execa-for-test-subprocess-plumbing.md index 958abc4aee..ca5edc50bf 100644 --- a/.agents/notes/implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.md +++ b/.agents/notes/archived/testing/2026-07-26-execa-for-test-subprocess-plumbing.md @@ -1,6 +1,7 @@ # Agent Note: Adopt execa for hand-rolled test subprocess plumbing Status: implemented +Archived: 2026-08-07 English | [中文](2026-07-26-execa-for-test-subprocess-plumbing.zh.md) diff --git a/.agents/notes/implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.zh.md b/.agents/notes/archived/testing/2026-07-26-execa-for-test-subprocess-plumbing.zh.md similarity index 99% rename from .agents/notes/implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.zh.md rename to .agents/notes/archived/testing/2026-07-26-execa-for-test-subprocess-plumbing.zh.md index 5ccadd93a1..186e5cd560 100644 --- a/.agents/notes/implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.zh.md +++ b/.agents/notes/archived/testing/2026-07-26-execa-for-test-subprocess-plumbing.zh.md @@ -1,6 +1,7 @@ # Agent Note: 采用 execa 替换手写的测试子进程管道代码 Status: implemented +Archived: 2026-08-07 [English](2026-07-26-execa-for-test-subprocess-plumbing.md) | 中文 diff --git a/.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.i18n.yaml index c897fc3bab..6fbbd961a2 100644 --- a/.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md -2026-06-13-twin-llm-adapters.md: b922891d4438553fd96a7f4f4226f378e66e8ad2 -2026-06-13-twin-llm-adapters.zh.md: 391f9259172bc91bb4e5fc036e6064a207a7e308 +2026-06-13-twin-llm-adapters.md: a4c87325a0b0d1ebe6cf8f95672e5de74ef37d57 +2026-06-13-twin-llm-adapters.zh.md: 753d7900f23c0d3388be0488c291145fdccf5a95 diff --git a/.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md b/.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md index b922891d44..a4c87325a0 100644 --- a/.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md +++ b/.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md @@ -12,7 +12,7 @@ English | [中文](2026-06-13-twin-llm-adapters.zh.md) Ship **two** adapters against the one contract from the start, deliberately built on different internals: -- `dsh-llm-deepseek` — direct `fetch` + in-repo translation against the DeepSeek API; SSE framing is delegated to `eventsource-parser` ([the SSE-parser swap](../simplification/2026-07-26-eventsource-parser-for-deepseek-sse.md)). The twin identity is owning the fetch/translate internals rather than delegating to a full provider SDK, not hand-rolling transport plumbing. +- `dsh-llm-deepseek` — direct `fetch` + in-repo translation against the DeepSeek API; SSE framing is delegated to `eventsource-parser` ([the archived SSE-parser swap](../../archived/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.md)). The twin identity is owning the fetch/translate internals rather than delegating to a full provider SDK, not hand-rolling transport plumbing. - `dsh-llm-pi-ai` — the same endpoint through the `@earendil-works/pi-ai` library (its own event vocabulary). The rule they enforce: **anything the StreamChunk vocabulary cannot express for BOTH implementations is a core-vocabulary bug**, caught immediately rather than at the next provider. The pair pinned down conventions now documented on `StreamChunk` in `dsh-llm/src/types.ts`: usage emitted before finish, nothing after finish, tool-call `arguments` as raw JSON strings end-to-end, and the two sanctioned error paths (throw from `stream()` *or* end with `finish {kind:'error'|'aborted'}`) that a consumer must handle on both sides — a divergence the library-backed adapter surfaced that a single direct-fetch adapter would have hidden. diff --git a/.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.zh.md b/.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.zh.md index 391f925917..753d7900f2 100644 --- a/.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.zh.md @@ -12,7 +12,7 @@ Status: implemented 从一开始就针对同一份契约交付**两个**适配器,刻意基于不同的内部实现构建: -- `dsh-llm-deepseek`:直接 `fetch` + 仓库内翻译逻辑对接 DeepSeek API;SSE(Server-Sent Events)分帧委托给 `eventsource-parser`([SSE 解析器替换](../simplification/2026-07-26-eventsource-parser-for-deepseek-sse.md))。孪生身份在于自行持有 fetch/translate 内部实现而非委托给完整的提供方 SDK,不在于手写传输层管道。 +- `dsh-llm-deepseek`:直接 `fetch` + 仓库内翻译逻辑对接 DeepSeek API;SSE(Server-Sent Events)分帧委托给 `eventsource-parser`([已归档的 SSE 解析器替换](../../archived/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.md))。孪生身份在于自行持有 fetch/translate 内部实现而非委托给完整的提供方 SDK,不在于手写传输层管道。 - `dsh-llm-pi-ai`:通过 `@earendil-works/pi-ai` 库访问同一端点(该库有自己的事件词汇)。 二者共同执行的规则是:**凡 StreamChunk 词汇无法为两个实现同时表达的内容,都是核心词汇的缺陷**——立即暴露,而非等到下一个提供方接入时才发现。这对孪生适配器确立了现已记录在 `dsh-llm/src/types.ts` 中 `StreamChunk` 上的约定:usage 在 finish 之前发出、finish 之后不再有任何事件、工具调用的 `arguments` 全程以原始 JSON 字符串传递,以及消费方必须在两侧都处理的两条合法错误路径(`stream()` 抛异常,*或者*以 `finish {kind:'error'|'aborted'}` 结束)。这一分歧正是由基于库的适配器暴露出来的,单一直接 fetch 适配器会将其隐藏。 diff --git a/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.i18n.yaml deleted file mode 100644 index b74717afb9..0000000000 --- a/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# side as of the last confirmed-consistent state. Both languages carry equal authority; -# after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.md -2026-07-28-dsh-native-typescript-source-launch.md: 773f831ec2b116d4908fcd5dc818df78c5deee5e -2026-07-28-dsh-native-typescript-source-launch.zh.md: 0e40a7e32bfaf1186ce816ec0bc1e608c76b47e0 diff --git a/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.i18n.yaml index 8a6dbf705c..5a884924f9 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.md -2026-07-29-dsh-source-launch-tsx-esm.md: 93fbb248b45efde37d5fbdb1ec4b812ab3332088 -2026-07-29-dsh-source-launch-tsx-esm.zh.md: 4d7b2c47db68f21e904a607f16c80d33c706c488 +2026-07-29-dsh-source-launch-tsx-esm.md: 21e912c7c7bbdd70142c202105d9a3035442884a +2026-07-29-dsh-source-launch-tsx-esm.zh.md: dc6150b7017777eea99778cc9813e17402354462 diff --git a/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.md b/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.md index 93fbb248b4..21e912c7c7 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.md +++ b/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.md @@ -4,11 +4,11 @@ Status: implemented English | [中文](2026-07-29-dsh-source-launch-tsx-esm.zh.md) -> Supersedes [native TypeScript source launch](2026-07-28-dsh-native-typescript-source-launch.md): Node removed the capability that decision was built on. +> Supersedes [native TypeScript source launch](../../archived/architecture/2026-07-28-dsh-native-typescript-source-launch.md): Node removed the capability that decision was built on. ## Problem -The [native source-launch decision](2026-07-28-dsh-native-typescript-source-launch.md) ran `apps/cli/src/bin.ts` under `node --experimental-transform-types` with a resolve-only paths loader, so Node owned TypeScript transformation. Node 26.0.0 removed `--experimental-transform-types` (the process rejects the flag with `bad option`), keeping only strip mode, and strip mode rejects syntax this source graph requires: vendored Cordis parameter properties (`constructor(private ctx: Context)`), the `@Inject` decorators in `vendor/hmr`, and runtime enums/namespaces throughout `vendor/` and `packages/workflow`. The repository's engines range (`^22.19.0 || >=24.0.0`) includes Node 26, so the native launch chain could not start at all there — and no CI job executed the real launch vector, so the incompatibility shipped silently. +The [archived native source-launch decision](../../archived/architecture/2026-07-28-dsh-native-typescript-source-launch.md) ran `apps/cli/src/bin.ts` under `node --experimental-transform-types` with a resolve-only paths loader, so Node owned TypeScript transformation. Node 26.0.0 removed `--experimental-transform-types` (the process rejects the flag with `bad option`), keeping only strip mode, and strip mode rejects syntax this source graph requires: vendored Cordis parameter properties (`constructor(private ctx: Context)`), the `@Inject` decorators in `vendor/hmr`, and runtime enums/namespaces throughout `vendor/` and `packages/workflow`. The repository's engines range (`^22.19.0 || >=24.0.0`) includes Node 26, so the native launch chain could not start at all there — and no CI job executed the real launch vector, so the incompatibility shipped silently. Startup latency also mattered: the off-thread `module.register()` hooks worker serialized every resolution across threads (~440ms of `makeSyncRequest` wait during TUI boot), and the full tsx default (`--import tsx`) pays ~0.4s in its CJS hook's resolution amplification. diff --git a/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.zh.md b/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.zh.md index 4d7b2c47db..dc6150b701 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.zh.md @@ -4,11 +4,11 @@ Status: implemented [English](2026-07-29-dsh-source-launch-tsx-esm.md) | 中文 -> 取代[原生 TypeScript 源码启动](2026-07-28-dsh-native-typescript-source-launch.md):Node 移除了该决策所依赖的能力。 +> 取代[已归档的原生 TypeScript 源码启动](../../archived/architecture/2026-07-28-dsh-native-typescript-source-launch.md):Node 移除了该决策所依赖的能力。 ## 问题 -[原生源码启动决策](2026-07-28-dsh-native-typescript-source-launch.md)让 `apps/cli/src/bin.ts` 在 `node --experimental-transform-types` 下运行,配合一个只做解析的 paths loader,由 Node 负责 TypeScript 转换。Node 26.0.0 移除了 `--experimental-transform-types`(进程以 `bad option` 拒绝该 flag),只保留 strip 模式,而 strip 模式无法接受这个源码图必需的语法:vendor Cordis 中的参数属性(`constructor(private ctx: Context)`)、`vendor/hmr` 中的 `@Inject` 装饰器,以及遍布 `vendor/` 与 `packages/workflow` 的运行时 enum/namespace。仓库的 engines 范围(`^22.19.0 || >=24.0.0`)包含 Node 26,因此原生启动链在其上完全无法启动——且没有任何 CI 任务执行过真实启动向量,这一不兼容悄然发布。 +[已归档的原生源码启动决策](../../archived/architecture/2026-07-28-dsh-native-typescript-source-launch.md)让 `apps/cli/src/bin.ts` 在 `node --experimental-transform-types` 下运行,配合一个只做解析的 paths loader,由 Node 负责 TypeScript 转换。Node 26.0.0 移除了 `--experimental-transform-types`(进程以 `bad option` 拒绝该 flag),只保留 strip 模式,而 strip 模式无法接受这个源码图必需的语法:vendor Cordis 中的参数属性(`constructor(private ctx: Context)`)、`vendor/hmr` 中的 `@Inject` 装饰器,以及遍布 `vendor/` 与 `packages/workflow` 的运行时 enum/namespace。仓库的 engines 范围(`^22.19.0 || >=24.0.0`)包含 Node 26,因此原生启动链在其上完全无法启动——且没有任何 CI 任务执行过真实启动向量,这一不兼容悄然发布。 启动延迟同样是问题:off-thread 的 `module.register()` 钩子工作线程把每次解析都跨线程序列化(TUI 启动期间约 440ms 的 `makeSyncRequest` 等待),而完整的 tsx 默认形态(`--import tsx`)会因其 CJS 钩子放大解析开销而多花约 0.4s。 diff --git a/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.i18n.yaml index 55efb87b8f..7665dc3290 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.md -2026-07-30-session-end-seed-log-boundary.md: 9d0685876b4d1bac339961c67ab08f620e499464 -2026-07-30-session-end-seed-log-boundary.zh.md: 8fa9625ea6c58b0b07d964ef2580b670893a3d75 +2026-07-30-session-end-seed-log-boundary.md: 26cd67ccbfdf5dfc62e44a53c877acf6d2fee34a +2026-07-30-session-end-seed-log-boundary.zh.md: 5ce12681da3ee46cc7c69aa6d432d25712db17fd diff --git a/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.md b/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.md index 9d0685876b..26cd67ccbf 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.md +++ b/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.md @@ -36,7 +36,7 @@ The predicate holds for a bracket *this* session inherited, not as a liveness si ## Alternatives considered -**A boundary written by the persistence coordinator's cold-load path.** Built first, as the [`session/resumed` boundary](../../rejected/architecture/2026-07-29-session-resumed-log-boundary.md), and abandoned before merge. It covers no fork, which is the one case where the inherited bracket's owner may still be running. Because the marker was minted at load it also had to be a durable write on a read path, which spread cost across the seam: a revision bump on every cold load, a `commitRepair` batch on a balanced log with nothing to repair, a stored-time floor to keep the clamp monotonic, and a load that failed against a read-only store. +**A boundary written by the persistence coordinator's cold-load path.** Built first as a `session/resumed` boundary and abandoned before merge. It covers no fork, which is the one case where the inherited bracket's owner may still be running. Because the marker was minted at load it also had to be a durable write on a read path, which spread cost across the seam: a revision bump on every cold load, a `commitRepair` batch on a balanced log with nothing to repair, a stored-time floor to keep the clamp monotonic, and a load that failed against a read-only store. **A boundary appended at loop start.** The loop calls `resumeWith`, so it covers the resume paths, but it misses `fork()` and `adopt()` entirely, and the event would have to fire on `'startup'` — the source a fork child publishes — so `SessionStartSource` would stop discriminating. It also publishes the session before the marker is appended, so a `session/created` listener could observe a seeded log with no boundary. diff --git a/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.zh.md b/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.zh.md index 8fa9625ea6..5ce12681da 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.zh.md @@ -36,7 +36,7 @@ Status: implemented ## Alternatives considered -**由持久化协调器的冷加载路径写入边界。** 最先实现的方案,即 [`session/resumed` 边界](../../rejected/architecture/2026-07-29-session-resumed-log-boundary.md),在合并前被放弃。它完全覆盖不到 fork,而 fork 恰恰是继承括号的所有方可能仍然存活的那一种情形。由于标记是在加载时铸造的,它还必须在读取路径上做持久写入,这把成本铺开到整个 seam:每次冷加载都递增 revision、对一份无需修复的平衡日志也要走 `commitRepair`、需要一个已存储时间下限来维持钳制的单调性,以及加载在只读存储上会失败。 +**由持久化协调器的冷加载路径写入边界。** 最初将其实现为 `session/resumed` 边界,并在合并前放弃。它完全覆盖不到 fork,而 fork 恰恰是继承括号的所有方可能仍然存活的那一种情形。由于标记是在加载时铸造的,它还必须在读取路径上做持久写入,这把成本铺开到整个 seam:每次冷加载都递增 revision、对一份无需修复的平衡日志也要走 `commitRepair`、需要一个已存储时间下限来维持钳制的单调性,以及加载在只读存储上会失败。 **在 loop 启动时追加边界。** loop 调用 `resumeWith`,因此覆盖恢复路径,但完全漏掉 `fork()` 与 `adopt()`,而且事件不得不在 `'startup'` 上触发——那是 fork 子会话发布的来源——于是 `SessionStartSource` 将不再具有区分力。它还会在追加标记之前就发布会话,因此 `session/created` 监听方可能观察到一份没有边界的带种子日志。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.i18n.yaml deleted file mode 100644 index 712d3e2dd8..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# side as of the last confirmed-consistent state. Both languages carry equal authority; -# after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.md -2026-07-27-question-composer-rows-do-not-shrink.md: 2e0e9b9ca6b141a200ba53d8b6f6f0cad5f7e89d -2026-07-27-question-composer-rows-do-not-shrink.zh.md: 73e3e7614c1eab814080eb7c5e0d03322f1c7145 diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-web-conversation-polish-sweep.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-28-web-conversation-polish-sweep.i18n.yaml deleted file mode 100644 index 987b40abff..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-07-28-web-conversation-polish-sweep.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# side as of the last confirmed-consistent state. Both languages carry equal authority; -# after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-28-web-conversation-polish-sweep.md -2026-07-28-web-conversation-polish-sweep.md: cae52217d66017509c025a5d8d37b1e1e8173c6a -2026-07-28-web-conversation-polish-sweep.zh.md: 0f352f066da13a749f61e89f52dd20487f7726b1 diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-web-details-session-lifecycle.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-29-web-details-session-lifecycle.i18n.yaml index ef84211f5c..f314df3e08 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-web-details-session-lifecycle.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-29-web-details-session-lifecycle.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-29-web-details-session-lifecycle.md -2026-07-29-web-details-session-lifecycle.md: cc1501440d50cb560291e416a0f2b0292e08e1c8 -2026-07-29-web-details-session-lifecycle.zh.md: 1102530f288359ebc5fb04a36c2b813da41e1318 +2026-07-29-web-details-session-lifecycle.md: 41b89fc059a02e56f53b27e5a5b48fb9488b93d7 +2026-07-29-web-details-session-lifecycle.zh.md: 82fb5e0c2608cccbb786a21971a74958417b4f10 diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-web-details-session-lifecycle.md b/.agents/notes/implemented/bug-fix/2026-07-29-web-details-session-lifecycle.md index cc1501440d..41b89fc059 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-web-details-session-lifecycle.md +++ b/.agents/notes/implemented/bug-fix/2026-07-29-web-details-session-lifecycle.md @@ -10,7 +10,7 @@ The details entry is Session-scoped, but its preferred grid width is root-scoped ## Decision -`AppFrame` reads the current Session id and its `blank` summary flag from the authoritative Session projection. It records the last non-blank selected id only when that Session can own details, so hero and other unselected states neither trigger closure nor replace the last Session owner; their rendered details track derives as zero without changing the stored preference. The first Session preserves the layout store's initial preference, whose [visibility default is now closed](2026-07-30-web-details-default-closed.md); returning to the same Session restores its current width, and selecting a different Session closes the root-scoped details preference through the layout store before paint. The per-Session chat selection remains owned by the session-scoped store described by the [slot system standard](../architecture/2026-07-22-slot-type-chain-implementation.md). +`AppFrame` reads the current Session id and its `blank` summary flag from the authoritative Session projection. It records the last non-blank selected id only when that Session can own details, so hero and other unselected states neither trigger closure nor replace the last Session owner; their rendered details track derives as zero without changing the stored preference. The first Session preserves the layout store's initial preference, whose [archived visibility-default decision](../../archived/bug-fix/2026-07-30-web-details-default-closed.md) chose closed; returning to the same Session restores its current width, and selecting a different Session closes the root-scoped details preference through the layout store before paint. The per-Session chat selection remains owned by the session-scoped store described by the [slot system standard](../architecture/2026-07-22-slot-type-chain-implementation.md). The layout store is transient and starts details closed. It neither reads nor writes `localStorage`, so reload restores the sidebar default and details closed and needs no Session-baseline exception. Manual close and reopen inside one unchanged Session retain their existing behavior. The lifecycle effect changes neither the [Workspace-owned New Session flow](../feature/2026-07-25-workspace-ui-product-flow.md), composer drafts, Session navigation, nor concession-chain resizing. diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-web-details-session-lifecycle.zh.md b/.agents/notes/implemented/bug-fix/2026-07-29-web-details-session-lifecycle.zh.md index 1102530f28..82fb5e0c26 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-web-details-session-lifecycle.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-29-web-details-session-lifecycle.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决策 -`AppFrame` 从权威会话投影读取当前会话 id 及其摘要中的 `blank` 标志。它只在该会话能够拥有详情时记录最后一个选中的非 blank 会话 id,因此 hero 和其他未选中状态既不会触发关闭,也不会替换最后一个会话 owner;这些状态下,详情栏轨道的渲染宽度派生为零,但存储的首选宽度不变。首个会话保留布局 store 的初始首选值,该值的[可见性默认设置现为关闭](2026-07-30-web-details-default-closed.md);返回同一会话时恢复其当前宽度;选择不同会话时,系统会先通过布局 store 关闭根作用域存储的详情栏首选宽度,再进行绘制。逐会话的聊天选中项继续由 [slot 体系标准](../architecture/2026-07-22-slot-type-chain-implementation.md)所述的会话作用域 store 拥有。 +`AppFrame` 从权威会话投影读取当前会话 id 及其摘要中的 `blank` 标志。它只在该会话能够拥有详情时记录最后一个选中的非 blank 会话 id,因此 hero 和其他未选中状态既不会触发关闭,也不会替换最后一个会话 owner;这些状态下,详情栏轨道的渲染宽度派生为零,但存储的首选宽度不变。首个会话保留布局 store 的初始首选值,其[已归档的可见性默认值决策](../../archived/bug-fix/2026-07-30-web-details-default-closed.md)选择关闭;返回同一会话时恢复其当前宽度;选择不同会话时,系统会先通过布局 store 关闭根作用域存储的详情栏首选宽度,再进行绘制。逐会话的聊天选中项继续由 [slot 体系标准](../architecture/2026-07-22-slot-type-chain-implementation.md)所述的会话作用域 store 拥有。 布局 store 是瞬时状态,详情栏在启动时保持关闭。它既不读取也不写入 `localStorage`,因此重新加载会恢复侧边栏默认值,并使详情栏保持关闭,无需会话基线例外。在同一个未变化的会话内手动关闭和重新打开详情栏,仍保持原有行为。该生命周期 effect 不改变 [Workspace 拥有的 New Session 动线](../feature/2026-07-25-workspace-ui-product-flow.md)、composer 草稿、会话导航或让步链缩放。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-web-details-default-closed.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-30-web-details-default-closed.i18n.yaml deleted file mode 100644 index 0f0ff3020b..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-07-30-web-details-default-closed.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# side as of the last confirmed-consistent state. Both languages carry equal authority; -# after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-30-web-details-default-closed.md -2026-07-30-web-details-default-closed.md: 658b6fc2c18dc67d8759bec78997f32dcba27914 -2026-07-30-web-details-default-closed.zh.md: 5a1d0e4713e47ce3bc0cc68fa4c4f5f8c94c945f diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-hero-visible-while-blank-session-opens.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-31-hero-visible-while-blank-session-opens.i18n.yaml deleted file mode 100644 index 202b86ce3b..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-07-31-hero-visible-while-blank-session-opens.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# side as of the last confirmed-consistent state. Both languages carry equal authority; -# after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-31-hero-visible-while-blank-session-opens.md -2026-07-31-hero-visible-while-blank-session-opens.md: 6afa5d0ee2b695d6805d20f54e82073db8028df7 -2026-07-31-hero-visible-while-blank-session-opens.zh.md: f21e549b5811d81094374b1363186a1d18fbadaf diff --git a/.agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.i18n.yaml deleted file mode 100644 index 754ca8bbd0..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# side as of the last confirmed-consistent state. Both languages carry equal authority; -# after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.md -2026-08-04-conversation-column-one-axis-scroll.md: 9a487c506a75033d0854f08e95da24704309003d -2026-08-04-conversation-column-one-axis-scroll.zh.md: 23441a7c8655d1f19d3c0fe0f661f81f69b55dba diff --git a/.agents/notes/implemented/bug-fix/2026-08-05-turn-tail-actions-require-a-completed-turn.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-05-turn-tail-actions-require-a-completed-turn.i18n.yaml index 72d3ae50b8..0050d26e51 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-05-turn-tail-actions-require-a-completed-turn.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-05-turn-tail-actions-require-a-completed-turn.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-05-turn-tail-actions-require-a-completed-turn.md -2026-08-05-turn-tail-actions-require-a-completed-turn.md: 689d50bb86c830d6e428239f112568f00d74c9b8 -2026-08-05-turn-tail-actions-require-a-completed-turn.zh.md: 2cc426bbb82acb8f57d491b0f068e89771699357 +2026-08-05-turn-tail-actions-require-a-completed-turn.md: 44a890c955089096204a5b2a2833905c9ef9f7ed +2026-08-05-turn-tail-actions-require-a-completed-turn.zh.md: 58ca9b2519101cae12121cd74e13bdaa90ce23cb diff --git a/.agents/notes/implemented/bug-fix/2026-08-05-turn-tail-actions-require-a-completed-turn.md b/.agents/notes/implemented/bug-fix/2026-08-05-turn-tail-actions-require-a-completed-turn.md index 689d50bb86..44a890c955 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-05-turn-tail-actions-require-a-completed-turn.md +++ b/.agents/notes/implemented/bug-fix/2026-08-05-turn-tail-actions-require-a-completed-turn.md @@ -8,7 +8,7 @@ English | [中文](2026-08-05-turn-tail-actions-require-a-completed-turn.zh.md) Assistant IconActions were derived from the finalized transcript alone: the last content-text assistant of each turn owned the row. That quantity is stable only after the turn closes. While a turn is still producing steps, the narration a model writes before a tool call *is* the last content assistant so far, so it took the row for as long as the tool ran and then lost it to the next step's text. Readers saw copy, branch, and a clock appear under an intermediate sentence, shift the flow by one 28px row, and disappear. The row was also incoherent in that state: its branch control was already disabled through `turnEnds`, and its `Ran for` label was already withheld through `turnTimings`, so only copy worked. -The [message chrome decision](../feature/2026-07-29-web-message-icon-actions-and-clock.md) always claimed mid-turn narration stays chrome-free; the derivation never carried a completion signal to make that true. +The [archived message-chrome decision](../../archived/feature/2026-07-29-web-message-icon-actions-and-clock.md) always claimed mid-turn narration stays chrome-free; the derivation never carried a completion signal to make that true. ## Decision diff --git a/.agents/notes/implemented/bug-fix/2026-08-05-turn-tail-actions-require-a-completed-turn.zh.md b/.agents/notes/implemented/bug-fix/2026-08-05-turn-tail-actions-require-a-completed-turn.zh.md index 2cc426bbb8..58ca9b2519 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-05-turn-tail-actions-require-a-completed-turn.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-05-turn-tail-actions-require-a-completed-turn.zh.md @@ -8,7 +8,7 @@ Status: implemented assistant IconActions 此前只从已定稿的 transcript(文本记录)推导:每个轮次中最后一条含内容文本的 assistant 拥有该行。这个量只有在轮次关闭后才稳定。轮次仍在产出步骤时,模型在工具调用前写下的叙述就是当时该轮次的最后一条内容 assistant,于是它在工具执行期间取得该行,等下一步的文本落定又把它交出去。读者会看到复制、分支和时钟出现在一句中间叙述下方,把流程推开一行 28px,然后消失。该行在这个状态下本身也是残缺的:分支控件已经通过 `turnEnds` 判定为禁用,`Ran for` 标签已经通过 `turnTimings` 判定为不显示,只有复制可用。 -[消息 chrome 决策](../feature/2026-07-29-web-message-icon-actions-and-clock.md)一直声称轮次中间的叙述不带 chrome,但推导过程从未拿到能让这句话成立的完成信号。 +[已归档的消息 chrome 决策](../../archived/feature/2026-07-29-web-message-icon-actions-and-clock.md)一直声称轮次中间的叙述不带 chrome,但推导过程从未拿到能让这句话成立的完成信号。 ## 决策 diff --git a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml index 0406274fbe..661cd44ce6 100644 --- a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md -2026-07-20-dsh-cli-personal-config.md: 10f16a1cbabdd8cd383c59ad8e09787c02d0109a -2026-07-20-dsh-cli-personal-config.zh.md: 22435efbec8ea661c546ffd0c1aa9bb0ff2ebbb2 +2026-07-20-dsh-cli-personal-config.md: e3baa2dc5158893ddaf919b610e51a0b278b58eb +2026-07-20-dsh-cli-personal-config.zh.md: 8417e0b27393fddeff5c75804c39deafdd1d83f8 diff --git a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md index 10f16a1cba..e3baa2dc51 100644 --- a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md +++ b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md @@ -41,7 +41,7 @@ The TUI and Web register the exact personal path through Cordis HMR after boot. ## Consequences - `dsh` from any directory (and `pnpm run demo:tui`) can apply personal providers, models, repository Plugins, and other Loader entries with no checkout edit; verified end-to-end against a personal Anthropic proxy with Opus 4.8, including a bash tool round trip. -- Because an id-targeted patch replaces the whole `config`, a personal override restates the base fields it keeps and can drift when the base entry changes shape; the loader's entry-not-found/name-mismatch warnings and [`dsh --dump-config`](2026-07-30-dsh-dump-config.md) (which prints the composed tree those patches produce) are the diagnostics. +- Because an id-targeted patch replaces the whole `config`, a personal override restates the base fields it keeps and can drift when the base entry changes shape; the loader's entry-not-found/name-mismatch warnings and [`dsh --dump-config`](../simplification/2026-08-03-explicit-config-dsh-entrypoint.md) (which prints the composed tree those patches produce) are the diagnostics. - Personal patches resolve ids against the booted file's own tree, so nested-include overlays (Code Mode) are not personalized; live-run parity for those leaves is deferred. - `dsh-app-boot` depends on `js-yaml` and imports the include's `!!js` YAML dialect (`entryListSchema`) directly, and, like `apps/cli`, depends on `@deepseek-ai/dsh-paths` for `resolveDshHome`. - Live watching belongs only to long-running TUI and Web processes. Headless automation gets deterministic startup configuration and exits without retaining a watcher. diff --git a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md index 22435efbec..8417e0b273 100644 --- a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md +++ b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md @@ -41,7 +41,7 @@ TUI 和 Web 启动后通过 Cordis HMR(热模块替换)注册确切的个人 ## Consequences - 在任意目录运行 `dsh`(以及 `pnpm run demo:tui`),无需修改 checkout,即可应用个人提供方、模型、仓库插件和其他 Loader 配置项;已针对个人 Anthropic 代理与 Opus 4.8 端到端验证,包括一次 bash 工具往返。 -- 由于按 id 定位的补丁替换整个 `config`,个人覆盖必须复述它保留的基础字段,并可能随基础配置项形态变化而漂移;诊断手段是 loader 的「配置项未找到/名称不匹配」警告和 [`dsh --dump-config`](2026-07-30-dsh-dump-config.md)(打印这些补丁合成出的配置树)。 +- 由于按 id 定位的补丁替换整个 `config`,个人覆盖必须复述它保留的基础字段,并可能随基础配置项形态变化而漂移;诊断手段是 loader 的「配置项未找到/名称不匹配」警告和 [`dsh --dump-config`](../simplification/2026-08-03-explicit-config-dsh-entrypoint.md)(打印这些补丁合成出的配置树)。 - 个人补丁只在被启动文件自身的树里解析 id,因此嵌套 include 的 overlay(Code Mode)不会被个性化;这些叶子的实际运行等价性暂缓。 - `dsh-app-boot` 依赖 `js-yaml`,并直接导入 include 的 `!!js` YAML 方言(`entryListSchema`);与 `apps/cli` 一样依赖 `@deepseek-ai/dsh-paths` 以获取 `resolveDshHome`。 - 只有长时间运行的 TUI 和 Web 进程进行实时监视。无头自动化使用确定性的启动配置,退出时不会保留 watcher。 diff --git a/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.i18n.yaml b/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.i18n.yaml deleted file mode 100644 index 7712757384..0000000000 --- a/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# side as of the last confirmed-consistent state. Both languages carry equal authority; -# after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.md -2026-07-22-docked-web-goal-bar.md: ffddef6cec8eb632cd44bb5352de246db7413c02 -2026-07-22-docked-web-goal-bar.zh.md: b732f71cfc3d3f813641c2ad9c594134beb2e440 diff --git a/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.i18n.yaml b/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.i18n.yaml deleted file mode 100644 index 45c03a2347..0000000000 --- a/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# side as of the last confirmed-consistent state. Both languages carry equal authority; -# after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.md -2026-07-29-web-message-icon-actions-and-clock.md: feced6aeb11d176d6c774242a4d1dae14f6730f8 -2026-07-29-web-message-icon-actions-and-clock.zh.md: 5e33182421b423f45c84dbe1a979505f4c31b819 diff --git a/.agents/notes/implemented/feature/2026-07-30-dsh-dump-config.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-dsh-dump-config.i18n.yaml deleted file mode 100644 index 0cd2549e55..0000000000 --- a/.agents/notes/implemented/feature/2026-07-30-dsh-dump-config.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# side as of the last confirmed-consistent state. Both languages carry equal authority; -# after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-dsh-dump-config.md -2026-07-30-dsh-dump-config.md: bc6504541c7868bad019a1bcd9f551435109e4c6 -2026-07-30-dsh-dump-config.zh.md: 5e173305a6cd03de3db4c763f26eeda6fba68ec7 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-composer-stats-and-input-polish.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-composer-stats-and-input-polish.i18n.yaml deleted file mode 100644 index 653b7c0554..0000000000 --- a/.agents/notes/implemented/feature/2026-07-30-web-composer-stats-and-input-polish.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# side as of the last confirmed-consistent state. Both languages carry equal authority; -# after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-web-composer-stats-and-input-polish.md -2026-07-30-web-composer-stats-and-input-polish.md: 78f286cb0edf58d0212492024b8706ffd432ee70 -2026-07-30-web-composer-stats-and-input-polish.zh.md: eeba56d9f3c2ef10222e32b0809e099f60181ac8 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.i18n.yaml deleted file mode 100644 index b3e0c18cdd..0000000000 --- a/.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# side as of the last confirmed-consistent state. Both languages carry equal authority; -# after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md -2026-07-30-web-context-injection-disclosure.md: 84c3259f3f226e501a671cc55cacf7d7d96f61fb -2026-07-30-web-context-injection-disclosure.zh.md: 4d77e06e27badb02fb73ca2ea2a739b33c5804de diff --git a/.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.i18n.yaml deleted file mode 100644 index efc896170b..0000000000 --- a/.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# side as of the last confirmed-consistent state. Both languages carry equal authority; -# after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.md -2026-07-31-hover-card-click-copy.md: c87734fe328fa2adb396d6685495faa82bc1fff2 -2026-07-31-hover-card-click-copy.zh.md: 2d3bc893dd617a3e2e21431175c54bcd4b7ed598 diff --git a/.agents/notes/implemented/feature/2026-07-31-web-cards-toolrow.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-web-cards-toolrow.i18n.yaml deleted file mode 100644 index 991243278a..0000000000 --- a/.agents/notes/implemented/feature/2026-07-31-web-cards-toolrow.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# side as of the last confirmed-consistent state. Both languages carry equal authority; -# after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-web-cards-toolrow.md -2026-07-31-web-cards-toolrow.md: caa18563a9e66f882873e8d7e84cc3ac20702033 -2026-07-31-web-cards-toolrow.zh.md: 7eb53a163f1fd22e09fcf498cb3e6b138834a2f3 diff --git a/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.i18n.yaml b/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.i18n.yaml index f9c6976a0d..abb67be310 100644 --- a/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md -2026-08-04-web-context-source-and-steer-marks.md: 9070ea6ed34fffecd9fd2b90275bd31155100c75 -2026-08-04-web-context-source-and-steer-marks.zh.md: 9d7c7c0a34587071e281ff8b2cb77e359a1580c1 +2026-08-04-web-context-source-and-steer-marks.md: ca44702cf637c4250d141396ac22d206a16acc15 +2026-08-04-web-context-source-and-steer-marks.zh.md: 09cefd07e3b417b63f05ce430cfed7b30597452f diff --git a/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md b/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md index 9070ea6ed3..ca44702cf6 100644 --- a/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md +++ b/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md @@ -14,13 +14,13 @@ The distinctions are already durable. `user/message.source` is the merge-extensi The transcript names all three roles a non-prompt message can play — injected context, recalled session, and steering. -`TranscriptAdapter` and the history fold attach a `provenance` view to every `ContextMessageNode`, computed by `contextProvenance()` from the durable source alone. It returns a `role` (`inject`, or `recall` for a cross-session snapshot) and a `label` naming the producer. `ContextInjectionRow` titles itself from the role and shows the label beside that title in `ToolRow`'s summary geometry, so the collapsed row already answers what was added and by whom; the 141px scrollport and truncation bound are unchanged from the [disclosure decision](2026-07-30-web-context-injection-disclosure.md). What renders inside that scrollport is chosen by the independent form axis added in the [context form decision](2026-08-05-context-form-vocabulary.md). +`TranscriptAdapter` and the history fold attach a `provenance` view to every `ContextMessageNode`, computed by `contextProvenance()` from the durable source alone. It returns a `role` (`inject`, or `recall` for a cross-session snapshot) and a `label` naming the producer. `ContextInjectionRow` titles itself from the role and shows the label beside that title in `ToolRow`'s summary geometry, so the collapsed row already answers what was added and by whom; the 141px scrollport and truncation bound are unchanged from the [archived disclosure decision](../../archived/feature/2026-07-30-web-context-injection-disclosure.md). What renders inside that scrollport is chosen by the independent form axis added in the [context form decision](2026-08-05-context-form-vocabulary.md). **The label is read out of the log, never from a client-side table of producer names.** `workspace-instructions` is named by the distinct instruction paths it reconciled, `session-reference` by the titles of the sessions it read, a plugin source by its logged plugin id, and any other source by its own `kind` — the documented default arm for a merge-extensible union. A source carrying no readable kind degrades to an unnamed injection. A new or renamed producer is therefore identifiable without a client release, no label can go stale against the code, and a resumed, forked, or foreign log projects exactly like a live session. `recall` covers `session-reference` because that is the one shipped source that lifts another session's material into this one. No Web leaf mounts `dsh-session-reference` today — it had only a terminal host — so the arm exists for log portability rather than for a bundled producer, and it is exercised by unit coverage rather than an assembled Web scenario. -`MessageItem` captions durable and pending steering bubbles with `插话`. The runtime replays durable `agent/inbox/spliced` events and projects a user-origin `user/message` as `SteeringMessageNode` when that same message identity was claimed from `next-step`; a queued-turn claim stays a `UserMessageNode`, and a non-user next-step message stays context. This reverses one clause of [no steer entry or interjection chrome](../simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md), which removed the badge because the composer could not steer and the label named a gesture users could not perform. The composer gained a Steer gesture afterwards without amending that note; this decision supplies the product decision its reintroduction clause required, and corrects the stale facts left in it. The caption is the only steering chrome here: composer modes, the Queue dock's strict-steer action, and pending-steering lifecycle stay with their own owners. +`MessageItem` captions durable and pending steering bubbles with `插话`. The runtime replays durable `agent/inbox/spliced` events and projects a user-origin `user/message` as `SteeringMessageNode` when that same message identity was claimed from `next-step`; a queued-turn claim stays a `UserMessageNode`, and a non-user next-step message stays context. This reverses one clause of the [archived no-steer decision](../../archived/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md), which removed the badge because the composer could not steer and the label named a gesture users could not perform. The composer gained a Steer gesture afterwards without amending that note; this decision supplies the product decision its reintroduction clause required, and corrects the stale facts left in it. The caption is the only steering chrome here: composer modes, the Queue dock's strict-steer action, and pending-steering lifecycle stay with their own owners. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.zh.md b/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.zh.md index 9d7c7c0a34..09cefd07e3 100644 --- a/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.zh.md +++ b/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.zh.md @@ -14,13 +14,13 @@ Status: implemented transcript 为非提示消息可能承担的三种角色分别命名:注入上下文、召回会话、steering。 -`TranscriptAdapter` 与历史折叠为每个 `ContextMessageNode` 附加一份 `provenance` 视图,由 `contextProvenance()` 仅依据持久来源计算得出。它返回 `role`(`inject`,跨会话快照则为 `recall`)与命名生产者的 `label`。`ContextInjectionRow` 以角色作为标题,并按 `ToolRow` 摘要的几何在标题旁展示该名称,因此折叠态就已经回答了「注入了什么、由谁注入」;141px 滚动视口与截断上限沿用[展开项决策](2026-07-30-web-context-injection-disclosure.md),未作改动。视口里渲染什么,则由[上下文形态决策](2026-08-05-context-form-vocabulary.md)引入的、相互独立的形态轴决定。 +`TranscriptAdapter` 与历史折叠为每个 `ContextMessageNode` 附加一份 `provenance` 视图,由 `contextProvenance()` 仅依据持久来源计算得出。它返回 `role`(`inject`,跨会话快照则为 `recall`)与命名生产者的 `label`。`ContextInjectionRow` 以角色作为标题,并按 `ToolRow` 摘要的几何在标题旁展示该名称,因此折叠态就已经回答了「注入了什么、由谁注入」;141px 滚动视口与截断上限沿用[已归档的展开项决策](../../archived/feature/2026-07-30-web-context-injection-disclosure.md),未作改动。视口里渲染什么,则由[上下文形态决策](2026-08-05-context-form-vocabulary.md)引入的、相互独立的形态轴决定。 **名称从日志中读出,绝不来自客户端维护的生产者名称表。** `workspace-instructions` 以它对账过的去重指令文件路径命名,`session-reference` 以它读取的会话标题命名,插件来源以其记录的插件 id 命名,其余来源则以自身的 `kind` 命名——这正是可合并扩展联合类型有文档记载的默认分支。没有可读 kind 的来源降级为无名注入。于是新增或重命名的生产者无需客户端发版即可辨识,任何名称都不会相对代码变味,恢复、fork 或来自外部的日志与实时会话的投影结果完全一致。 `recall` 覆盖 `session-reference`,因为它是当前唯一会把另一个会话的材料搬进本会话的已发布来源。今天没有任何 Web 叶子挂载 `dsh-session-reference`——它此前只有终端宿主——因此该分支的存在是为了日志可移植性,而不是为了某个已打包的生产方,其覆盖来自单元测试而非组装后的 Web 场景。 -`MessageItem` 为持久与待处理的 steering 气泡加上 `插话` 标注。runtime 会重放持久 `agent/inbox/spliced` 事件;如果一条用户来源的消息以相同身份从 `next-step` 被领取,后续 `user/message` 就投影为 `SteeringMessageNode`。从排队轮次领取的消息仍是 `UserMessageNode`,非用户来源的 next-step 消息仍是上下文。这推翻了[取消 steer 入口与插话装饰](../simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md)中的一条结论。当时移除徽章,是因为 composer 无法 steer,标签指向了用户做不到的动作。此后 composer 获得了 Steer 手势,却没有同步修订那份 note;本决策提供了它在「重新引入」条款中要求的产品决策,并订正了其中留下的过时事实。标注是这里唯一的 steering 装饰:composer 模式、Queue dock 的严格 steer 操作、待处理 steering 的生命周期仍归各自的所有者。 +`MessageItem` 为持久与待处理的 steering 气泡加上 `插话` 标注。runtime 会重放持久 `agent/inbox/spliced` 事件;如果一条用户来源的消息以相同身份从 `next-step` 被领取,后续 `user/message` 就投影为 `SteeringMessageNode`。从排队轮次领取的消息仍是 `UserMessageNode`,非用户来源的 next-step 消息仍是上下文。这推翻了[已归档的取消 steer 入口与插话装饰决策](../../archived/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md)中的一条结论。当时移除徽章,是因为 composer 无法 steer,标签指向了用户做不到的动作。此后 composer 获得了 Steer 手势,却没有同步修订那份 note;本决策提供了它在「重新引入」条款中要求的产品决策,并订正了其中留下的过时事实。标注是这里唯一的 steering 装饰:composer 模式、Queue dock 的严格 steer 操作、待处理 steering 的生命周期仍归各自的所有者。 ## Alternatives considered diff --git a/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.i18n.yaml b/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.i18n.yaml index f58f595671..4acabc8700 100644 --- a/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.i18n.yaml +++ b/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.md -2026-06-20-core-data-structures-catalog.md: ef100f96b06c454cfd1ec092cc7fd23e712bdf7a -2026-06-20-core-data-structures-catalog.zh.md: 0545235f96341a638c43805de1b47a400d69e618 +2026-06-20-core-data-structures-catalog.md: 7ee1e0ac3df7cb37fc9797702d44f409da820a94 +2026-06-20-core-data-structures-catalog.zh.md: 7cb0ae216f5c5f429c18d097862350997a8335d3 diff --git a/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.md b/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.md index ef100f96b0..7ee1e0ac3d 100644 --- a/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.md +++ b/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.md @@ -8,7 +8,7 @@ English | [中文](2026-06-20-core-data-structures-catalog.zh.md) A reader trying to understand the harness could find its *behavior* in [architecture.md](../../../../docs/architecture.md) (the service map, the session/turn/step lifecycle, the event taxonomy) but had no single place describing its *vocabulary* — the data structures that behavior moves around. The type shapes lived only in source, scattered across `packages/*/src/types.ts`, so understanding "what is a `Message`, a `SessionEvent`, a `StreamChunk`" meant reading the declarations directly. A prose catalog would help, but a catalog that paraphrases or paste-copies type definitions rots the instant a field changes — and an out-of-sync type doc is worse than none, because a reader trusts it. -So the work had two intertwined questions: **what belongs in such a catalog** (the scoping problem — a harness has dozens of cross-package types and dumping all of them helps no one), and **how to keep pasted type definitions from drifting** (the durability problem). This Agent Note records both decisions. Its sibling, [the generated cordis events + services catalog](2026-06-20-generated-cordis-catalog.md), is the *wiring*-axis complement: this one catalogs the data structures, that one the events and services that move them. +So the work had two intertwined questions: **what belongs in such a catalog** (the scoping problem — a harness has dozens of cross-package types and dumping all of them helps no one), and **how to keep pasted type definitions from drifting** (the durability problem). This Agent Note records both decisions. Its historical sibling, [the archived generated Cordis events + services catalog decision](../../archived/process/2026-06-20-generated-cordis-catalog.md), is the *wiring*-axis complement: this one catalogs the data structures, that one the events and services that move them. ## Decision @@ -50,7 +50,7 @@ The durability requirement was specific: the doc shows the **literal** current t The spine-vs-seam rule was tested against `BashExecRequest`, tool schemas and definitions, the schema DSL, presentation types, and the session/persistence split before adoption. -`verify-type-equiv` must scan the complete Markdown scope, not only manifest-named documents. Otherwise an unmanifested `type-equiv` block escapes the claimed one-to-one check. The gate therefore reports such blocks as orphans. This Agent Note records that fail-closed scan rule together with the spine-vs-seam and verbatim-match decisions; the generated Cordis catalog has the symmetric design record in [its Agent Note](2026-06-20-generated-cordis-catalog.md). +`verify-type-equiv` must scan the complete Markdown scope, not only manifest-named documents. Otherwise an unmanifested `type-equiv` block escapes the claimed one-to-one check. The gate therefore reports such blocks as orphans. This Agent Note records that fail-closed scan rule together with the spine-vs-seam and verbatim-match decisions; the generated Cordis catalog has the symmetric design record in [its archived Agent Note](../../archived/process/2026-06-20-generated-cordis-catalog.md). ## Consequences diff --git a/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.zh.md b/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.zh.md index 0545235f96..7cb0ae216f 100644 --- a/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.zh.md +++ b/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.zh.md @@ -8,7 +8,7 @@ Status: implemented 试图理解 harness 的读者可以在 [architecture.md](../../../../docs/architecture.md) 中找到它的*行为*(服务图、会话/轮次/步骤生命周期、事件分类),却找不到一个统一描述其*词汇*的地方,也就是这些行为所传递的数据结构。类型形状只存在于源码中,散落在 `packages/*/src/types.ts` 各处,因此要理解「什么是 `Message`、`SessionEvent`、`StreamChunk`」,就必须直接阅读声明。文字目录会有所帮助,但复述或复制粘贴类型定义的目录会在字段发生变化时立即腐化,而不同步的类型文档比没有文档更糟,因为读者会信任它。 -因此,这项工作有两个相互交织的问题:**这样的目录应包含什么**(范围问题——harness 有数十种跨包边界的类型,把它们全部倾倒进来对谁都没有帮助),以及**如何避免粘贴的类型定义发生漂移**(持久性问题)。本 Agent Note 记下了这两项决策。与它配套的[生成的 Cordis 事件与服务目录](2026-06-20-generated-cordis-catalog.md)从*接线*维度形成补充:本文对数据结构编目,另一篇则对传递这些结构的事件和服务编目。 +因此,这项工作有两个相互交织的问题:**这样的目录应包含什么**(范围问题——harness 有数十种跨包边界的类型,把它们全部倾倒进来对谁都没有帮助),以及**如何避免粘贴的类型定义发生漂移**(持久性问题)。本 Agent Note 记下了这两项决策。与它历史上配套的[已归档的 Cordis 事件与服务目录自动生成决策](../../archived/process/2026-06-20-generated-cordis-catalog.md)从*接线*维度形成补充:本文对数据结构编目,另一篇则对传递这些结构的事件和服务编目。 ## 决策 @@ -50,7 +50,7 @@ Status: implemented 主干与 seam 规则在采纳前经过了 `BashExecRequest`、工具 schema 与定义、schema DSL、展示类型以及会话/持久化拆分的逐一测试。 -`verify-type-equiv` 必须扫描完整的 Markdown 范围,而不仅是 manifest 点名的文档。否则,未列入清单的 `type-equiv` 块就会逃过所宣称的一一检查。因此,门禁会将此类块报告为未列入清单的块。本 Agent Note 将这条默认拒绝放行的扫描规则,连同主干与 seam 的分界决策及逐字匹配决策一并记录;生成的 Cordis 目录在[其 Agent Note](2026-06-20-generated-cordis-catalog.md) 中有对称的设计记录。 +`verify-type-equiv` 必须扫描完整的 Markdown 范围,而不仅是 manifest 点名的文档。否则,未列入清单的 `type-equiv` 块就会逃过所宣称的一一检查。因此,门禁会将此类块报告为未列入清单的块。本 Agent Note 将这条默认拒绝放行的扫描规则,连同主干与 seam 的分界决策及逐字匹配决策一并记录;生成的 Cordis 目录在[其已归档的 Agent Note](../../archived/process/2026-06-20-generated-cordis-catalog.md) 中有对称的设计记录。 ## 后果 diff --git a/.agents/notes/implemented/process/2026-06-20-generated-cordis-catalog.i18n.yaml b/.agents/notes/implemented/process/2026-06-20-generated-cordis-catalog.i18n.yaml deleted file mode 100644 index 00064dc95b..0000000000 --- a/.agents/notes/implemented/process/2026-06-20-generated-cordis-catalog.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# side as of the last confirmed-consistent state. Both languages carry equal authority; -# after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-06-20-generated-cordis-catalog.md -2026-06-20-generated-cordis-catalog.md: 5005e50a2e23c8286a8057dc57f365554bde5056 -2026-06-20-generated-cordis-catalog.zh.md: 384e00d23aafeec7c7bed9bf572a628f77150993 diff --git a/.agents/notes/implemented/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.i18n.yaml deleted file mode 100644 index 4c0e0c5b0d..0000000000 --- a/.agents/notes/implemented/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# side as of the last confirmed-consistent state. Both languages carry equal authority; -# after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.md -2026-07-26-eventsource-parser-for-deepseek-sse.md: e7835bc738b3dec5aefd6011848525f6604e852e -2026-07-26-eventsource-parser-for-deepseek-sse.zh.md: 7c746079aa7012115bea05ec0191d665c8f860d2 diff --git a/.agents/notes/implemented/simplification/2026-07-26-turndown-for-tool-web-html-markdown.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-26-turndown-for-tool-web-html-markdown.i18n.yaml deleted file mode 100644 index 17590b4a15..0000000000 --- a/.agents/notes/implemented/simplification/2026-07-26-turndown-for-tool-web-html-markdown.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# side as of the last confirmed-consistent state. Both languages carry equal authority; -# after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-07-26-turndown-for-tool-web-html-markdown.md -2026-07-26-turndown-for-tool-web-html-markdown.md: 0e387021e3d3be3011cc0d64d37864b30aec4fdf -2026-07-26-turndown-for-tool-web-html-markdown.zh.md: 6c9b9a22dbb556cdf4eef210705e2a7e265447c4 diff --git a/.agents/notes/implemented/simplification/2026-07-30-sidebar-resize-without-visible-pill.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-30-sidebar-resize-without-visible-pill.i18n.yaml deleted file mode 100644 index 76604171be..0000000000 --- a/.agents/notes/implemented/simplification/2026-07-30-sidebar-resize-without-visible-pill.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# side as of the last confirmed-consistent state. Both languages carry equal authority; -# after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-07-30-sidebar-resize-without-visible-pill.md -2026-07-30-sidebar-resize-without-visible-pill.md: cc41898990fa23ff2937140186a8324217911d2e -2026-07-30-sidebar-resize-without-visible-pill.zh.md: 9f1f521df2848b15f5015719bfa6f0e0e9b7be0c diff --git a/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.i18n.yaml deleted file mode 100644 index 00c51f7dff..0000000000 --- a/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# side as of the last confirmed-consistent state. Both languages carry equal authority; -# after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md -2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md: e2d821f3951af472ef1a13b7b6df88a3aa96a318 -2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.zh.md: b55d4a271e0c5f2729222f4652fb0cb43e5cc9f9 diff --git a/.agents/notes/implemented/simplification/2026-08-03-explicit-config-dsh-entrypoint.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-03-explicit-config-dsh-entrypoint.i18n.yaml index c0a15302a9..8ff1af7e8e 100644 --- a/.agents/notes/implemented/simplification/2026-08-03-explicit-config-dsh-entrypoint.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-08-03-explicit-config-dsh-entrypoint.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-08-03-explicit-config-dsh-entrypoint.md -2026-08-03-explicit-config-dsh-entrypoint.md: bbb40babf8abca726126678f4bccb40a63160568 -2026-08-03-explicit-config-dsh-entrypoint.zh.md: a97221a73bab0b181562ddfb7ddab1211f87f168 +2026-08-03-explicit-config-dsh-entrypoint.md: e0d1e954d9cef472ea59345a3d2ef5a67bd03ae8 +2026-08-03-explicit-config-dsh-entrypoint.zh.md: b5b464e3b45a6f3909bbf087f7005ad3f819424a diff --git a/.agents/notes/implemented/simplification/2026-08-03-explicit-config-dsh-entrypoint.md b/.agents/notes/implemented/simplification/2026-08-03-explicit-config-dsh-entrypoint.md index bbb40babf8..e0d1e954d9 100644 --- a/.agents/notes/implemented/simplification/2026-08-03-explicit-config-dsh-entrypoint.md +++ b/.agents/notes/implemented/simplification/2026-08-03-explicit-config-dsh-entrypoint.md @@ -20,7 +20,7 @@ The CLI no longer ships a TUI application. Its TUI overlay, launcher, first-run `dsh web` retains the shared base plus Web overlay and personal-or-explicit user layer. `dsh -p` retains the one-shot Web/headless composition. The reusable TUI package initially remained after this entrypoint change, then [the package-wide removal decision](2026-08-04-remove-tui-package.md) deleted it and its SDK interface. -This decision supersedes the `dsh`-specific parts of the [dedicated TUI front door](../../archived/feature/2026-07-17-dedicated-full-screen-tui-front-door.md), [personal config](../feature/2026-07-20-dsh-cli-personal-config.md), [guided skill commands](../../archived/feature/2026-07-28-dsh-guided-skill-session-commands.md), [meta workspace](../../archived/feature/2026-07-28-dsh-meta-source-workspace.md), [shared config overlays](2026-07-29-shared-base-config-overlays.md), [config dump](../feature/2026-07-30-dsh-dump-config.md), [first-run welcome](../../archived/feature/2026-07-30-versioned-tui-first-run-welcome.md), and [experimental subcommand gate](../../archived/feature/2026-07-31-experimental-subcommand-gate.md) notes. The later [package-wide removal decision](2026-08-04-remove-tui-package.md) supersedes their reusable-package decisions and consolidates the deleted launcher-identity record. +This decision supersedes the `dsh`-specific parts of the [dedicated TUI front door](../../archived/feature/2026-07-17-dedicated-full-screen-tui-front-door.md), [personal config](../feature/2026-07-20-dsh-cli-personal-config.md), [guided skill commands](../../archived/feature/2026-07-28-dsh-guided-skill-session-commands.md), [meta workspace](../../archived/feature/2026-07-28-dsh-meta-source-workspace.md), [shared config overlays](2026-07-29-shared-base-config-overlays.md), [config dump](../../archived/feature/2026-07-30-dsh-dump-config.md), [first-run welcome](../../archived/feature/2026-07-30-versioned-tui-first-run-welcome.md), and [experimental subcommand gate](../../archived/feature/2026-07-31-experimental-subcommand-gate.md) notes. The later [package-wide removal decision](2026-08-04-remove-tui-package.md) supersedes their reusable-package decisions and consolidates the deleted launcher-identity record. ## Verification diff --git a/.agents/notes/implemented/simplification/2026-08-03-explicit-config-dsh-entrypoint.zh.md b/.agents/notes/implemented/simplification/2026-08-03-explicit-config-dsh-entrypoint.zh.md index a97221a73b..b5b464e3b4 100644 --- a/.agents/notes/implemented/simplification/2026-08-03-explicit-config-dsh-entrypoint.zh.md +++ b/.agents/notes/implemented/simplification/2026-08-03-explicit-config-dsh-entrypoint.zh.md @@ -20,7 +20,7 @@ CLI 不再交付 TUI 应用。TUI overlay、启动器、首次运行 onboarding `dsh web` 保留共享 base、Web overlay 与个人或显式用户层。`dsh -p` 保留一次性 Web/headless 组合。可复用 TUI 包(package)在本入口变更后起初保留,随后[全包移除决策](2026-08-04-remove-tui-package.md)将其及 SDK 接口删除。 -本决策取代以下记录中专用于 `dsh` 的部分:[独立 TUI 入口](../../archived/feature/2026-07-17-dedicated-full-screen-tui-front-door.md)、[个人配置](../feature/2026-07-20-dsh-cli-personal-config.md)、[引导式 skill 命令](../../archived/feature/2026-07-28-dsh-guided-skill-session-commands.md)、[meta workspace](../../archived/feature/2026-07-28-dsh-meta-source-workspace.md)、[共享配置 overlay](2026-07-29-shared-base-config-overlays.md)、[配置转储](../feature/2026-07-30-dsh-dump-config.md)、[首次运行欢迎页](../../archived/feature/2026-07-30-versioned-tui-first-run-welcome.md)和[实验性子命令门禁](../../archived/feature/2026-07-31-experimental-subcommand-gate.md)。后续的[全包移除决策](2026-08-04-remove-tui-package.md)取代了其中关于可复用包的决策,并整合了已删除的启动器身份记录。 +本决策取代以下记录中专用于 `dsh` 的部分:[独立 TUI 入口](../../archived/feature/2026-07-17-dedicated-full-screen-tui-front-door.md)、[个人配置](../feature/2026-07-20-dsh-cli-personal-config.md)、[引导式 skill 命令](../../archived/feature/2026-07-28-dsh-guided-skill-session-commands.md)、[meta workspace](../../archived/feature/2026-07-28-dsh-meta-source-workspace.md)、[共享配置 overlay](2026-07-29-shared-base-config-overlays.md)、[配置转储](../../archived/feature/2026-07-30-dsh-dump-config.md)、[首次运行欢迎页](../../archived/feature/2026-07-30-versioned-tui-first-run-welcome.md)和[实验性子命令门禁](../../archived/feature/2026-07-31-experimental-subcommand-gate.md)。后续的[全包移除决策](2026-08-04-remove-tui-package.md)取代了其中关于可复用包的决策,并整合了已删除的启动器身份记录。 ## 验证 diff --git a/.agents/notes/implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.i18n.yaml b/.agents/notes/implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.i18n.yaml deleted file mode 100644 index f3676ba1ef..0000000000 --- a/.agents/notes/implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# side as of the last confirmed-consistent state. Both languages carry equal authority; -# after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.md -2026-07-26-execa-for-test-subprocess-plumbing.md: 958abc4aee94adb3e6206cc299595ad92bde4044 -2026-07-26-execa-for-test-subprocess-plumbing.zh.md: 5ccadd93a182ba299be80d48881fe1c470a2d537 diff --git a/.agents/notes/rejected/architecture/2026-07-29-session-resumed-log-boundary.i18n.yaml b/.agents/notes/rejected/architecture/2026-07-29-session-resumed-log-boundary.i18n.yaml deleted file mode 100644 index ec95910194..0000000000 --- a/.agents/notes/rejected/architecture/2026-07-29-session-resumed-log-boundary.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# side as of the last confirmed-consistent state. Both languages carry equal authority; -# after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/rejected/architecture/2026-07-29-session-resumed-log-boundary.md -2026-07-29-session-resumed-log-boundary.md: 877b0c780f4c92983d2762243fac4e26d945887a -2026-07-29-session-resumed-log-boundary.zh.md: a6f6ecc0d3f3a349f1a438eec0a84f27f8f649b2 diff --git a/.agents/notes/rejected/architecture/2026-07-29-session-resumed-log-boundary.md b/.agents/notes/rejected/architecture/2026-07-29-session-resumed-log-boundary.md deleted file mode 100644 index 877b0c780f..0000000000 --- a/.agents/notes/rejected/architecture/2026-07-29-session-resumed-log-boundary.md +++ /dev/null @@ -1,51 +0,0 @@ -# Agent Note: Record the resume process boundary in the session log - -Status: rejected — the boundary belongs at the seeded-`Session` constructor, which also covers fork and replay; superseded by [the end-seed boundary](../../implemented/architecture/2026-07-30-session-end-seed-log-boundary.md) - -English | [中文](2026-07-29-session-resumed-log-boundary.zh.md) - -## Problem - -A session's durable log gave no evidence that it had changed processes. `session/created`, `session/disposed`, and `session/flush` are cordis runtime signals rather than `SessionEventMap` members, and `agent/session-start` carries a `SessionStartSource` but is emit-only and never logged. Reading a stored log therefore gave no hint that anything had been resumed. - -That gap makes one class of question unanswerable. A plugin that owns a standalone open/close pair in the log — compaction's `compact/start` … `compact/end` is the only one today — must distinguish an unmatched opening marker left by a process that died mid-operation from one an operation is holding right now. Those two states are **byte-identical in stored history**. Without a boundary the owner has to choose between refusing forever (an unmatched marker wedges the operation permanently, and because automatic compaction failure is warn-and-continue the user-visible result is that compaction silently stops working until the context window overflows) and proceeding always (which defeats the point of holding a lock). - -The pressure to fix this is immediate: moving `compact/start` to its real time point, before summarization, widens the crash window from a few microseconds of synchronous appends to the length of a whole model call, so orphaned brackets go from rare to routine. - -## Proposal - -`@deepseek-ai/dsh-session-persistence` declares one log-only `session/resumed` with an empty payload and appends exactly one at the end of every cold load, in the same `commitRepair` batch as any crash-repair closers and positioned after them — so every event before the boundary has a smaller seq and was written by a writer that is no longer tracking this log. Ownership lands narrowly on `loadCore()`, the cold-load path reached by `load()` and by `adopt()`. `loadLiveSnapshot()` appends nothing, and the non-mutating `inspect()`/`readFrom()` reads never write one. - -The predicate a bracket owner evaluates is purely a function of the log: an unmatched opening marker with a `session/resumed` after it is stale, and one with no `session/resumed` after it is live. - -`time` is `Date.now()` floored at the log's greatest `time`, deliberately unlike the synthetic closers, which reuse the last real event's timestamp so repair output stays a deterministic function of stored history. The wall clock is not monotonic — an NTP step, a VM restore, or a log copied from a machine that was ahead can put it behind events already stored — so the floor keeps every cross-boundary duration non-negative. The floor is durable, because the clamped boundary is stored and joins the log's maximum: one future-dated event pins every later boundary in that log to the same instant until wall time passes it. - -**The predicate distinguishes process succession, not concurrent writers.** `load()`'s liveness guard is `ctx.sessions.get(id)`, which only sees sessions live in *this* runtime, and no backend takes a cross-process per-session lock. So process B cold-loading a session A currently owns writes a boundary after A's still-open bracket. A consumer that must tolerate concurrent writers still needs a liveness signal beyond the log. - -## Why this was rejected - -Two reasons, found while reviewing where the marker belonged. - -**It covers no fork.** `sessions.fork()` and a subagent fork child construct a seeded session without touching persistence, so neither gets a boundary. A forked child inherits its parent's prefix verbatim — including an open `compact/start` the parent is still holding — which is the one case where the inherited bracket's owner is demonstrably alive. The predicate was unavailable exactly where it was most needed. - -**Minting the marker at load made a read path a durable write.** Every consequence the review surfaced traced to that: a revision bump on every cold load, a `commitRepair` batch on a balanced log with nothing to repair, the durable time floor above, a load that fails against a read-only store, and a marked log after a resume the caller then cancelled. None of these are wrong given the placement; they are the placement's cost. - -The successor keeps the problem statement and the concurrent-writer scope limit unchanged, and moves the write to `Session`'s constructor — the single waist all six seeded-start paths pass through, fork included. Because the marker then rides the ordinary seed-persistence path, the whole durable-write surface above disappears. - -## Alternatives considered - -**Use `Session.firstLiveSeq` as the staleness predicate.** Dismissed here on the grounds that it is documented as deliberately not persisted, so the same stored log yields different answers in different processes and a read-only reader cannot evaluate it at all. That reasoning was sound about the field and wrong about the conclusion: the fix is to persist a projection of it rather than to compute the boundary somewhere else. This is the alternative that became the successor. - -**Declare the event in core (`dsh-session`).** Rejected here because "the constructor cannot distinguish resume from fork or replay." That is true and turned out not to matter — the distinction is not needed, since inherited history is dead history in all three cases. - -**Teach `interruptedTurnClosers` to close `compact/*`.** Rejected: `compact/*` is plugin-owned vocabulary and core must not know it. Core closes turn, step, and tool boundaries — the relations it owns. The successor keeps this rejection. - -**Lazy self-repair: the owner appends a synthetic closing marker when it finds an orphan.** A write inside a read-shaped check, and it needs an invariant exception for a numbered owner whose turn has already closed. - -**A merge-extensible repair-contributor registry in core.** The right shape once a second consumer exists; with one consumer today, `packages/AGENTS.md` says not to split a seam preemptively. - -**Write the boundary only when repair actually occurred.** Rejected: the predicate must hold for an orderly restart too, where there is nothing to repair. The successor keeps this rejection. - -## Related - -The cold-session `updatedAt` skew this proposal documented is scoped in [the last-activity-index Agent Note](../../proposed/architecture/2026-07-29-durable-last-activity-index.md). That defect predates this proposal and survives its rejection: it is caused by mtime counting every durable write, not by any one boundary. diff --git a/.agents/notes/rejected/architecture/2026-07-29-session-resumed-log-boundary.zh.md b/.agents/notes/rejected/architecture/2026-07-29-session-resumed-log-boundary.zh.md deleted file mode 100644 index a6f6ecc0d3..0000000000 --- a/.agents/notes/rejected/architecture/2026-07-29-session-resumed-log-boundary.zh.md +++ /dev/null @@ -1,51 +0,0 @@ -# Agent Note: 在会话日志中记录恢复的进程边界 - -Status: rejected — 边界应当落在带种子 `Session` 的构造函数上,那里同时覆盖 fork 与回放;由[种子结束边界](../../implemented/architecture/2026-07-30-session-end-seed-log-boundary.md)取代 - -[English](2026-07-29-session-resumed-log-boundary.md) | 中文 - -## Problem - -会话的持久日志此前无法证明它换过进程。`session/created`、`session/disposed` 和 `session/flush` 是 cordis 运行时信号,而不是 `SessionEventMap` 成员;`agent/session-start` 虽然携带 `SessionStartSource`,却只用于 emit,从不记录。因此,读取一份已存储日志得不到任何关于「曾经发生过恢复」的线索。 - -这一空缺让一类问题无法回答。在日志中拥有独立开始/结束事件对的插件必须区分两种未匹配的起始标记:一种由某个在操作中途死亡的进程留下,另一种正被当前某项操作持有;今天符合这一形态的只有压缩的 `compact/start` … `compact/end`。这两种状态**在已存储历史中逐字节相同**。没有边界,所有方只能在两种做法之间选择:永远拒绝(一个未匹配的标记会永久卡住该操作,而自动压缩失败采取警告并继续的策略,因此用户可见的结果是压缩静默停止工作,直到上下文窗口溢出),或者始终继续(这让持有锁失去了意义)。 - -修复它的压力是即刻的:把 `compact/start` 移到摘要生成之前这个真实的时间点,会把崩溃窗口从几微秒的同步追加扩大为一整次模型调用的时长,孤儿括号也就从罕见变为常态。 - -## Proposal - -`@deepseek-ai/dsh-session-persistence` 声明唯一一个纯日志事件 `session/resumed`,其载荷为空,并在每次冷加载结束时恰好追加一条:与崩溃修复产生的 closers 同处一个 `commitRepair` 批次,且排在它们之后。因此,该边界之前的每个事件都有更小的 seq,并且都是由一个不再追踪这份日志的写入方写下的。所有权狭窄地落在 `loadCore()`,也就是 `load()` 与 `adopt()` 到达的冷加载路径。`loadLiveSnapshot()` 不追加任何内容,非变更性的 `inspect()`/`readFrom()` 读取也从不写入。 - -括号所有方求值的谓词纯粹是日志的函数:未匹配的起始标记之后有 `session/resumed` 的就是陈旧的,之后没有的就是存活的。 - -`time` 取 `Date.now()` 并以日志的最大 `time` 为下限,刻意区别于合成 closers——后者复用最后一个真实事件的时间戳,以便修复输出始终是已存储历史的确定性函数。挂钟并非单调:一次 NTP 跳变、一次虚拟机恢复,或一份从走快的机器上拷来的日志,都可能让它落在已存储事件之后,因此这个下限让跨边界的时长都非负。该下限是持久的,因为被钳制的边界本身会被存储并加入日志的最大值:一个未来时间的事件会把该日志中之后的每个边界都钉在同一时刻,直到挂钟时间越过它。 - -**该谓词区分的是进程接替,不是并发写入方。** `load()` 的存活性守卫是 `ctx.sessions.get(id)`,它只看到*本*运行时中存活的会话,而且没有任何后端会取跨进程的按会话锁。因此,进程 B 冷加载一个 A 当前拥有的会话时,会在 A 仍然开放的括号之后写入一个边界。必须容忍并发写入方的消费方仍然需要日志之外的存活信号。 - -## 为什么被否决 - -两个原因,都是在复审标记应当落在何处时发现的。 - -**它完全覆盖不到 fork。** `sessions.fork()` 与子代理 fork 子会话在不触及持久化的情况下构造带种子会话,因此两者都拿不到边界。fork 子会话会逐字节继承父会话的前缀——包括父会话仍然持有的开放 `compact/start`——而这恰恰是继承括号的所有方明显还活着的唯一情形。谓词偏偏在最需要它的地方不可用。 - -**在加载时铸造标记,把读取路径变成了持久写入。** 复审暴露出的每一项后果都源于此:每次冷加载都递增 revision、对一份无需修复的平衡日志也要走 `commitRepair`、上文那个持久时间下限、加载在只读存储上会失败,以及调用方随后取消的恢复也已留下标记。这些在该放置方式下都不算错,它们就是该放置方式的成本。 - -取代方案保留问题陈述与并发写入方的适用范围限制不变,并把写入移到 `Session` 的构造函数——全部六条带种子启动路径(含 fork)必经的唯一收窄处。由于标记随后走普通的种子持久化路径,上述整个持久写入面就消失了。 - -## Alternatives considered - -**用 `Session.firstLiveSeq` 作为陈旧性谓词。** 此处以「文档明确它有意不做持久化,因此同一份已存储日志在不同进程中会给出不同答案,而只读读取方根本无法对它求值」为理由否决。这个推理对字段本身是成立的,但结论错了:正确的修法是持久化它的一个投影,而不是把边界挪到别处去算。这条替代方案正是后来的取代方案。 - -**在核心(`dsh-session`)中声明该事件。** 此处以「构造函数无法把恢复与 fork 或回放区分开」为理由否决。这句话是对的,但事实证明它无关紧要——并不需要这种区分,因为在这三种情形下继承历史都是死历史。 - -**教 `interruptedTurnClosers` 关闭 `compact/*`。** 否决:`compact/*` 是插件所属词汇,核心不得知道它。核心只关闭轮次、步骤和工具边界,也就是它自己拥有的关系。取代方案保留这条否决。 - -**惰性自修复:所有方发现孤儿时自行追加一条合成的关闭标记。** 这是在一次形似读取的检查中执行写入,而且需要为一个带轮次编号、其轮次却已经关闭的所有方开一个不变式例外。 - -**在核心中建一个可合并扩展的修复贡献方注册表。** 一旦出现第二个消费方,这就是正确的形状;今天只有一个消费方,而 `packages/AGENTS.md` 要求不要预先拆分 seam。 - -**仅在确实发生了修复时才写入边界。** 否决:该谓词对有序重启同样必须成立,而那时没有任何东西需要修复。取代方案保留这条否决。 - -## 相关 - -本提案记录过的冷会话 `updatedAt` 偏斜,范围界定在[最后活动索引 Agent Note](../../proposed/architecture/2026-07-29-durable-last-activity-index.md)。该缺陷早于本提案存在,并且在本提案被否决后依然存在:它的成因是 mtime 会计入每一次持久写入,而不是某一个边界。 diff --git a/.agents/notes/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.i18n.yaml b/.agents/notes/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.i18n.yaml deleted file mode 100644 index ea631fa09c..0000000000 --- a/.agents/notes/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# side as of the last confirmed-consistent state. Both languages carry equal authority; -# after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.md -2026-07-04-prune-unimplemented-subagent-vocabulary.md: 276e832af695acbcf70103def8b51fb8c6e1033f -2026-07-04-prune-unimplemented-subagent-vocabulary.zh.md: 81b79c77a055f97785c6a96b7b17802878ded623 diff --git a/.agents/notes/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.md b/.agents/notes/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.md deleted file mode 100644 index 276e832af6..0000000000 --- a/.agents/notes/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.md +++ /dev/null @@ -1,39 +0,0 @@ -# Agent Note: Prune the unimplemented subagent seam vocabulary - -Status: rejected — the deferred capability vocabulary (`outputSchema`/`structured`, `toolFilter`, `sendMessage`/`resume`) is intentionally reserved surface: the seam advertises the full intended contract ahead of its implementations by design, so providers and consumers grow into a stable shape rather than re-negotiating it per capability. The consumer-evidence analysis below records the decision-time state. - -English | [中文](2026-07-04-prune-unimplemented-subagent-vocabulary.zh.md) - -## Problem - -The [subagent seam](../../implemented/feature/2026-06-21-subagent-capability-seam.md) shipped a two-tier capability design: start-time capability flags checked by the service, and optional runtime methods on `SubagentRun`. Three start-time features and both optional runtime methods have zero implementations and zero callers: - -- **`outputSchema`/`structured` and `toolFilter`** (`SubagentCapabilities`, `SubagentStartRequest`, `SubagentResult` in `packages/subagent/subagent/src/types.ts`): at the decision point, every real provider declared `outputSchema: false, toolFilter: false` (`packages/subagent/subagent-spawn/src/index.ts`, `packages/subagent/subagent-fork/src/index.ts`, `packages/subagent/subagent-acp/src/index.ts`); the sole production `ctx.subagents.start` caller (`packages/subagent/tool-subagent/src/index.ts`) built `{ prompt, parent, signal?, agentOptions? }` and structurally could not set either; `structured` appeared only in the scripted test fixture. The service's capability check carried two assert rows whose only exercisers were the rejection tests. -- **`SubagentRun.sendMessage` / `SubagentRun.resume`** (same file): implemented by NO provider — not even the mock; the spawn spec asserts their *absence*. - -The only reason `dsh-subagent` depended on `dsh-tools` at the decision point was `outputSchema`'s schema type (now `ObjectJsonSchema`). Three subsequent subagent workstreams (per-session snapshot replay, the fork seed boundary, the ACP backend) landed around this surface without growing a single consumer. - -## Proposal - -Remove `outputSchema`/`structured`, `toolFilter`, `sendMessage`, and `resume` from the seam; shrink `SubagentCapabilities` to `{ depthLimit }`; drop the two capability-assert rows, the all-false flags on the three providers, the scripted fixture's structured branch and capability knobs, and the tests that exist to pin the removed surface. Drop the `dsh-tools` peer/dev dependency from `packages/subagent/subagent/package.json`. Update the [subagent.md](../../../../docs/core-data-structures/subagent.md) pastes and the type-equiv manifest, plus the affected provider READMEs. The implementing PR amends the seam Agent Note's capability catalog per [implemented/AGENTS.md](../../implemented/AGENTS.md). - -**Keep** `depthLimit`/`maxDepth` and capability checks. The in-process backend enforces the limit, although the shipping tool does not yet set it. Recursion is a known seam risk, so the appropriate follow-up is to supply a tool default rather than delete working enforcement. - -Adjacent surface examined and deliberately left alone: `SubagentService.getProvider()`/`list()` have test-harness consumers only, but the [prune-dead-seam-methods implementation note](../../archived/simplification/2026-06-20-prune-dead-seam-methods.md) records precisely this shape being removed from the bash executor and reverted — a test harness IS a consumer for a one-line accessor over an already-tracked map. `SubagentRunEndInfo.lastAssistantMessage` is a recorded keep (the [subagent-observe-enrich Agent Note](../../archived/feature/2026-06-30-subagent-observe-enrich.md)'s review dropped `agentType` and kept it deliberately, as the only final-message channel for out-of-process children); its currently-unwired bridge forwarding is a gap to close or a consumer to document, not surface for this Agent Note to cut. - -This is the seam-vocabulary echo of [prune dead methods from the persistence seam](../../archived/simplification/2026-06-20-prune-dead-seam-methods.md): members every implementation must declare for nobody — weaker even, since here zero implementations exist. - -## Alternatives considered - -### Why not keep it? - -The two-kinds-of-capability design is the seam Agent Note's headline, and re-adding `outputSchema` later touches several files. But the design survives with `depthLimit` as its live example and the Agent Notes as its record, and the seam Agent Note itself concedes the shipped `toolFilter` shape is wrong (real enforcement needs a `tools/pre-execute` deny in the child's context, not schema filtering) — that deny primitive exists on the interception seams, so re-adding against a real implementing provider will pin a better contract than the current speculative one. - -## Acceptance criteria - -- The removed spellings appear only in this Agent Note and the amended seam Agent Notes; `SubagentCapabilities` is `{ depthLimit: boolean }`; the `dsh-tools` dependency edge is gone (`hygiene` green). -- Depth-enforcement tests are unchanged and green. - -## Risks - -The subagent lifecycle events carry `lastAssistantMessage` on the end payload — that enrichment lives in the service module, not the seam vocabulary this Agent Note shrinks, and the observe-enrich Agent Note records dropping an `agentType` sibling for lacking a consumer: the judgment this Agent Note extends. The CC hooks bridge, the first outside consumer of those lifecycle events, reads only the event payloads and touches none of the surface removed here; the observe-enrich Agent Note's deferred control-flow redesign names implementing `resume` as its own future work — exactly the re-add trigger this Agent Note's pattern anticipates. diff --git a/.agents/notes/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.zh.md b/.agents/notes/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.zh.md deleted file mode 100644 index 81b79c77a0..0000000000 --- a/.agents/notes/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.zh.md +++ /dev/null @@ -1,39 +0,0 @@ -# Agent Note: 裁剪未实现的 subagent seam 词汇 - -Status: rejected — 延后的能力词汇(`outputSchema`/`structured`、`toolFilter`、`sendMessage`/`resume`)是有意保留的接口面:该 seam 按设计先于实现声明完整的预期契约,使提供方与消费方沿稳定形状演进,而非针对每项能力重新协商。下方的消费方证据分析记录了决策时的状态。 - -[English](2026-07-04-prune-unimplemented-subagent-vocabulary.md) | 中文 - -## 问题 - -[subagent seam](../../implemented/feature/2026-06-21-subagent-capability-seam.md) 交付了一套两层能力设计:启动时由服务检查的能力 flag,以及 `SubagentRun` 上的可选运行时方法。三个启动时功能和两个可选运行时方法的实现数与调用数均为零: - -- **`outputSchema`/`structured` 与 `toolFilter`**(`SubagentCapabilities`、`SubagentStartRequest`、`SubagentResult`,位于 `packages/subagent/subagent/src/types.ts`):在作出决策时,每个真实提供方都声明 `outputSchema: false, toolFilter: false`(`packages/subagent/subagent-spawn/src/index.ts`、`packages/subagent/subagent-fork/src/index.ts`、`packages/subagent/subagent-acp/src/index.ts`);唯一的生产环境 `ctx.subagents.start` 调用方(`packages/subagent/tool-subagent/src/index.ts`)构造 `{ prompt, parent, signal?, agentOptions? }`,结构上无法设置这两个字段;`structured` 仅出现在脚本化测试 fixture(测试前置数据)中。服务的能力检查包含两行 assert,其唯一执行者是拒绝测试。 -- **`SubagentRun.sendMessage` / `SubagentRun.resume`**(同一文件):没有任何提供方实现——包括 mock 也没有;spawn spec 断言的正是它们的*缺失*。 - -在作出决策时,`dsh-subagent` 依赖 `dsh-tools` 的唯一原因是 `outputSchema` 的 schema 类型(现为 `ObjectJsonSchema`)。三项后续 subagent 工作(按会话快照回放、fork seed 边界、ACP(Agent Client Protocol)后端)都围绕这块接口面落地,却连一个消费方都没有产生。 - -## 提案 - -从 seam 中移除 `outputSchema`/`structured`、`toolFilter`、`sendMessage` 与 `resume`;将 `SubagentCapabilities` 缩减为 `{ depthLimit }`;删除两行能力 assert、三个提供方上的 all-false flag、脚本化 fixture 的 structured 分支和能力旋钮,以及为固定被移除接口面而存在的测试。`dsh-tools` 的对等依赖(peer dependency)和开发依赖应从 `packages/subagent/subagent/package.json` 中删除。更新 [subagent.md](../../../../docs/core-data-structures/subagent.md) 中的粘贴内容与 type-equiv manifest(元数据清单),以及受影响的提供方 README。实现 PR(Pull Request)按照 [implemented/AGENTS.md](../../implemented/AGENTS.md) 修订 seam Agent Note 的能力目录。 - -**保留** `depthLimit`/`maxDepth` 与能力检查。进程内后端已强制执行该限制,尽管当前发布的工具尚未设置它。递归是已知的 seam 风险,因此恰当的后续工作是提供一个工具默认值,而非删除正在工作的强制逻辑。 - -审视过但有意不动的相邻接口面:`SubagentService.getProvider()`/`list()` 仅有测试 harness 消费方,但 [prune-dead-seam-methods 实现说明](../../archived/simplification/2026-06-20-prune-dead-seam-methods.md)恰好记录了这种形态从 bash 执行器中被移除后又被回退的经过——对于一个基于已跟踪 map 的单行访问器而言,测试 harness 就是消费方。`SubagentRunEndInfo.lastAssistantMessage` 是一个已记录的保留项([subagent 观测/丰富化 Agent Note](../../archived/feature/2026-06-30-subagent-observe-enrich.md)的评审删除了 `agentType` 但有意保留了它,因为它是进程外子 agent(智能体)唯一的最终消息通道);它当前未接通的桥接转发是一个待补的缺口或待记录的消费方,不是本 Agent Note 要裁剪的接口面。 - -这是[从持久化 seam 裁剪死方法](../../archived/simplification/2026-06-20-prune-dead-seam-methods.md)在 seam 词汇层面的回响:每个实现都必须声明、却无人使用的成员,甚至更弱,因为这里连一个实现都没有。 - -## 曾考虑的替代方案 - -### 为什么不保留? - -两类能力的设计是 seam Agent Note 的核心亮点,日后重新添加 `outputSchema` 会涉及多个文件。但该设计以 `depthLimit` 作为活跃示例、以 Agent Note 作为记录仍然成立;而且 seam Agent Note 本身承认已交付的 `toolFilter` 形态是错误的(真正的强制需要在子 agent 上下文中实施 `tools/pre-execute` deny,而非 schema 过滤)——该 deny 原语已存在于拦截 seam 上,因此在由真实提供方实现并重新添加时,将确定一份比当前推测性契约更好的契约。 - -## 验收标准 - -- 被移除的拼写仅出现在本 Agent Note 和修订后的 seam Agent Note 中;`SubagentCapabilities` 为 `{ depthLimit: boolean }`;`dsh-tools` 依赖边已消除(`hygiene` 绿色)。 -- 深度强制测试不变且绿色。 - -## 风险 - -subagent 生命周期事件在结束载荷上携带 `lastAssistantMessage`——该增强位于服务模块中,不在本 Agent Note 缩减的 seam 词汇范围内;observe-enrich Agent Note 记录了因缺少消费方而删除 `agentType` 兄弟字段的判断,本 Agent Note 延续了这一判断。CC 钩子桥接是这些生命周期事件的第一个外部消费方,它只读取事件载荷,不涉及本文移除的任何接口面;observe-enrich Agent Note 推迟的控制流重设计将实现 `resume` 列为自身的未来工作——恰好是本 Agent Note 模式所预期的重新添加触发点。 diff --git a/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.i18n.yaml b/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.i18n.yaml index 7f8463064f..0f5dedddd2 100644 --- a/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.i18n.yaml +++ b/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.md -2026-07-26-dependency-swaps-rejected-by-nih-audit.md: 421ce93a20c567cac4d6a96f806949348dff3e6b -2026-07-26-dependency-swaps-rejected-by-nih-audit.zh.md: 8ae9eb83269a910160aef3a563dce6d83dbc7ad8 +2026-07-26-dependency-swaps-rejected-by-nih-audit.md: 538f6a41b4d2db72f867e98810e8e9382cbdac98 +2026-07-26-dependency-swaps-rejected-by-nih-audit.zh.md: b3f7a1d9717397ee9ed50890bc024fba7d79fa9a diff --git a/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.md b/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.md index 421ce93a20..538f6a41b4 100644 --- a/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.md +++ b/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.md @@ -18,7 +18,7 @@ Adopt the following dependency swaps. Rejected — per-item evidence below; a fu - **`vscode-languageserver-types` for lsp-local's wire-type subset**: ~80 type lines and ~45 guard lines, but upstream guards differ in both directions (accept `uri: undefined` the repo must reject; require `targetRange` the repo tolerates absent), and the initialize-result shapes live in `vscode-languageserver-protocol`, dragging `vscode-jsonrpc` in as a runtime dep — ~1 MB for 80 spec-exact lines. - **`json-rpc-2.0` for `dsh-jsonrpc`**: deletable correlation/dispatch is real (~100–130 lines) but the NDJSON wire must stay bit-identical for the hand-rolled Python SDK client, the package is single-maintainer, and the [GUI RPC note](../../implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md) already treats this package as a frozen narrow surface. `vscode-jsonrpc` is a worse fit still (Content-Length framing, cancellation vocabulary the protocol lacks). - **`jsonrpcclient` for the Python SDK client**: v4 builds/parses messages only — ~20 lines — while the 500 lines that matter (subprocess lifecycle, threaded reader, id correlation, bidirectional server-role responses) stay; the library is in low-maintenance mode. -- **`eventsource-parser` for apiproxy's `readSse`**: only ~15 lines of framing are deletable, both wire ends are in-repo so spec conformance is moot, and it would add a dep to a browser-safe package. (Contrast with the [llm-deepseek proposal](../../implemented/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.md), where a real provider sits across the wire.) +- **`eventsource-parser` for apiproxy's `readSse`**: only ~15 lines of framing are deletable, both wire ends are in-repo so spec conformance is moot, and it would add a dep to a browser-safe package. (Contrast with the [archived llm-deepseek dependency decision](../../archived/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.md), where a real provider sits across the wire.) **Retry, timers, async:** @@ -46,7 +46,7 @@ Adopt the following dependency swaps. Rejected — per-item evidence below; a fu - **`shell-quote` for POSIX single-quoting**: two 1-line quoting helpers with exhaustive tests versus a maintenance-mode package with a CVE history and different escaping output — a safety boundary is the wrong place to save one line. - **`strip-ansi` for pty sanitization**: the pty sanitizer is a streaming state machine with split-sequence carry across chunks and OSC `133;D` prompt-marker extraction (the shell-readiness signal); stateless strippers replace ~20 inner lines while all state machinery stays. `stripVTControlCharacters` also demonstrably leaks unterminated-OSC payloads the session-title normalizer must strip (anti-spoofing). - **`pidtree`/`ps-tree` for the pty process inspector**: bare PID trees; the code needs start-time identity against PID reuse plus `/proc` stdin-wait detection no package does. -- **`execa` for the subagent-subprocess dispose ladder**: `forceKillAfterDelay` covers SIGTERM→SIGKILL but not the stdin-EOF-first cooperative tier or the reject-if-no-exit-edge contract; adopting it here rewrites spawn sites while keeping the ladder. (Test-infrastructure spawn plumbing is different — see the [execa Agent Note](../../implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.md).) +- **`execa` for the subagent-subprocess dispose ladder**: `forceKillAfterDelay` covers SIGTERM→SIGKILL but not the stdin-EOF-first cooperative tier or the reject-if-no-exit-edge contract; adopting it here rewrites spawn sites while keeping the ladder. (Test-infrastructure spawn plumbing is different — see the [archived execa test-infrastructure decision](../../archived/testing/2026-07-26-execa-for-test-subprocess-plumbing.md).) - **`tree-kill` for acp-snapshot teardown and lsp process kill**: the lines are drain-ordering/error-propagation, not tree traversal; lsp/bash already use detached process groups + taskkill. - **node-pty everywhere for the TUI test driver**: the archived [Windows-TUI note](../../archived/feature/2026-07-20-windows-tui-support.md) explicitly rejected node-pty-on-every-host; it was already the Windows leg. diff --git a/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.zh.md b/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.zh.md index 8ae9eb8326..b3f7a1d971 100644 --- a/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.zh.md +++ b/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.zh.md @@ -18,7 +18,7 @@ Status: rejected — 下列每一项替换在证据上都未达到净简化门 - **以 `vscode-languageserver-types` 承担 lsp-local 的协议类型子集**:约 80 行类型加约 45 行守卫,但上游守卫在两个方向上都与本仓库不一致(接受本仓库必须拒绝的 `uri: undefined`;强制要求本仓库容忍缺失的 `targetRange`),而且 initialize 结果的形状住在 `vscode-languageserver-protocol` 里,会把 `vscode-jsonrpc` 拖成运行时依赖——为 80 行严格贴合规范的代码付出约 1 MB。 - **以 `json-rpc-2.0` 替换 `dsh-jsonrpc`**:可删除的关联/分发代码确实存在(约 100–130 行),但 NDJSON 协议格式(wire format)必须与手写的 Python SDK 客户端逐位一致,该包只有单一维护者,且 [GUI RPC 决策](../../implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)已把这个包当作冻结的窄接口面对待。`vscode-jsonrpc` 更不合适(Content-Length 分帧、该协议并不具备的取消词汇)。 - **以 `jsonrpcclient` 承担 Python SDK 客户端**:v4 只做消息的构造/解析——约 20 行——而真正要紧的 500 行(子进程生命周期、线程化读取器、id 关联、双向的服务端角色应答)全都保留;该库处于低维护模式。 -- **以 `eventsource-parser` 替换 apiproxy 的 `readSse`**:可删除的分帧只有约 15 行,线路两端都在仓库内,规范符合性无关紧要,而且这会给一个浏览器安全的包添加依赖。(对比 [llm-deepseek 提案](../../implemented/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.md):那里线路对面是真实的提供方。) +- **以 `eventsource-parser` 替换 apiproxy 的 `readSse`**:可删除的分帧只有约 15 行,线路两端都在仓库内,规范符合性无关紧要,而且这会给一个浏览器安全的包添加依赖。(对比[已归档的 llm-deepseek 依赖决策](../../archived/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.md):那里线路对面是真实的提供方。) **重试、定时器与异步:** @@ -46,7 +46,7 @@ Status: rejected — 下列每一项替换在证据上都未达到净简化门 - **以 `shell-quote` 承担 POSIX 单引号包裹**:两个各 1 行、测试详尽的引号辅助函数,对上一个处于维护模式、有 CVE 历史、转义输出还不一样的包——安全边界不是省一行代码的地方。 - **以 `strip-ansi` 承担 pty 净化**:pty 净化器是一台流式状态机,带跨分片的断裂序列续接和 OSC `133;D` 提示符标记提取(shell 就绪信号);无状态的剥离器只能替掉约 20 行内层代码,全部状态机构件原样保留。`stripVTControlCharacters` 还被实证会泄漏未终止的 OSC 载荷,会话标题归一化器必须剥除它们(反欺骗)。 - **以 `pidtree`/`ps-tree` 承担 pty 进程巡检器**:它们只给裸 PID 树;这段代码需要对抗 PID 复用的启动时间身份校验,加上 `/proc` stdin 等待检测,没有包做这些。 -- **以 `execa` 承担 subagent-subprocess 的 dispose(资源释放)阶梯**:`forceKillAfterDelay` 覆盖 SIGTERM→SIGKILL,但覆盖不了先发 stdin EOF 的协作层级,也覆盖不了「无退出沿即 reject」契约;在这里采用它意味着重写各 spawn 调用点、同时阶梯照旧保留。(测试基础设施的 spawn 管线是另一回事——见 [execa Agent Note](../../implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.md)。) +- **以 `execa` 承担 subagent-subprocess 的 dispose(资源释放)阶梯**:`forceKillAfterDelay` 覆盖 SIGTERM→SIGKILL,但覆盖不了先发 stdin EOF 的协作层级,也覆盖不了「无退出沿即 reject」契约;在这里采用它意味着重写各 spawn 调用点、同时阶梯照旧保留。(测试基础设施的 spawn 管线是另一回事——见[已归档的 execa 测试基础设施决策](../../archived/testing/2026-07-26-execa-for-test-subprocess-plumbing.md)。) - **以 `tree-kill` 承担 acp-snapshot 拆除与 lsp 进程终止**:那些代码行做的是排空顺序与错误传播,不是进程树遍历;lsp/bash 已经使用分离的进程组加 taskkill。 - **在 TUI 测试驱动器上到处使用 node-pty**:已归档的 [Windows TUI 决策](../../archived/feature/2026-07-20-windows-tui-support.md)明确否决了在每个宿主上都使用 node-pty;它当时已经是 Windows 那一条腿。 diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 7794833aa0..32d6c6dcce 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md -README.md: 2d956f31a737d345393232aec9ce55b429e5b4d8 -README.zh.md: 087babe2ff878c69c668ad8fdf22b345f38ac204 +README.md: 0cf50146cc44ef0d6cc060a4c97b3d1ff454f013 +README.zh.md: b0503ed2677f2ef30a51716b1735be1fa9eabe82 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 2d956f31a7..0cf50146cc 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -16,7 +16,7 @@ Approvals take over the composer through the chain this package declares: `Appro The session header declares and renders the session-scoped `'conversation.session.header.actions'` list beside the title, allowing feature plugins to contribute controls without entering the skeleton. The composer chain currency includes the current conversation `session`; ui-subagent selects one-shot or parent-unavailable addressed sessions for reason-specific read-only copy, while the ordinary InputBar keeps every addressed child Send-only because the continuation service exposes no public per-Activation cancellation operation and `session.cancel` would bypass its ownership. -Logged non-user messages render as a default-collapsed disclosure whose header names the role the runtime projected for the message — `上下文注入` for an injection, `跨会话召回` for a recalled session — followed by the producer name that projection read out of the durable source, so a reader distinguishes a skill catalog from a workspace instruction file or a recalled session without expanding. A source that names no producer shows the role alone. The header shares the Tool calls geometry and interaction with `ToolRow` through the package-internal `DisclosureRow`, while retaining context semantics: the expanded body follows its content height up to a 141px scrolling cap and synthesizes no tool state, summary, or keyed toolview dispatch ([disclosure decision](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md), [provenance decision](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md)). That body follows the form the producer declared on its durable source: `instructions` names the reconciled files above their text, `catalog` lists the entries the source recorded instead of the model-facing prose, and every other value — absent, unknown to this version, or carrying no usable fields — renders the opaque body, which shows the model-facing text with its real line breaks and the remaining provenance as fields. The opaque body is the documented default, not a leftover: a resumed, forked, or foreign log must render whether or not its producer is mounted here. A durable or pending steering bubble carries an `插话` / `Interjection` caption above it, the only thing distinguishing a mid-turn interjection from the turn-opening prompt that shares its bubble. +Logged non-user messages render as a default-collapsed disclosure whose header names the role the runtime projected for the message — `上下文注入` for an injection, `跨会话召回` for a recalled session — followed by the producer name that projection read out of the durable source, so a reader distinguishes a skill catalog from a workspace instruction file or a recalled session without expanding. A source that names no producer shows the role alone. The header shares the Tool calls geometry and interaction with `ToolRow` through the package-internal `DisclosureRow`, while retaining context semantics: the expanded body follows its content height up to a 141px scrolling cap and synthesizes no tool state, summary, or keyed toolview dispatch ([historical disclosure decision](../../../.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.md), [provenance decision](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md)). That body follows the form the producer declared on its durable source: `instructions` names the reconciled files above their text, `catalog` lists the entries the source recorded instead of the model-facing prose, and every other value — absent, unknown to this version, or carrying no usable fields — renders the opaque body, which shows the model-facing text with its real line breaks and the remaining provenance as fields. The opaque body is the documented default, not a leftover: a resumed, forked, or foreign log must render whether or not its producer is mounted here. A durable or pending steering bubble carries an `插话` / `Interjection` caption above it, the only thing distinguishing a mid-turn interjection from the turn-opening prompt that shares its bubble. A Think row stays collapsed by default and exposes live reasoning throughput without expanding the chain of thought: while its reasoning block is the streaming tail, the summary switches from the settled first line to the latest non-blank line and its one-line scrollport follows each delta to the inline end. Expanding the row removes the moving summary and leaves the full reasoning in ordinary page flow, so page reading never fights an internal follower; settlement restores the stable first-line summary at the left edge ([decision](../../../.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md)). diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 087babe2ff..b0503ed267 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -14,7 +14,7 @@ 会话页头会在标题旁声明并渲染 Session scope 的 `'conversation.session.header.actions'` 列表,使功能插件无需进入骨架即可贡献控件。编辑器链的 currency 包含当前对话 `session`;ui-subagent 会选取 one-shot 或 parent 不可用的已寻址会话,并按原因显示只读文案,而普通 InputBar 会让所有已寻址 child 仅保留 Send,因为继续执行服务不公开逐 Activation 取消操作,`session.cancel` 也会绕过其所有权。 -已记录的非用户消息渲染为默认折叠的展开项,标题栏先给出运行时为该消息投影出的角色——注入为 `上下文注入`,召回为 `跨会话召回`——其后是该投影从持久来源读出的生产者名称,因此读者无需展开即可区分 skill(技能)目录、工作区指令文件与被召回的会话。来源未提供生产者名称时只显示角色。标题栏通过包内部的 `DisclosureRow` 与 `ToolRow` 共享 Tool calls 的几何与交互,同时保留上下文语义:展开内容区的高度会随内容自适应,最大为 141px,超出后滚动,且不会合成工具状态、摘要或键控 toolview 分发([展开项决策](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md)、[来源决策](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md))。该内容区按生产方在持久来源上声明的形态渲染:`instructions` 在正文之上列出它对账过的文件,`catalog` 列出来源记录的条目而非面向模型的散文,其余取值——未声明、本版本不认识、或字段不可用——一律渲染 opaque 内容区,即按真实换行展示面向模型的文本,并把剩余来源信息列成字段。opaque 不是兜底剩余物而是有文档的默认:恢复的、fork 的、外部写入的日志,无论其生产方是否挂载在此处,都必须渲染得出来。持久或待处理的 steering(中途引导)气泡上方带有 `插话` / `Interjection` 标注,这是把中途插话与共用同一气泡的开轮提示区分开的唯一标识。 +已记录的非用户消息渲染为默认折叠的展开项,标题栏先给出运行时为该消息投影出的角色——注入为 `上下文注入`,召回为 `跨会话召回`——其后是该投影从持久来源读出的生产者名称,因此读者无需展开即可区分 skill(技能)目录、工作区指令文件与被召回的会话。来源未提供生产者名称时只显示角色。标题栏通过包内部的 `DisclosureRow` 与 `ToolRow` 共享 Tool calls 的几何与交互,同时保留上下文语义:展开内容区的高度会随内容自适应,最大为 141px,超出后滚动,且不会合成工具状态、摘要或键控 toolview 分发([历史披露决策](../../../.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.md)、[来源决策](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md))。该内容区按生产方在持久来源上声明的形态渲染:`instructions` 在正文之上列出它对账过的文件,`catalog` 列出来源记录的条目而非面向模型的散文,其余取值——未声明、本版本不认识、或字段不可用——一律渲染 opaque 内容区,即按真实换行展示面向模型的文本,并把剩余来源信息列成字段。opaque 不是兜底剩余物而是有文档的默认:恢复的、fork 的、外部写入的日志,无论其生产方是否挂载在此处,都必须渲染得出来。持久或待处理的 steering(中途引导)气泡上方带有 `插话` / `Interjection` 标注,这是把中途插话与共用同一气泡的开轮提示区分开的唯一标识。 Think 行默认保持折叠,并在不展开思维链的情况下暴露实时推理(reasoning)吞吐:当推理块是流式输出尾部时,摘要从结算后的首行切换到最新的非空行,其单行滚动区会随每个 delta 追到行内末端。展开该行会移除移动摘要,让完整推理进入普通页面流,因此页面阅读不会与内部跟随器争夺滚动;结算后恢复左对齐的稳定首行摘要([决策](../../../.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md))。 diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 47cebf796f..3829879b9d 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md -README.md: 7571cb48424b650a1aaa5222b33a3ee14faa69b4 -README.zh.md: fa0c3f24023ec8c1eb77553bfe191801b6698687 +README.md: c54759f98a944565959ef21ce538eb9b12fccdf1 +README.zh.md: 32275c19bca9d6e8aa510e982d535a72eb1a06a7 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 7571cb4842..c54759f98a 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -6,7 +6,7 @@ Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/ ## Hover cards -`HoverCard` keeps its portaled preview reachable across the anchor gap with a pointer-leave grace. A consumer may also pass `copyText`: the card then exposes button semantics for pointer and keyboard activation, includes that value after the `copyLabel` prefix in its accessible name, writes the exact value through the package clipboard helper, and temporarily replaces its content with `copiedLabel` only after the host accepts the write. A non-collapsed text selection intersecting the card suppresses pointer-click activation, while success feedback retains the original card height and clears when the card closes or after one second. `copyLabel` and `copiedLabel` are label props because this zero-cordis atom cannot read the application locale; omitting `copyText` preserves the read/select-only card. Rationale: [the hover-card copy note](../../../.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.md). +`HoverCard` keeps its portaled preview reachable across the anchor gap with a pointer-leave grace. A consumer may also pass `copyText`: the card then exposes button semantics for pointer and keyboard activation, includes that value after the `copyLabel` prefix in its accessible name, writes the exact value through the package clipboard helper, and temporarily replaces its content with `copiedLabel` only after the host accepts the write. A non-collapsed text selection intersecting the card suppresses pointer-click activation, while success feedback retains the original card height and clears when the card closes or after one second. `copyLabel` and `copiedLabel` are label props because this zero-cordis atom cannot read the application locale; omitting `copyText` preserves the read/select-only card. Historical rationale: [the archived hover-card copy note](../../../.agents/notes/archived/feature/2026-07-31-hover-card-click-copy.md). ## Markdown rendering diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index fa0c3f2402..32275c19bc 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -6,7 +6,7 @@ ## 悬浮卡片 -`HoverCard` 通过指针离开宽限期,让采用 portal 渲染的预览在跨越与锚点之间的间隙时仍可抵达。消费方还可传入 `copyText`:此时卡片为指针与键盘激活提供按钮语义,其无障碍名称会在 `copyLabel` 前缀后包含该值,通过包内剪贴板辅助函数原样写入该值,并且只有宿主接受写入后,才会临时将内容替换为 `copiedLabel`。与卡片相交的非折叠文本选区会阻止指针点击激活;成功反馈保持卡片原有高度,并随卡片关闭或在一秒后清除。`copyLabel` 和 `copiedLabel` 采用 label prop,是因为这个 zero-cordis 原子组件无法读取应用 locale;省略 `copyText` 时,卡片维持只读且可选择文本的行为。理由见[悬浮卡片复制 Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.md)。 +`HoverCard` 通过指针离开宽限期,让采用 portal 渲染的预览在跨越与锚点之间的间隙时仍可抵达。消费方还可传入 `copyText`:此时卡片为指针与键盘激活提供按钮语义,其无障碍名称会在 `copyLabel` 前缀后包含该值,通过包内剪贴板辅助函数原样写入该值,并且只有宿主接受写入后,才会临时将内容替换为 `copiedLabel`。与卡片相交的非折叠文本选区会阻止指针点击激活;成功反馈保持卡片原有高度,并随卡片关闭或在一秒后清除。`copyLabel` 和 `copiedLabel` 采用 label prop,是因为这个 zero-cordis 原子组件无法读取应用 locale;省略 `copyText` 时,卡片维持只读且可选择文本的行为。历史依据见[已归档的悬浮卡片复制 Agent Note](../../../.agents/notes/archived/feature/2026-07-31-hover-card-click-copy.md)。 ## Markdown 渲染 diff --git a/packages/web/tool-web/README.i18n.yaml b/packages/web/tool-web/README.i18n.yaml index 8f2ccbd057..eb57a7b20a 100644 --- a/packages/web/tool-web/README.i18n.yaml +++ b/packages/web/tool-web/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/web/tool-web/README.md -README.md: 12f5c806db66b2109888c1ec642d117f3432d0df -README.zh.md: 27b4bc54a03af6347783a9666bd926bdc74fd0c8 +README.md: 791ea87c655444e639ef85ccce737066ead8b749 +README.zh.md: ffcf2d9813dcb94d8106b2c7d5f8ee9fc25e1aaa diff --git a/packages/web/tool-web/README.md b/packages/web/tool-web/README.md index 12f5c806db..791ea87c65 100644 --- a/packages/web/tool-web/README.md +++ b/packages/web/tool-web/README.md @@ -133,6 +133,6 @@ Append-only; newly visible content follows the reusable request prefix and does ## Known Limitations and Deferred Work -- **HTML→markdown conversion degrades on inputs GFM cannot safely represent** — [turndown](https://github.com/mixmark-io/turndown) (with GFM tables/strikethrough) converts at most `fetchMaxOutputChars` source characters through a real DOM. A conservative 512-level lexical guard passes deeply or ambiguously nested bodies through as raw HTML, conversion exceptions do the same, and table `colspan` is ignored because GFM has no spanning-cell representation; these bounds avoid blocking the event loop or expanding output from an untrusted numeric attribute ([Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-turndown-for-tool-web-html-markdown.md)). +- **HTML→markdown conversion degrades on inputs GFM cannot safely represent** — [turndown](https://github.com/mixmark-io/turndown) (with GFM tables/strikethrough) converts at most `fetchMaxOutputChars` source characters through a real DOM. A conservative 512-level lexical guard passes deeply or ambiguously nested bodies through as raw HTML, conversion exceptions do the same, and table `colspan` is ignored because GFM has no spanning-cell representation; these bounds avoid blocking the event loop or expanding output from an untrusted numeric attribute ([archived dependency decision](../../../.agents/notes/archived/simplification/2026-07-26-turndown-for-tool-web-html-markdown.md)). - **The model-facing surface is minimal by design, with promotions deferred** — `max_results` stays a config bound (not a model argument), and `web_fetch` takes only `url` (no `format`/`prompt`/LLM-summarization mode); both are named later steps in [the seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md). - **No web-specific permission policy** — both tools execute without requesting `ctx.approval`; a deployment that needs confirmation must add a `tools/pre-execute` policy, and the package does not define persistent URL/domain grants. diff --git a/packages/web/tool-web/README.zh.md b/packages/web/tool-web/README.zh.md index 27b4bc54a0..ffcf2d9813 100644 --- a/packages/web/tool-web/README.zh.md +++ b/packages/web/tool-web/README.zh.md @@ -133,6 +133,6 @@ Use the web_fetch tool to retrieve the content of a specific HTTP(S) URL (for ex ## 已知限制与暂缓事项 -- **HTML→markdown 转换会在 GFM 无法安全表示的输入上降级**:[turndown](https://github.com/mixmark-io/turndown)(带 GFM 表格/删除线)通过真实 DOM 转换至多 `fetchMaxOutputChars` 个源字符。保守的 512 层词法守卫会将深层或嵌套有歧义的主体作为原始 HTML 直接透传,转换异常也会如此处理;表格的 `colspan` 会被忽略,因为 GFM 无法表示跨列单元格。这些限制可避免阻塞事件循环,也避免不受信任的数值属性使输出膨胀([Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-turndown-for-tool-web-html-markdown.md))。 +- **HTML→markdown 转换会在 GFM 无法安全表示的输入上降级**:[turndown](https://github.com/mixmark-io/turndown)(带 GFM 表格/删除线)通过真实 DOM 转换至多 `fetchMaxOutputChars` 个源字符。保守的 512 层词法守卫会将深层或嵌套有歧义的主体作为原始 HTML 直接透传,转换异常也会如此处理;表格的 `colspan` 会被忽略,因为 GFM 无法表示跨列单元格。这些限制可避免阻塞事件循环,也避免不受信任的数值属性使输出膨胀([已归档的依赖决策](../../../.agents/notes/archived/simplification/2026-07-26-turndown-for-tool-web-html-markdown.md))。 - **面向模型的接口有意保持精简,后续扩展暂缓**:`max_results` 保持为配置上限(不是模型参数),`web_fetch` 只接受 `url`(没有 `format`/`prompt`/LLM(大语言模型)摘要模式);两项都列为 [seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md) 中的后续步骤。 - **没有 web 专用权限策略**:两个工具都不会请求 `ctx.approval` 就直接执行;需要确认的部署必须添加 `tools/pre-execute` 策略,该包不定义持久化的 URL/域名授权。 From 1c23f196fefb6d68075aab73599ca988308f1ae1 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 7 Aug 2026 23:05:55 +0800 Subject: [PATCH 62/63] fix(ui): use official hero title casing --- apps/web/tests/details-session-lifecycle.e2e.ts | 2 +- apps/web/tests/hmr-live.e2e.ts | 4 ++-- apps/web/tests/lifecycle-chrome.e2e.ts | 2 +- apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md | 2 +- .../tests/snapshots/lifecycle-chrome/plan-active.expected.md | 2 +- apps/web/tests/startup-auto-selection.e2e.ts | 2 +- packages/client/ui-conversation/src/client/locales.ts | 2 +- packages/client/ui-conversation/tests/skeleton.spec.tsx | 2 +- 8 files changed, 9 insertions(+), 9 deletions(-) diff --git a/apps/web/tests/details-session-lifecycle.e2e.ts b/apps/web/tests/details-session-lifecycle.e2e.ts index 3bd781a5ee..c88a823fb7 100644 --- a/apps/web/tests/details-session-lifecycle.e2e.ts +++ b/apps/web/tests/details-session-lifecycle.e2e.ts @@ -121,7 +121,7 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S expect(await page.getByText('Details', { exact: true }).isVisible()).toBe(false) await page.getByRole('button', { name: /^(?:New session|新.*会话)$/ }).last().click() - await page.getByText('Into the unknown', { exact: false }).waitFor({ timeout: 15_000 }) + await page.getByText('Into the Unknown', { exact: false }).waitFor({ timeout: 15_000 }) await expect.poll(() => detailsTrack(page), { timeout: 5_000 }).toBe(0) expect(await page.getByText('Details', { exact: true }).isVisible()).toBe(false) diff --git a/apps/web/tests/hmr-live.e2e.ts b/apps/web/tests/hmr-live.e2e.ts index 1e8e81909f..cafd0fb474 100644 --- a/apps/web/tests/hmr-live.e2e.ts +++ b/apps/web/tests/hmr-live.e2e.ts @@ -75,8 +75,8 @@ it('hot-reloads a real client-plugin source edit without refreshing the page', a if (!existsSync(binPath)) throw new Error('HMR browser test needs the built dsh bin; run pnpm run build first') const originalSource = await readFile(sourcePath) const originalBundle = await readFile(bundlePath) - const oldText = 'Into the unknown' - const sourceNeedle = "'hero.headline': 'Into the unknown'" + const oldText = 'Into the Unknown' + const sourceNeedle = "'hero.headline': 'Into the Unknown'" const newText = `HMR UPDATED ${'x'.repeat(80)}` const updatedSource = originalSource.toString().replace(sourceNeedle, `'hero.headline': '${newText}'`) if (updatedSource === originalSource.toString()) throw new Error(`HMR source lacks ${JSON.stringify(sourceNeedle)}`) diff --git a/apps/web/tests/lifecycle-chrome.e2e.ts b/apps/web/tests/lifecycle-chrome.e2e.ts index 90587e9e4c..1aa6b3dea9 100644 --- a/apps/web/tests/lifecycle-chrome.e2e.ts +++ b/apps/web/tests/lifecycle-chrome.e2e.ts @@ -159,7 +159,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () } // The blank frame renders the hero, not the resident composer: the // headline plus the guidance placeholder are the empty state's anchors. - await expect.poll(() => page.getByText('Into the unknown', { exact: false }).count(), { timeout: 15_000 }).toBe(1) + await expect.poll(() => page.getByText('Into the Unknown', { exact: false }).count(), { timeout: 15_000 }).toBe(1) const input = page.locator('textarea').first() await input.waitFor({ timeout: 10_000 }) if (MODE !== 'record') { diff --git a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md index ad060c5d59..dfa23ca508 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md @@ -20,7 +20,7 @@ - button "Settings": - img - text: Settings -- text: Into the unknown Preview +- text: Into the Unknown Preview - button "Choose workspace": - img - text: workspace diff --git a/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md index ce2ce36af0..3bf7e93148 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md @@ -20,7 +20,7 @@ - button "Settings": - img - text: Settings -- text: Into the unknown Preview +- text: Into the Unknown Preview - button "Choose workspace": - img - text: workspace diff --git a/apps/web/tests/startup-auto-selection.e2e.ts b/apps/web/tests/startup-auto-selection.e2e.ts index c93ed04a40..21ad64e4ed 100644 --- a/apps/web/tests/startup-auto-selection.e2e.ts +++ b/apps/web/tests/startup-auto-selection.e2e.ts @@ -145,7 +145,7 @@ describe('web e2e: startup auto-selection', () => { // seat with `visibility:hidden`, which Playwright reports as not visible). await page.waitForSelector(ROOT_PHASE, { timeout: 15_000 }) expect(await page.locator(ROOT_PHASE).first().getAttribute('data-phase')).toBe('hero') - expect(await page.getByText('Into the unknown').isVisible()).toBe(true) + expect(await page.getByText('Into the Unknown').isVisible()).toBe(true) expect(await page.locator('textarea').first().isVisible()).toBe(true) releaseHistory() diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index eec25939b3..df107d2cd2 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -184,7 +184,7 @@ export const en = { 'access.confirm.acknowledge': 'I understand the risks and want to continue', 'access.confirm.cancel': 'Cancel', 'access.confirm.enable': 'Enable Full access', - 'hero.headline': 'Into the unknown', + 'hero.headline': 'Into the Unknown', 'hero.preview': 'Preview', 'hero.chooseWorkspace': 'Choose workspace', 'session.hierarchy': 'Session hierarchy', diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index 1cf97bb61a..f2da8be7cb 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -242,7 +242,7 @@ function mount( describe('Hero chrome', () => { it('renders the English preview badge through the hero locale seat', () => { const view = render() - expect(view.getByText('Into the unknown')).toBeTruthy() + expect(view.getByText('Into the Unknown')).toBeTruthy() expect(view.getByText('Preview')).toBeTruthy() }) }) From 8315bfdc1f16b28f4708ff7458432d5208e53f3b Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 7 Aug 2026 23:45:28 +0800 Subject: [PATCH 63/63] fix(notes): address archive review findings --- ...2026-08-04-conversation-column-one-axis-scroll.i18n.yaml | 6 ------ .agents/notes/archived/manifest.json | 3 --- ...2026-08-04-conversation-column-one-axis-scroll.i18n.yaml | 6 ++++++ .../2026-08-04-conversation-column-one-axis-scroll.md | 1 - .../2026-08-04-conversation-column-one-axis-scroll.zh.md | 5 ++--- .../feature/2026-07-20-dsh-cli-personal-config.i18n.yaml | 4 ++-- .../feature/2026-07-20-dsh-cli-personal-config.md | 2 +- .../feature/2026-07-20-dsh-cli-personal-config.zh.md | 2 +- packages/client/ui-conversation/README.i18n.yaml | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- 10 files changed, 14 insertions(+), 19 deletions(-) delete mode 100644 .agents/notes/archived/bug-fix/2026-08-04-conversation-column-one-axis-scroll.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.i18n.yaml rename .agents/notes/{archived => implemented}/bug-fix/2026-08-04-conversation-column-one-axis-scroll.md (99%) rename .agents/notes/{archived => implemented}/bug-fix/2026-08-04-conversation-column-one-axis-scroll.zh.md (97%) diff --git a/.agents/notes/archived/bug-fix/2026-08-04-conversation-column-one-axis-scroll.i18n.yaml b/.agents/notes/archived/bug-fix/2026-08-04-conversation-column-one-axis-scroll.i18n.yaml deleted file mode 100644 index cb05519fde..0000000000 --- a/.agents/notes/archived/bug-fix/2026-08-04-conversation-column-one-axis-scroll.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# side as of the last confirmed-consistent state. Both languages carry equal authority; -# after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/archived/bug-fix/2026-08-04-conversation-column-one-axis-scroll.md -2026-08-04-conversation-column-one-axis-scroll.md: e8f80c23a2ac2230079802fb6c85fec6c8b8e807 -2026-08-04-conversation-column-one-axis-scroll.zh.md: a7378b2d5ec026d6a054a080347b155cc476a57a diff --git a/.agents/notes/archived/manifest.json b/.agents/notes/archived/manifest.json index 6fa5f06ceb..c46bb59b44 100644 --- a/.agents/notes/archived/manifest.json +++ b/.agents/notes/archived/manifest.json @@ -94,9 +94,6 @@ "bug-fix/2026-08-03-tui-long-session-render-costs.i18n.yaml": "sha256:f65f7bf8fc84c7a1f022ee393c8d969c06d9bde8bed3a0206de86fb35b246ac6", "bug-fix/2026-08-03-tui-long-session-render-costs.md": "sha256:6ecf2ef831f527f361ade18a882d79bc6eccf15cc676d05728e7753f41cde051", "bug-fix/2026-08-03-tui-long-session-render-costs.zh.md": "sha256:5f44e707b332e13fa06d625212173ea055c1c3c0aee60888435a0ff099ec6037", - "bug-fix/2026-08-04-conversation-column-one-axis-scroll.i18n.yaml": "sha256:ec2ab13c899d2f138cdad0fcbbba3565395ca13bb2c6925ac0fee6518c7b1a2b", - "bug-fix/2026-08-04-conversation-column-one-axis-scroll.md": "sha256:7866cb16460aa47a958b81e904161aa655d54ac331b32f585d6429fffb5c700c", - "bug-fix/2026-08-04-conversation-column-one-axis-scroll.zh.md": "sha256:e01af7c18cad86dac88720014eaeb1f5491eb7feac1e542c5a3d0fd2cc3afee5", "feature/2026-06-14-acp-agent-client-protocol.i18n.yaml": "sha256:006795baa43ae962a8d125cc0f1e9f134bc2ee9fb758b6e7669e3fa0126e1918", "feature/2026-06-14-acp-agent-client-protocol.md": "sha256:6828c0af74bb3fb96206ca6b21c0e56a000b50e4744aad4bc2c05092f3a5a31b", "feature/2026-06-14-acp-agent-client-protocol.zh.md": "sha256:ba104e841a1fb84edbd3b6c8119d50445b7785255a7a8d13bb9ac8a2cb4d2e69", diff --git a/.agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.i18n.yaml new file mode 100644 index 0000000000..754ca8bbd0 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.md +2026-08-04-conversation-column-one-axis-scroll.md: 9a487c506a75033d0854f08e95da24704309003d +2026-08-04-conversation-column-one-axis-scroll.zh.md: 23441a7c8655d1f19d3c0fe0f661f81f69b55dba diff --git a/.agents/notes/archived/bug-fix/2026-08-04-conversation-column-one-axis-scroll.md b/.agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.md similarity index 99% rename from .agents/notes/archived/bug-fix/2026-08-04-conversation-column-one-axis-scroll.md rename to .agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.md index e8f80c23a2..9a487c506a 100644 --- a/.agents/notes/archived/bug-fix/2026-08-04-conversation-column-one-axis-scroll.md +++ b/.agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.md @@ -1,7 +1,6 @@ # Agent Note: The conversation column scrolls on one axis Status: implemented -Archived: 2026-08-07 English | [中文](2026-08-04-conversation-column-one-axis-scroll.zh.md) diff --git a/.agents/notes/archived/bug-fix/2026-08-04-conversation-column-one-axis-scroll.zh.md b/.agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.zh.md similarity index 97% rename from .agents/notes/archived/bug-fix/2026-08-04-conversation-column-one-axis-scroll.zh.md rename to .agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.zh.md index a7378b2d5e..23441a7c86 100644 --- a/.agents/notes/archived/bug-fix/2026-08-04-conversation-column-one-axis-scroll.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.zh.md @@ -1,7 +1,6 @@ -# Agent Note: 会话列只在一个轴上滚动 +# Agent Note:会话列只在一个轴上滚动 -Status: implemented -Archived: 2026-08-07 +状态:已实现 [English](2026-08-04-conversation-column-one-axis-scroll.md) | 中文 diff --git a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml index 661cd44ce6..ee99911ee8 100644 --- a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md -2026-07-20-dsh-cli-personal-config.md: e3baa2dc5158893ddaf919b610e51a0b278b58eb -2026-07-20-dsh-cli-personal-config.zh.md: 8417e0b27393fddeff5c75804c39deafdd1d83f8 +2026-07-20-dsh-cli-personal-config.md: 2a8ae4b235823b4493d2f082d37b85806f45b662 +2026-07-20-dsh-cli-personal-config.zh.md: d8ff6c4fcc5da8f1db6f030e990118e30ae6fe41 diff --git a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md index e3baa2dc51..2a8ae4b235 100644 --- a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md +++ b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md @@ -41,7 +41,7 @@ The TUI and Web register the exact personal path through Cordis HMR after boot. ## Consequences - `dsh` from any directory (and `pnpm run demo:tui`) can apply personal providers, models, repository Plugins, and other Loader entries with no checkout edit; verified end-to-end against a personal Anthropic proxy with Opus 4.8, including a bash tool round trip. -- Because an id-targeted patch replaces the whole `config`, a personal override restates the base fields it keeps and can drift when the base entry changes shape; the loader's entry-not-found/name-mismatch warnings and [`dsh --dump-config`](../simplification/2026-08-03-explicit-config-dsh-entrypoint.md) (which prints the composed tree those patches produce) are the diagnostics. +- Because an id-targeted patch replaces the whole `config`, a personal override restates the base fields it keeps and can drift when the base entry changes shape; the loader's entry-not-found/name-mismatch warnings and [`dsh --dump-config`](../../../../apps/cli/README.md#profiles) (which prints the composed tree those patches produce) are the diagnostics. - Personal patches resolve ids against the booted file's own tree, so nested-include overlays (Code Mode) are not personalized; live-run parity for those leaves is deferred. - `dsh-app-boot` depends on `js-yaml` and imports the include's `!!js` YAML dialect (`entryListSchema`) directly, and, like `apps/cli`, depends on `@deepseek-ai/dsh-paths` for `resolveDshHome`. - Live watching belongs only to long-running TUI and Web processes. Headless automation gets deterministic startup configuration and exits without retaining a watcher. diff --git a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md index 8417e0b273..d8ff6c4fcc 100644 --- a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md +++ b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md @@ -41,7 +41,7 @@ TUI 和 Web 启动后通过 Cordis HMR(热模块替换)注册确切的个人 ## Consequences - 在任意目录运行 `dsh`(以及 `pnpm run demo:tui`),无需修改 checkout,即可应用个人提供方、模型、仓库插件和其他 Loader 配置项;已针对个人 Anthropic 代理与 Opus 4.8 端到端验证,包括一次 bash 工具往返。 -- 由于按 id 定位的补丁替换整个 `config`,个人覆盖必须复述它保留的基础字段,并可能随基础配置项形态变化而漂移;诊断手段是 loader 的「配置项未找到/名称不匹配」警告和 [`dsh --dump-config`](../simplification/2026-08-03-explicit-config-dsh-entrypoint.md)(打印这些补丁合成出的配置树)。 +- 由于按 id 定位的补丁替换整个 `config`,个人覆盖必须复述它保留的基础字段,并可能随基础配置项形态变化而漂移;诊断手段是 loader 的「配置项未找到/名称不匹配」警告和 [`dsh --dump-config`](../../../../apps/cli/README.md#profiles)(打印这些补丁合成出的配置树)。 - 个人补丁只在被启动文件自身的树里解析 id,因此嵌套 include 的 overlay(Code Mode)不会被个性化;这些叶子的实际运行等价性暂缓。 - `dsh-app-boot` 依赖 `js-yaml`,并直接导入 include 的 `!!js` YAML 方言(`entryListSchema`);与 `apps/cli` 一样依赖 `@deepseek-ai/dsh-paths` 以获取 `resolveDshHome`。 - 只有长时间运行的 TUI 和 Web 进程进行实时监视。无头自动化使用确定性的启动配置,退出时不会保留 watcher。 diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 32d6c6dcce..b6bf25d410 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md README.md: 0cf50146cc44ef0d6cc060a4c97b3d1ff454f013 -README.zh.md: b0503ed2677f2ef30a51716b1735be1fa9eabe82 +README.zh.md: 8bfb96bb9326d8fcadc3c357b6abaad88c92bd17 diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index b0503ed267..8bfb96bb93 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -14,7 +14,7 @@ 会话页头会在标题旁声明并渲染 Session scope 的 `'conversation.session.header.actions'` 列表,使功能插件无需进入骨架即可贡献控件。编辑器链的 currency 包含当前对话 `session`;ui-subagent 会选取 one-shot 或 parent 不可用的已寻址会话,并按原因显示只读文案,而普通 InputBar 会让所有已寻址 child 仅保留 Send,因为继续执行服务不公开逐 Activation 取消操作,`session.cancel` 也会绕过其所有权。 -已记录的非用户消息渲染为默认折叠的展开项,标题栏先给出运行时为该消息投影出的角色——注入为 `上下文注入`,召回为 `跨会话召回`——其后是该投影从持久来源读出的生产者名称,因此读者无需展开即可区分 skill(技能)目录、工作区指令文件与被召回的会话。来源未提供生产者名称时只显示角色。标题栏通过包内部的 `DisclosureRow` 与 `ToolRow` 共享 Tool calls 的几何与交互,同时保留上下文语义:展开内容区的高度会随内容自适应,最大为 141px,超出后滚动,且不会合成工具状态、摘要或键控 toolview 分发([历史披露决策](../../../.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.md)、[来源决策](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md))。该内容区按生产方在持久来源上声明的形态渲染:`instructions` 在正文之上列出它对账过的文件,`catalog` 列出来源记录的条目而非面向模型的散文,其余取值——未声明、本版本不认识、或字段不可用——一律渲染 opaque 内容区,即按真实换行展示面向模型的文本,并把剩余来源信息列成字段。opaque 不是兜底剩余物而是有文档的默认:恢复的、fork 的、外部写入的日志,无论其生产方是否挂载在此处,都必须渲染得出来。持久或待处理的 steering(中途引导)气泡上方带有 `插话` / `Interjection` 标注,这是把中途插话与共用同一气泡的开轮提示区分开的唯一标识。 +已记录的非用户消息渲染为默认折叠的展开项,标题栏先给出运行时为该消息投影出的角色——注入为 `上下文注入`,召回为 `跨会话召回`——其后是该投影从持久来源读出的生产者名称,因此读者无需展开即可区分 skill(技能)目录、工作区指令文件与被召回的会话。来源未提供生产者名称时只显示角色。标题栏通过包内部的 `DisclosureRow` 与 `ToolRow` 共享 Tool calls 的几何与交互,同时保留上下文语义:展开内容区的高度会随内容自适应,最大为 141px,超出后滚动,且不会合成工具状态、摘要或键控 toolview 分发([历史展开项决策](../../../.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.md)、[来源决策](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md))。该内容区按生产方在持久来源上声明的形态渲染:`instructions` 在正文之上列出它对账过的文件,`catalog` 列出来源记录的条目而非面向模型的散文,其余取值——未声明、本版本不认识、或字段不可用——一律渲染 opaque 内容区,即按真实换行展示面向模型的文本,并把剩余来源信息列成字段。opaque 不是兜底剩余物而是有文档的默认:恢复的、fork 的、外部写入的日志,无论其生产方是否挂载在此处,都必须渲染得出来。持久或待处理的 steering(中途引导)气泡上方带有 `插话` / `Interjection` 标注,这是把中途插话与共用同一气泡的开轮提示区分开的唯一标识。 Think 行默认保持折叠,并在不展开思维链的情况下暴露实时推理(reasoning)吞吐:当推理块是流式输出尾部时,摘要从结算后的首行切换到最新的非空行,其单行滚动区会随每个 delta 追到行内末端。展开该行会移除移动摘要,让完整推理进入普通页面流,因此页面阅读不会与内部跟随器争夺滚动;结算后恢复左对齐的稳定首行摘要([决策](../../../.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md))。