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/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..c46bb59b44 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,9 +76,18 @@ "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", @@ -157,6 +172,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 +223,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 +361,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 +396,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/feature/2026-07-30-dsh-dump-config.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml similarity index 56% rename from .agents/notes/implemented/feature/2026-07-30-dsh-dump-config.i18n.yaml rename to .agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml index 0cd2549e55..71ded0fa8d 100644 --- a/.agents/notes/implemented/feature/2026-07-30-dsh-dump-config.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.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 .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 +# 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: 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 new file mode 100644 index 0000000000..215c647bcd --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md @@ -0,0 +1,526 @@ +# Agent Note: TypeRT Gateway Targeted Method Calls + +Status: implemented + +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 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 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. + +## Decision + +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. + +`@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 `TypeRTClientRemote` contract through Cordis rather than importing the concrete Gateway implementation. + +## Components and Cordis services + +| Component | Cordis service | Responsibility | +|---|---|---| +| `@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` | +| 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.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 Remote does not understand the physical carrier, and Connection does not understand Goal, Agent, lookup, `InvocationDescriptor`, or Remote namespaces. + +## Business declarations + +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 { + constructor(ctx: Context) { + super(ctx, 'goals') + } + + create(agent: Agent, request: CreateGoalRequest): GoalView { + // Existing business method remains unchanged. + } + + @Remote('create') + remoteExportCreate(agent: Agent, request: CreateGoalRequest): CreateGoalResult { + const view = this.create(agent, request) + return { ref: { id: view.id, revision: view.revision } } + } +} +``` + +`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 `@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 { + constructor(ctx: Context) { + super(ctx, 'goals') + } + + @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 `@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 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 `@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 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: + +```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 design does not support `request.agent`, object destructuring, arrays of objects, nested lookups, or searching arbitrary complex structures for IDs. + +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. + +## InvocationDescriptor + +TypeRT, the permissive SRC parser, Host Gateway, and Client Remote 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 } + ] + cancellation?: { parameter: 'signal' } + result: codec + sourceLocation +} +``` + +`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. + +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. + +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 + +```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. 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. + +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. + +## 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 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 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. + +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 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. + +## 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, + signal?: AbortSignal, + ) => Promise +} + +interface TypeRTRemoteMap { + 'goals/create': ( + agentId: SessionId, + request: CreateGoalRequest, + signal?: AbortSignal, + ) => Promise +} + +interface TypeRTRemoteNamespaceMap { + goals: TypeRTRemoteNamespace$676f616c73 +} + +interface TypeRTRemoteScopeMap { + 'agent:goals/create': ( + request: CreateGoalRequest, + signal?: AbortSignal, + ) => Promise +} +``` + +`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 `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 `@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. + +## 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-api-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' + +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 `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.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 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 `@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) + → direct descriptor + → ctx.connection.rpc.call('/api', 'goals/create', { args }) + +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` 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. + +## 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, `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 `@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. + +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 + +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. + +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. + +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 resolution + +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. + +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 `@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 }) +→ 查找本地 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 编码业务结果 +``` + +`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 shared `/api` call chain + +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.intercept( + '/api', + endpoint => ownsRemoteEndpoint(endpoint), + (endpoint, payload, signal) => { + const { namespace, method } = parseEndpoint(endpoint) + const { args } = parsePayload(payload) + return ctx.typertGateway.invoke({ namespace, method, args, signal }) + }, +) +``` + +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 /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: + +```json +{ + "args": { + "agentId": "session-1", + "request": { + "objective": "finish the migration" + } + } +} +``` + +The complete path is: + +```text +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) +→ 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(..., request.signal) +→ Host InvocationDescriptor 解码、lookup、receiver 解析并把 signal 注入 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 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. + +## Connection and protocol boundaries + +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. + +## Package boundaries + +- `@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. +- `@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. +- 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 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. + +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. + +**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 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. + +## Verification + +- 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 `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. +- 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 + +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 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. + +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 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. + +`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 new file mode 100644 index 0000000000..0ce431b7cb --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md @@ -0,0 +1,526 @@ +# Agent Note: TypeRT Gateway 定向方法调用 + +Status: implemented + +[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 接入,但实现边界不得阻断这种同构复用。 + +## 决策 + +业务 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 复用。 + +`@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 消费共享的 `TypeRTClientRemote` 契约,而不导入具体 Gateway 实现。 + +## 组件和 Cordis 服务 + +| 组件 | Cordis 服务 | 职责 | +|---|---|---| +| `@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` | +| 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.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 Remote 不理解物理 carrier,Connection 也不理解 Goal、Agent、lookup、`InvocationDescriptor` 或 Remote namespace。 + +## 业务声明 + +普通直接调用使用 `@Remote`。现有方法的参数和结果已经是预期的 Remote 契约时,直接装饰该方法,不为此重命名。只有 wire 契约需要不同的请求或结果形态时,才新增 `remoteExport*` 适配器,并由 decorator 参数声明短 API 名。方法需要哪个业务对象,就在顶层参数位置显式声明该对象: + +```text +export class GoalService extends GatewayService { + constructor(ctx: Context) { + super(ctx, 'goals') + } + + create(agent: Agent, request: CreateGoalRequest): GoalView { + // Existing business method remains unchanged. + } + + @Remote('create') + remoteExportCreate(agent: Agent, request: CreateGoalRequest): CreateGoalResult { + const view = this.create(agent, request) + return { ref: { id: view.id, revision: view.revision } } + } +} +``` + +`goals` 是传给 `super()` 的明确 Cordis service key,并默认作为 wire namespace。只有协议 namespace 确实需要与 service key 不同时,才通过第三个参数传入 `namespace` 选项。 + +需要在某类隔离 Context 中查找 Service receiver 时使用 `@RemoteScope()`。Scope identity 不进入业务方法参数: + +```text +export class ScopedGoalService extends GatewayService { + constructor(ctx: Context) { + super(ctx, 'goals') + } + + @RemoteScope('agent', 'create') + remoteExportCreate(request: CreateGoalRequest): Promise { + // Runs against the goals service resolved from the Agent Context. + } +} +``` + +同一个 endpoint 只能选择一种调用模式。需要显式 `Agent` 参数的流程使用 `@Remote`;需要切换到 Agent Context 再解析 scoped receiver 的流程使用 `@RemoteScope('agent')`,两者不会由 TypeRT 根据方法体或参数缺失自动猜测。 + +业务包只依赖轻量的 `@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')` 和 `@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 Scope 注册 + +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 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。 + +## InvocationDescriptor + +TypeRT、SRC 弱解析器、Host Gateway 和 Client Remote 之间只交换一种规范描述: + +```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 } + ] + cancellation?: { parameter: 'signal' } + result: codec + sourceLocation +} +``` + +`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 投影,其中类型不一致属于构建错误。 + +参数顺序来自方法签名,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 生成。 + +descriptor 只存在于两端本地 registry。wire 上只有 `/api` 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。挂载 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 会直接失败。 + +业务对象包和 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。 + +## 唯一类型、符号与 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 方法本身使用 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 Remote 用它编码参数并校验响应;复杂类型无法生成严格 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 交给 Client Remote 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, + signal?: AbortSignal, + ) => Promise +} + +interface TypeRTRemoteMap { + 'goals/create': ( + agentId: SessionId, + request: CreateGoalRequest, + signal?: AbortSignal, + ) => Promise +} + +interface TypeRTRemoteNamespaceMap { + goals: TypeRTRemoteNamespace$676f616c73 +} + +interface TypeRTRemoteScopeMap { + 'agent:goals/create': ( + request: CreateGoalRequest, + signal?: AbortSignal, + ) => Promise +} +``` + +`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 把 `TypeRTRemoteScopeMap` 按 Context key 投影到专用 Scope 类型。最终编程界面保持: + +```text +ctx.remote.goals.create(agentId, request) +agentCtx.remote.goals.create(request) +``` + +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。 + +## Client TypeRT 与 API Gateway Client face + +一个消费环境的 TypeRT 同时维护本地信息和从其他环境导入的 Remote 信息,但两者存放在不同 registry: + +```text +TypeRT.local 当前环境自己的反射模型 +TypeRT.remotes 已导入的 Remote contribution +``` + +`@deepseek-ai/dsh-api-remotes/client` 集中加载需要的 Remote contribution: + +```text +import goalsRemote from '@deepseek-ai/dsh-goal/remote' +import sessionsRemote from '@deepseek-ai/dsh-session/remote' + +await ctx.remote.$mount(goalsRemote) +await ctx.remote.$mount(sessionsRemote) +``` + +Client 业务包只引用 `@deepseek-ai/dsh-api-remotes/client`,不直接依赖 API Gateway 或各业务 `/remote` 运行时入口。API Remotes 消费共享的 `TypeRTClientRemote` 契约和 Cordis `ctx.remote` 服务,再重新导出声明,使所选 Remote map 进入业务编译;新增或移除整套 Client 能力只修改这一处 assembly。 + +`ctx.remote.$mount()` 把 contribution 注册到 `TypeRT.remotes`,安装它的 namespace Service 和具体方法,并在它们就绪后才 resolve。调用该方法的 Cordis fiber 持有 disposer。endpoint 重复、同一 namespace/method 模式冲突或 descriptor 与现有类型身份冲突时直接失败。 + +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 和 `@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) + → direct descriptor + → ctx.connection.rpc.call('/api', 'goals/create', { args }) + +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 }) +``` + +根 `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 可以作为实现选择,但不会成为类型或反射来源。 + +## 跨环境同构约束 + +Remote API 是消费端能力,不等同于 Browser API。已交付的运行时实现 Browser Client contribution 挂载、Connection RPC 调用和 Agent Scope 关联。 + +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` 和 `@RemoteScope` 生成,不能因为它与 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` 和 `@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 对象的内部结构。 + +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。 + +LIB 运行时只加载 `lib` 中的 definition,不启动 TypeScript compiler。Host Gateway 后续的 Service 关联、lookup、Context 解析、调用和响应编码不区分 descriptor 来自 SRC 弱解析还是 LIB 严格生成。 + +CI 和发布运行 LIB。全仓 coverage 全部切换到 LIB 是独立后续工作,不阻塞本次直接方法调用实现。 + +## Host Gateway 解析 + +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 调用方法。 + +普通 `@Remote` 调用保留原始 Service 实例作为 receiver。lookup 成功后,Gateway 按 descriptor 的参数顺序调用 `implementation ?? method` 指定的成员;若 descriptor 声明取消,则在这些参数之后追加 carrier signal。 + +`@RemoteScope('agent')` 调用先由 Agent Context provider 解析 wire identity,再从该 Context 读取 descriptor 的 service key 并调用 scoped receiver。业务方法不会收到隐藏 Context 参数或 Agent ID。 + +```text +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 编码业务结果 +``` + +`ctx.typertGateway.invoke()` 是 carrier-independent 的 Host 入口。它不创建 rpcId、RPC envelope 或 HTTP response;它只返回编码结果,或产生由 Connection RPC adapter 映射的 Gateway 错误。 + +## 共享 `/api` 调用链 + +Connection 在 HTTP Server 上持有唯一 `/api` route。Gateway 把同步 endpoint ownership 判断和 Remote RPC handler 挂到 Connection: + +```text +ctx.connection.rpc.intercept( + '/api', + endpoint => ownsRemoteEndpoint(endpoint), + (endpoint, payload, signal) => { + const { namespace, method } = parseEndpoint(endpoint) + const { args } = parsePayload(payload) + return ctx.typertGateway.invoke({ namespace, method, args, signal }) + }, +) +``` + +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 /api// +``` + +Remote payload 使用具名 JSON 对象,不使用位置数组,也不发送 `InvocationDescriptor`。普通 Goal 调用的 payload slot 是: + +```json +{ + "args": { + "agentId": "session-1", + "request": { + "objective": "finish the migration" + } + } +} +``` + +完整链路为: + +```text +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) +→ 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(..., request.signal) +→ Host InvocationDescriptor 解码、lookup、receiver 解析并把 signal 注入 Reflect.apply +→ result codec 编码 +→ Connection 写入既有 RPC result 并回送相同 rpcId +→ Client result codec 验证并返回 CreateGoalResult +``` + +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 迁移后续独立完成。 + +## Connection 与协议边界 + +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 编程界面。 + +## 包边界 + +- `@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。 +- `@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()`。 +- 业务 Service 包:声明 binding、Remote 方法及其 request/result 类型,并导出生成的 `/remote` 子路径。 + +## 已交付范围与后续工作 + +已交付的纵向链路是 `@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 事件流、调用授权、重试、幂等及跨版本协议兼容均不属于本决策。 + +包拓扑为 `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 接口在多个位置重复声明,也会继续把直接调用、带状态交互和事件流绑在同一生命周期中,因此不采用。 + +**让 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 的 Client Remote Service 显式挂载。 + +**为 Remote 新建独立 transport、HTTP route 或 `/api2` channel。** 这会复制或拆分 Connection 的 Server ownership、rpcId、序列化、trust、错误和未来 WebSocket 生命周期。共享 `/api` interceptor 保留唯一物理 route,并让 Connection 继续以 API Proxy 作为回退 FetchHandler。 + +## 验证 + +- Goal Service 直接装饰业务签名已经符合 Remote 契约的变更类方法,仅保留 `remoteExportCreate(...)` 把 `GoalView` 适配为 `CreateGoalResult`,无需第二条路由、第二份 codec 或 Client 方法清单。 +- 一次干净的 `build:lib` 会在 Client 编译前生成 Host 与消费方 Remote 产物,包括业务包 `/remote` 下的 JS、DTS 和 declaration map。 +- 导入 `@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`。 +- 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 事件流行为保持不变。 + +## 后果 + +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 watcher。 + +公共类型唯一性要求业务 DTO 具有纯类型出口,可能暴露现有包中 Host 类型与实现入口混杂的问题。构建会拒绝这些边界,而不是复制类型掩盖问题。 + +类型 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。若 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。 + +`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/.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-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-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..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: 10f16a1cbabdd8cd383c59ad8e09787c02d0109a -2026-07-20-dsh-cli-personal-config.zh.md: 22435efbec8ea661c546ffd0c1aa9bb0ff2ebbb2 +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 10f16a1cba..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`](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`](../../../../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 22435efbec..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`](2026-07-30-dsh-dump-config.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/.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-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-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/bug-fix/2026-07-28-web-conversation-polish-sweep.i18n.yaml b/.agents/notes/implemented/feature/2026-08-06-resolved-theme-color-metadata.i18n.yaml similarity index 56% rename from .agents/notes/implemented/bug-fix/2026-07-28-web-conversation-polish-sweep.i18n.yaml rename to .agents/notes/implemented/feature/2026-08-06-resolved-theme-color-metadata.i18n.yaml index 987b40abff..7550af746a 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-28-web-conversation-polish-sweep.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-06-resolved-theme-color-metadata.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 .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 +# 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-07-22-docked-web-goal-bar.i18n.yaml b/.agents/notes/implemented/feature/2026-08-06-web-install-manifest.i18n.yaml similarity index 59% rename from .agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.i18n.yaml rename to .agents/notes/implemented/feature/2026-08-06-web-install-manifest.i18n.yaml index 7712757384..d13ede02d2 100644 --- a/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-06-web-install-manifest.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 .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 +# 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/.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/feature/2026-07-31-hover-card-click-copy.i18n.yaml b/.agents/notes/implemented/process/2026-08-04-forward-only-pr-issue-status.i18n.yaml similarity index 56% rename from .agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.i18n.yaml rename to .agents/notes/implemented/process/2026-08-04-forward-only-pr-issue-status.i18n.yaml index efc896170b..1b704da8f1 100644 --- a/.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-04-forward-only-pr-issue-status.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 .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 +# 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/.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/.github/issue-management/policy.mjs b/.github/issue-management/policy.mjs index 4c9242bab5..608291c4f8 100644 --- a/.github/issue-management/policy.mjs +++ b/.github/issue-management/policy.mjs @@ -12,6 +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 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. @@ -129,6 +135,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 = [] @@ -401,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 @@ -424,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`, @@ -491,11 +516,14 @@ 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) + const context = await projectContext(number) + const target = nextResolvingIssueStatus(context.item?.fieldValueByName?.name ?? null, pull) + if (!target) continue + // 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) } } @@ -530,12 +558,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/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/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/apps/cli/composition.md b/apps/cli/composition.md index 1da393bd06..45dc52561a 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-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-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/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/assembled-boot.ts b/apps/web/tests/assembled-boot.ts index d4244e8495..729428e47b 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,53 +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: [ - '@deepseek-ai/dsh-client-connection', - '@deepseek-ai/dsh-client-runtime', - '@deepseek-ai/dsh-client-locale', - ], - immediately: true, - }, - { - id: '@deepseek-ai/dsh-client-locale', - dir: 'locale', - url: '/plugins/locale.js', - rev: 'fx', - inject: ['@deepseek-ai/dsh-client-connection', '@deepseek-ai/dsh-client-runtime'], - 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', '@deepseek-ai/dsh-client-ui-theme'], - }, - { 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-connection', - '@deepseek-ai/dsh-client-locale', - '@deepseek-ai/dsh-client-runtime', - '@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-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'] }, + { 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: [ @@ -69,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 { @@ -101,8 +69,7 @@ let unmount: (() => void) | undefined export function installAssembledBootEnv(): void { beforeEach(() => { localStorage.clear() - Object.defineProperty(navigator, 'languages', { value: ['en-US'], configurable: true }) - Object.defineProperty(navigator, 'language', { value: 'en-US', configurable: true }) + localStorage.setItem('dsh.locale', 'en') document.title = 'DeepSeek Harness' vi.stubGlobal('ResizeObserver', ResizeObserverStub) vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => @@ -120,9 +87,6 @@ export function installAssembledBootEnv(): void { document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() }) document.title = '' history.replaceState(null, '', '/') - const ownNavigator = navigator as unknown as Record - delete ownNavigator.languages - delete ownNavigator.language vi.unstubAllGlobals() }) } @@ -136,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/details-session-lifecycle.e2e.ts b/apps/web/tests/details-session-lifecycle.e2e.ts index cb6c9ba914..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("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) diff --git a/apps/web/tests/hmr-live.e2e.ts b/apps/web/tests/hmr-live.e2e.ts index df81a10402..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 = "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)}`) diff --git a/apps/web/tests/lifecycle-chrome.e2e.ts b/apps/web/tests/lifecycle-chrome.e2e.ts index 06c0cc591d..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("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') { 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/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/apps/web/tests/settings-chrome.e2e.ts b/apps/web/tests/settings-chrome.e2e.ts index 774ceb332f..1ea2bcc5d9 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 -> Host settings -// -> 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 and busy-state Enter preference (both Host-backed), plus // Permission as the persisted default for subsequently created sessions. // Zero model calls: everything is pure client + persistence state on a blank @@ -153,17 +154,38 @@ describe('web e2e: settings modal and General preferences', () => { it('flips the theme through the Appearance cubes and persists across reload and a distinct port', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-appearance')) - const readState = async (target: Page = page): Promise<{ attr: boolean; token: string; legacy: string | null }> => - await target.evaluate(() => ({ + interface ThemeState { + attr: boolean + background: string + /** Pre-migration localStorage key; the Host-backed world never writes it. */ + legacy: string | null + themeColor: string | null + themeColorCount: number + token: string + } + const readState = async (target: Page = page): Promise => await target.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, legacy: 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: '设置' }) @@ -178,6 +200,7 @@ describe('web e2e: settings modal and General preferences', () => { expect(dark.attr).toBe(true) expect(dark.legacy).toBeNull() expect(dark.token).not.toBe(light.token) + expectThemeColorSynchronized(dark) await expect.poll(async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'), { timeout: 5_000 }) .toMatch(/ui-theme:\n\s+preference: dark/) await page.keyboard.press('Escape') @@ -189,7 +212,9 @@ describe('web e2e: settings modal and General preferences', () => { acknowledgeReloadConnectionLoss(tripwire, warningStart) await page.emulateMedia({ colorScheme: 'light' }) await expect.poll(async () => (await readState()).attr, { timeout: 5_000 }).toBe(true) - expect((await readState()).legacy).toBeNull() + const reloaded = await readState() + expect(reloaded.legacy).toBeNull() + expectThemeColorSynchronized(reloaded) // A second live Host binds another ephemeral port but shares the same // user-settings home. Its fresh origin has no theme localStorage and still @@ -203,7 +228,9 @@ describe('web e2e: settings modal and General preferences', () => { await secondPage.goto(second.baseUrl, { waitUntil: 'load' }) await secondPage.waitForSelector('[class*="frame"]', { timeout: 30_000 }) await expect.poll(async () => (await readState(secondPage)).attr, { timeout: 5_000 }).toBe(true) - expect((await readState(secondPage)).legacy).toBeNull() + const secondState = await readState(secondPage) + expect(secondState.legacy).toBeNull() + expectThemeColorSynchronized(secondState) expect(secondTripwire.pageErrors).toEqual([]) expect(secondTripwire.warnings).toEqual([]) } finally { @@ -217,12 +244,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/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md index 728dc768f8..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: Let's start building 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 968083919a..4ccab18ac7 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 diff --git a/apps/web/tests/startup-auto-selection.e2e.ts b/apps/web/tests/startup-auto-selection.e2e.ts index fea5217b73..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("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() diff --git a/docs/api-gateway.i18n.yaml b/docs/api-gateway.i18n.yaml new file mode 100644 index 0000000000..074644ff3e --- /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: 33dfb30c9da25e46b660a3fa54ef37f587cbda08 +api-gateway.zh.md: 633eb10c0f2f065ecf27545813cc17d79f391865 diff --git a/docs/api-gateway.md b/docs/api-gateway.md new file mode 100644 index 0000000000..33dfb30c9d --- /dev/null +++ b/docs/api-gateway.md @@ -0,0 +1,166 @@ +# 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 `@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. + +`@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, RemoteScope } 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) + } + + @RemoteScope('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 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' +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 + +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 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.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 + +| 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-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.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 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 + +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` | `api-remotes` | A mountable `TypeRTRemoteContribution` containing strict descriptors and runtime codecs | +| `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. + +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 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. + +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. 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. + +## 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. `@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. + +## 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. + +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 new file mode 100644 index 0000000000..633eb10c0f --- /dev/null +++ b/docs/api-gateway.zh.md @@ -0,0 +1,166 @@ +# API Gateway + +[English](api-gateway.md) | 中文 + +本文是 TypeRT API Gateway 的当前状态参考。它描述业务 Service 如何声明一元 Remote 方法、构建如何生成 Host 与 Client 契约,以及调用如何复用 Connection 的 RPC 与 `/api` 路由。会话事件、增量数据和其他流协议不属于本文范围;它们可以使用同一个 Connection,但不使用 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。 + +`@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, RemoteScope } 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) + } + + @RemoteScope('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。直接调用与作用域调用分别出现在 `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' +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 + +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.remote.$mount()` 挂载贡献,同时重新导出相同文件中的声明合并。增加一个 Host Remote 包是 Client 组合所有者的显式选择;业务组件不需要分别加载 TypeRT Gateway 或业务包的 Remote JS。 + +未来的 TUI 可以装配同一个不依赖 React 的 `api-remotes` 与 `ctx.remote` 契约,因此它能看到的 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-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.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 Remote endpoint 两个对等入口,但两侧构建不会进入同一个 `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` | `api-remotes` | 可挂载的 `TypeRTRemoteContribution`,包含严格描述符与运行时 codec | +| `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 及发布文件清单;只有具备相应入口的显式贡献包才会生成产物。 + +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 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 编程界面。 + +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 默认策略。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 推断,以免热卸载悄然降低校验强度。 + +## 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 数据。`@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.*`。 + +## 开发模式 + +完整构建会先生成 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 方法或放入调用描述符。 + +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 8daacae254..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: 81464d9c8800556565c84d33239882dc750180a8 -architecture.zh.md: c02bca4f12c3758723b0fc818c89080dccf435d9 +architecture.md: 35a73d4a307f5f48cc41cc496742a2ac210e8877 +architecture.zh.md: 185958221a477bb690e3ab5c91c33ba892ab2d73 diff --git a/docs/architecture.md b/docs/architecture.md index 81464d9c88..35a73d4a30 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` | [`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 c02bca4f12..185958221a 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` | [`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 6a8f7943c2..2de89ef94b 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`](../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 f9fa6e8bb2..21f38d18e2 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:32`](../packages/client/connection/src/index.ts) ## `@deepseek-ai/dsh-client-hmr` @@ -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` @@ -2520,6 +2520,8 @@ 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-runtime` ([`packages/client/runtime/src/index.ts`](../packages/client/runtime/src/index.ts)) @@ -2563,7 +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-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 +2621,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..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/*` @@ -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..4a73dc06ad 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` @@ -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,12 +760,20 @@ 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. + * @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 ``` -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` @@ -1748,7 +1756,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 +2535,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 +2593,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:446`](../../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; lookup-policy and business errors retain identity. + */ +async invoke(request: InvokeRemoteRequest): Promise +``` + +Source: [`packages/api/gateway/src/index.ts:78`](../../packages/api/gateway/src/index.ts) ## `ctx.userInteraction` — `UserInteractionService` 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..75b7837687 --- /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: c70e50e2fea8455eb75dfdf8c309f659ab9cb2f9 +typert.zh.md: 2cd1636d4cc8dbcfa009073b4a8e1dcc8d5897e4 diff --git a/docs/core-data-structures/typert.md b/docs/core-data-structures/typert.md new file mode 100644 index 0000000000..c70e50e2fe --- /dev/null +++ b/docs/core-data-structures/typert.md @@ -0,0 +1,203 @@ +# 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-api-gateway`](../../packages/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. 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. */ +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[] + /** 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. */ + readonly sourceLocation?: InvocationSourceLocation +} +``` + +## TypeRT registry + +`ctx.typert` separates current-environment descriptors, explicitly selected Remote contributions, lookup providers, and scoped Context providers. A lookup provider owns the stable wire declaration and default resolver; Host composition can configure an effect-scoped synchronous or asynchronous resolver for the same key, and unloading that configuration restores the default policy. Registrations are Cordis-owned effects and return awaitable disposers. + +```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 `TypeRTClientRemote`. + +```ts type-equiv +/** Merge-extensible direct namespace surface generated for Client Remote services. */ +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, 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. */ +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> + /** Carrier or direct-caller cancellation injected only into cancellation-aware methods. */ + readonly signal?: AbortSignal +} +``` + +```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; lookup-policy and business errors retain identity. + */ + invoke(request: InvokeRemoteRequest): Promise +} +``` + +## Consumer Remote + +`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 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 after namespace services and concrete methods are ready. + */ + $mount(contribution: TypeRTRemoteContribution): Promise +} +``` diff --git a/docs/core-data-structures/typert.zh.md b/docs/core-data-structures/typert.zh.md new file mode 100644 index 0000000000..2cd1636d4c --- /dev/null +++ b/docs/core-data-structures/typert.zh.md @@ -0,0 +1,203 @@ +# 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-api-gateway`](../../packages/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 安全值。取消通过带外 carrier signal 表达:它在业务参数之后注入,绝不进入 `args`。 + +```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[] + /** 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. */ + readonly sourceLocation?: InvocationSourceLocation +} +``` + +## TypeRT 注册表 + +`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. */ +interface TypeRTService { + readonly local: TypeRTLocalRegistry + readonly remotes: TypeRTRemoteRegistry + readonly lookups: TypeRTLookupRegistry + readonly contexts: TypeRTContextRegistry +} +``` + +生成的消费方声明会把 direct namespace 合并到 `TypeRTClientRemote` 继承的 map 中。 + +```ts type-equiv +/** Merge-extensible direct namespace surface generated for Client Remote services. */ +interface TypeRTRemoteNamespaceMap {} +``` + +## Host Gateway + +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. */ +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> + /** Carrier or direct-caller cancellation injected only into cancellation-aware methods. */ + readonly signal?: AbortSignal +} +``` + +```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; lookup-policy and business errors retain identity. + */ + invoke(request: InvokeRemoteRequest): Promise +} +``` + +## 消费方 Remote + +`ctx.remote` 只暴露由已导入 `/remote` 产物贡献的 namespace。`$mount()` 会把生成的 descriptor 与具体方法作为一项由 fiber 持有的操作统一注册。每个 namespace 都是可追踪的 `remote.` Cordis 子 Service,其生命周期覆盖已挂载的方法;JavaScript Proxy 与 Host 业务 Service 类型都不会进入消费方。 + +```ts type-equiv +/** 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 after namespace services and concrete methods are ready. + */ + $mount(contribution: TypeRTRemoteContribution): Promise +} +``` diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index 95ed34cce0..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: 30a2bd0a2c97df8d3d75ec50f47b861b3a65590e -development.zh.md: 5582a85429c97c3e31517a495c69392b80885f7d +development.md: b7ecab3536d739c105f11a640a07ea83a22f4398 +development.zh.md: 33ceba9f05c45c06acae7c83425a30c5e26ca433 diff --git a/docs/development.md b/docs/development.md index 30a2bd0a2c..b7ecab3536 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 `@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: ```sh diff --git a/docs/development.zh.md b/docs/development.zh.md index 5582a85429..33ceba9f05 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` 或 `@RemoteScope` 声明可调用方法;Host 构建生成 Host-for-Client 类型与运行时贡献,Client 的 `api-remotes` 组合加载这些贡献并挂到 `ctx.remote` 与作用域 `agentCtx.remote` namespace。两侧的生成产物、装配关系、SRC 开发回退和 Web 构建顺序见 [API Gateway](api-gateway.md)。 + 如果相关的本地检查需要使用构建后的包产物,请先构建一次: ```sh diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 1b749b464f..39297afd46 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -27,13 +27,13 @@ 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) | -| `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`) | - | @@ -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` | - | `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 22ec45fc8e..9b1774145a 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"] @@ -272,6 +276,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"] @@ -298,7 +303,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 @@ -311,6 +315,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 @@ -321,18 +326,6 @@ flowchart TD pkg_client_hmr --> pkg_client_modules pkg_client_hmr --> pkg_host_webserver pkg_client_hmr --> 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 @@ -370,23 +363,15 @@ 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 pkg_web --> pkg_invariants pkg_web --> pkg_llm - pkg_client_locale --> pkg_client_connection - 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_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_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 @@ -405,6 +390,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 @@ -435,37 +421,6 @@ flowchart TD pkg_app_boot --> pkg_invariants pkg_app_boot --> pkg_paths pkg_app_boot --> pkg_system_prompt - 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_connection - 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_code_runtime_worker --> pkg_code_runtime pkg_code_runtime_worker --> pkg_invariants pkg_code_runtime_worker --> pkg_session @@ -498,6 +453,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 @@ -555,10 +511,6 @@ flowchart TD pkg_headless --> pkg_host_webserver pkg_headless --> pkg_invariants pkg_headless --> pkg_session - 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_time_context --> pkg_agent pkg_time_context --> pkg_invariants pkg_time_context --> pkg_session @@ -566,16 +518,6 @@ flowchart TD pkg_tmux_context --> pkg_bash pkg_tmux_context --> pkg_invariants pkg_tmux_context --> pkg_session - 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_pty --> pkg_agent pkg_pty --> pkg_brand pkg_pty --> pkg_invariants @@ -667,21 +609,15 @@ flowchart TD pkg_permission --> pkg_session_projection pkg_permission --> pkg_settings pkg_permission --> pkg_user_approval - pkg_client_ui_conversation --> pkg_client_connection - 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_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 pkg_command_feedback --> pkg_commands pkg_command_feedback --> pkg_invariants pkg_command_feedback --> 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_local --> pkg_agent pkg_pty_local --> pkg_invariants pkg_pty_local --> pkg_pty @@ -835,35 +771,10 @@ flowchart TD pkg_tool_ask_user --> pkg_invariants pkg_tool_ask_user --> pkg_tools pkg_tool_ask_user --> pkg_user_interaction - 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_api_remotes + pkg_client_runtime --> pkg_invariants + pkg_client_runtime --> pkg_type_meta + pkg_client_runtime --> pkg_typert_registry pkg_session_reference --> pkg_agent pkg_session_reference --> pkg_compact pkg_session_reference --> pkg_invariants @@ -995,42 +906,30 @@ 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_connection + 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 @@ -1073,6 +972,37 @@ 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_connection + 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 @@ -1106,6 +1036,18 @@ 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_connection + 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_acp_demo --> pkg_acp pkg_acp_demo --> pkg_agent_spine_demo pkg_acp_demo --> pkg_app_boot @@ -1126,6 +1068,85 @@ 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_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 + 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_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 + 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 | @@ -1144,7 +1165,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) | @@ -1157,14 +1177,12 @@ 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) | | [`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-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) | @@ -1177,16 +1195,15 @@ 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-locale`](../packages/client/locale) | `client` | [`client-connection`](../packages/client/connection), [`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-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) | +| [`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) | | [`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) | @@ -1197,19 +1214,13 @@ 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) | -| [`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-connection`](../packages/client/connection), [`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) | | [`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) | | [`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) | @@ -1225,11 +1236,8 @@ flowchart TD | [`user-approval`](../packages/ui/user-approval) | `ui` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`user-interaction`](../packages/ui/user-interaction) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`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) | -| [`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) | | [`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-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) | | [`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) | @@ -1249,9 +1257,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) | -| [`client-ui-conversation`](../packages/client/ui-conversation) | `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-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`token-meter`](../packages/llm/token-meter) | +| [`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) | | [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`commands`](../packages/ui/commands), [`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-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) | @@ -1277,10 +1284,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) | -| [`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` | [`api-remotes`](../packages/api/remotes), [`invariants`](../packages/support/invariants), [`type-meta`](../packages/typert/type-meta), [`typert-registry`](../packages/typert/registry) | | [`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) | @@ -1302,10 +1306,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-connection`](../packages/client/connection), [`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) | @@ -1313,8 +1318,27 @@ 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-connection`](../packages/client/connection), [`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-connection`](../packages/client/connection), [`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) | | [`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) | +| [`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 7a3922c8fb..3d7836104f 100644 --- a/knip.json +++ b/knip.json @@ -115,6 +115,15 @@ "tests/**/*.ts" ] }, + "packages/api/remotes": { + "entry": [ + "tests/**/*.e2e.ts" + ], + "project": [ + "src/**/*.ts", + "tests/**/*.ts" + ] + }, "packages/client/ui-primitives": { "entry": [ "tests/**/*.spec.tsx" @@ -200,7 +209,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/package.json b/package.json index 7bd84db93a..81cb40acad 100644 --- a/package.json +++ b/package.json @@ -15,17 +15,21 @@ ], "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", - "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", "test": "vitest run", "test:coverage": "vitest run --coverage", "test:e2e": "vitest run --config vitest.e2e.config.ts", + "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", 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/api/README.i18n.yaml b/packages/api/README.i18n.yaml new file mode 100644 index 0000000000..6a834cdf4d --- /dev/null +++ b/packages/api/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/README.md +README.md: 7c75e8012459266e0ce09c97416d140e5ac777e1 +README.zh.md: 87bd15fc4e5ad23ef785f7c9ee805a4aa1a35e46 diff --git a/packages/api/README.md b/packages/api/README.md new file mode 100644 index 0000000000..7c75e80124 --- /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.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 `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 + +- 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..87bd15fc4e --- /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.remote` | +| [`gateway/`](gateway/README.md) | Host TypeRT 分发器与 Client Remote endpoint | `ctx.typertGateway` / `ctx.remote` | + +运行时依赖方向为 `remotes → gateway → connection → webserver`:BFF 消费共享的 `TypeRTClientRemote` 契约,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/api/gateway/README.i18n.yaml b/packages/api/gateway/README.i18n.yaml new file mode 100644 index 0000000000..3f4cd32e4d --- /dev/null +++ b/packages/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/api/gateway/README.md +README.md: 0e1a03d2016b8cfbe165dbf1b0a9802290b29502 +README.zh.md: 6b5ccff2340405cc0045147239c5bd4f3eead7da diff --git a/packages/api/gateway/README.md b/packages/api/gateway/README.md new file mode 100644 index 0000000000..0e1a03d201 --- /dev/null +++ b/packages/api/gateway/README.md @@ -0,0 +1,39 @@ +# @deepseek-ai/dsh-api-gateway + +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.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`) + +`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()`; `@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. + +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: `ClientRemote` (ctx key: `remote`) + +`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 `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 + +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 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/api/gateway/README.zh.md b/packages/api/gateway/README.zh.md new file mode 100644 index 0000000000..6b5ccff234 --- /dev/null +++ b/packages/api/gateway/README.zh.md @@ -0,0 +1,39 @@ +# @deepseek-ai/dsh-api-gateway + +[English](README.md) | 中文 + +为 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`) + +每次调用时,`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()` 覆盖解析行为;`@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 等策略拒绝保持原错误码。 + +支持取消的 Remote 方法会把 `signal: AbortSignal` 声明为最后一个 Host 参数。signal 是 descriptor 元数据,而不是 wire 参数:Connection 将它提供给 Gateway,Gateway 则在已解码的业务参数之后注入它。SRC 识别这个保留的末位参数名,严格生成还要求它具有全局 `AbortSignal` 类型。 + +## Client 服务:`ClientRemote`(ctx key:`remote`) + +`ctx.remote.$mount()` 会校验并注册生成的 Host-for-Client 贡献项,然后为发起调用的 Cordis fiber 安装具体的直接方法和作用域方法。每个 namespace 都是可追踪的 `remote.` 子 Service,并在最后一个方法撤回后卸载。重复端点、命名空间冲突,以及缺少生成的严格编解码器的描述符,都会在方法可调用前报错。 + +每次调用都会校验位置参数,构造与描述符完全匹配的具名 `args`,再通过 `ctx.connection.rpc.call('/api', endpoint, ...)` 发送。生成的支持取消的方法接受最后一个可选 `AbortSignal`;Client 会在调用 Connection 前将它与贡献项的挂载生命周期合并。返回值经过校验后才会交给应用代码。撤回贡献项会同时移除其描述符和方法、中止正在进行的调用,并使外部仍持有的方法句柄在调用时返回拒绝。 + +生成的声明合并通过共享的 `TypeRTClientRemote` 契约提供 TypeScript API。Client 入口不包含 Host 服务或 Host Cordis 接口合并;方法查找和调用使用普通对象与函数,而不使用 JavaScript Proxy。 + +## 模型体验 + +无,因为该包分发应用调用,不注册任何提示词、工具或会话事件。 + +#### KV Cache 影响 + +无直接影响;被调用的业务服务负责产生任何模型可见结果。 + +## 已知限制与延期工作 + +- 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/api/gateway/package.json b/packages/api/gateway/package.json new file mode 100644 index 0000000000..fa351d84bf --- /dev/null +++ b/packages/api/gateway/package.json @@ -0,0 +1,66 @@ +{ + "name": "@deepseek-ai/dsh-api-gateway", + "description": "TypeRT Remote Host dispatcher and Client API endpoint", + "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" + ], + "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/api/gateway/src/client/index.ts b/packages/api/gateway/src/client/index.ts new file mode 100644 index 0000000000..e49e9e5822 --- /dev/null +++ b/packages/api/gateway/src/client/index.ts @@ -0,0 +1,498 @@ +/** + * Client projection of generated TypeRT Remote descriptors. Contributions + * install traced `remote.` services; no JavaScript Proxy + * participates in method 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, + TypeRTClientRemote, + TypeRTCodec, + TypeRTDisposer, + TypeRTRemoteContribution, +} from '@deepseek-ai/dsh-type-meta' + +interface MountToken { + active: boolean + readonly abort: AbortController +} + +interface ScopedProjection { + readonly context: string + readonly wire: string + readonly codec: TypeRTCodec + readonly parameterIndex?: number +} + +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 Remote namespaces selected by the Client assembly. */ + remote: ClientRemote + } +} + +/** Required Client services: the TypeRT registry and the existing Connection carrier. */ +export const inject = ['typert', 'connection'] + +/** + * Install the typed Client Remote service. + * @param ctx - Client Cordis root. + */ +export function apply(ctx: Context): void { + new ClientRemoteService(ctx) +} + +class ClientRemoteService extends Service implements TypeRTClientRemote { + private readonly ownerCtx: Context + private readonly namespaces = new Map() + private mutations = Promise.resolve() + + constructor(ctx: Context) { + super(ctx, 'remote') + this.ownerCtx = ctx + } + + 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) + const installed: TypeRTDisposer[] = [] + try { + for (const descriptor of contribution.descriptors) installed.push(await this.install(descriptor)) + } catch (error) { + for (const dispose of installed.reverse()) await dispose() + await disposeRemote() + throw error + } + return async () => { + for (const dispose of installed.reverse()) await dispose() + await 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 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`) + } + } + 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') + } + 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 async install(descriptor: InvocationDescriptor): Promise { + const token: MountToken = { active: true, abort: new AbortController() } + const installed: TypeRTDisposer[] = [] + try { + if (descriptor.invocation.kind === 'direct') { + installed.push(await this.installDirect(descriptor, token)) + } + const projection = scopedProjection(descriptor) + if (projection !== undefined) installed.push(await this.installScoped(descriptor, projection, token)) + } catch (error) { + token.active = false + token.abort.abort() + for (const dispose of installed.reverse()) await dispose() + throw error + } + return async () => { + /* v8 ignore next -- Cordis effect disposers are idempotent and invoke this cleanup at most once. */ + if (!token.active) return + token.active = false + token.abort.abort() + for (const dispose of installed.reverse()) await dispose() + } + } + + private async installDirect(descriptor: InvocationDescriptor, token: MountToken): Promise { + const namespace = await this.namespace(descriptor.namespace) + try { + namespace.service.installDirect(descriptor, token) + } catch (error) { + await this.disposeNamespace(descriptor.namespace, namespace) + throw error + } + return async () => { + namespace.service.remove('direct', descriptor.method, token) + await this.disposeNamespace(descriptor.namespace, namespace) + } + } + + private async installScoped( + descriptor: InvocationDescriptor, + projection: ScopedProjection, + token: MountToken, + ): 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 + } + return async () => { + namespace.service.remove('scoped', descriptor.method, token) + 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`) + const expected = descriptor.parameters.length - (projection?.parameterIndex === undefined ? 0 : 1) + 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 ${contract}, got ${String(values.length)}`, + ) + } + const args = Object.create(null) as Record + if (projection !== 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 = boundIdentity === undefined + ? binder?.identity(callerCtx) + : boundIdentity.value + 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 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') + } +} + +type InvokeRemote = ( + direct: DirectMethod | undefined, + scoped: ScopedMethod | undefined, + callerCtx: Context, + args: readonly unknown[], +) => Promise + +class RemoteNamespaceService extends Service { + private readonly methods = new Map() + private readonly namespace: string + + static assertMethodAvailable(namespace: string, method: string): void { + 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`) + } + } + + constructor( + ctx: Context, + name: string, + private readonly invokeRemote: InvokeRemote, + ) { + super(ctx, remoteServiceKey(name)) + this.namespace = name + } + + assertMethodAvailable(method: string): void { + 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`) + } + } + + 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 (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): 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 + if (kind === 'direct') delete record.direct + else delete record.scoped + if (record.direct !== undefined || record.scoped !== undefined) return + this.methods.delete(method) + Reflect.deleteProperty(this, method) + } +} + +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}` +} + +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/api/gateway/src/index.ts b/packages/api/gateway/src/index.ts new file mode 100644 index 0000000000..5899f5d560 --- /dev/null +++ b/packages/api/gateway/src/index.ts @@ -0,0 +1,638 @@ +/** + * Live TypeRT Remote dispatch over Cordis Services and registered providers. + * Transport, request correlation, and response envelopes belong to Connection. + * @module @deepseek-ai/dsh-api-gateway + */ + +import { Context, Service, symbols } from 'cordis' +import type { ConnectionRpcHandler } from '@deepseek-ai/dsh-client-connection' +import { + remoteMethods, + TypeRTLookupFailure, + type InvocationDescriptor, + type InvocationParameterDescriptor, + type TypeRTCodec, + type TypeRTGatewayBinding, +} 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 = Awaited> +type ConnectionRpcError = Extract['error'] +const NEVER_ABORTED_SIGNAL = new AbortController().signal + +/** 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'] + + 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', + 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 + 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) || 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 claims + } + + /** + * 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; lookup-policy and business errors retain 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 = await 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 = 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 + 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, signal) + } + + private async invokeRpc(endpoint: string, payload: unknown, signal: AbortSignal): Promise { + try { + const segments = endpoint.split('/') + 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 + || !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, + signal, + }) + 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 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 businessNames) { + const matches = this.ctx.typert.lookups.definitions() + .filter(definition => definition.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.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, + ...(cancellation === undefined ? {} : { cancellation }), + result: { mode: 'src-json' }, + } + } + + private async resolveReceiverContext( + descriptor: InvocationDescriptor, + args: Readonly>, + endpoint: string, + ): Promise { + 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 = await provider.resolve(identity) + } catch (cause) { + if (cause instanceof TypeRTLookupFailure) throw 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 async resolveParameter( + parameter: InvocationParameterDescriptor, + args: Readonly>, + endpoint: string, + ): Promise { + 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', + 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 = await provider.resolve(value) + } catch (cause) { + if (cause instanceof TypeRTLookupFailure) throw 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 { + if (error instanceof TypeRTLookupFailure) { + return { ok: false, error: error.failure as ConnectionRpcError } + } + 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) + /* 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()) + 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') value = 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)) { + 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') + } + 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/api/gateway/src/invariant.ts b/packages/api/gateway/src/invariant.ts new file mode 100644 index 0000000000..711c4edab5 --- /dev/null +++ b/packages/api/gateway/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * 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-api-gateway' + +/** Cordis companion plugin name. */ +export const name = '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/api/gateway/src/types.ts b/packages/api/gateway/src/types.ts new file mode 100644 index 0000000000..0917ba2ca6 --- /dev/null +++ b/packages/api/gateway/src/types.ts @@ -0,0 +1,54 @@ +/** + * Carrier-independent TypeRT Gateway request, service, and error contracts. + * @module @deepseek-ai/dsh-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> + /** 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. */ +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; lookup-policy and business errors retain identity. + */ + invoke(request: InvokeRemoteRequest): Promise +} + +declare module 'cordis' { + interface Context { + /** Host dispatcher for TypeRT Remote calls. */ + typertGateway: TypertGateway + } +} diff --git a/packages/api/gateway/tests/client.spec.ts b/packages/api/gateway/tests/client.spec.ts new file mode 100644 index 0000000000..641ea81ebc --- /dev/null +++ b/packages/api/gateway/tests/client.spec.ts @@ -0,0 +1,573 @@ +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' +import type { + InvocationDescriptor, + TypeRTClientRemote, + TypeRTContext, + TypeRTRemoteScopeApi, + 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 }, + signal?: AbortSignal, + ) => Promise<{ readonly ref: string }> + } + + interface TypeRTRemoteScopeMap { + 'fixture:goals/create': ( + request: { readonly objective: string }, + signal?: AbortSignal, + ) => Promise<{ readonly ref: string }> + 'fixture:goals/rename': (request: { readonly objective: string }) => Promise<{ readonly renamed: boolean }> + } + + interface TypeRTRemoteNamespaceMap { + goals: TypeRTRemoteNamespace<'goals'> + } + +} + +type FixtureContext = Omit & { + readonly remote: TypeRTClientRemote & TypeRTRemoteScopeApi<'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 }, + }], + cancellation: { parameter: 'signal' }, + 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) + const businessGoals = { owner: 'host business service' } + const disposeBusinessGoals = ctx.provide('goals', businessGoals) + const assembly = ctx.plugin(Object.assign( + (scope: Context) => scope.remote.$mount({ package: '@fixture/goals', descriptors: [directDescriptor()] }), + { inject: ['remote'] }, + )) + await assembly + const retained = ctx.remote.goals.create + + await expect(ctx.remote.goals.create('agent-1', { objective: 'ship' })).resolves.toEqual({ ref: 'goal-1' }) + expect(call).toHaveBeenCalledWith( + '/api', + 'goals/create', + { args: { agentId: 'agent-1', request: { objective: 'ship' } } }, + expect.any(AbortSignal), + ) + const callerAbort = new AbortController() + await expect(ctx.remote.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.remote.goals.create('', { objective: 'ship' })).rejects.toThrow('rejected "agentId"') + + call.mockResolvedValueOnce({ ok: true, value: { ref: 1 } }) + await expect(ctx.remote.goals.create('agent-1', { objective: 'ship' })).rejects.toThrow('rejected "result"') + + await assembly.dispose() + 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 () => { + 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.remote.$mount({ package: '@fixture/goals', descriptors: [directDescriptor()] }), + { inject: ['remote'] }, + )) + await assembly + + 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).remote.goals.create({ objective: 'wrong scope' })) + .rejects.toThrow('expected 2 business argument(s)') + + await assembly.dispose() + 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 () => { + 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.remote.$mount({ package: '@fixture/goals', descriptors: [contextDescriptor()] }), + { inject: ['remote'] }, + )) + await assembly + + 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).remote.goals.rename({ objective: 'land' })) + .rejects.toThrow('requires a "fixture" Context') + + await assembly.dispose() + expect(ctx.get('remote.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' }, + } + + 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' }], + })).rejects.toThrow('conflicts with the Remote service') + expect(ctx.typert.remotes.list()).toEqual([]) + }) + + it('rejects duplicate, live, scoped-service, and Context namespace collisions', async () => { + 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() + + await expect(ctx.remote.$mount({ + package: '@fixture/direct-duplicates', + descriptors: [direct, { ...direct, id: '@fixture/goals#goals/create-again' }], + })).rejects.toThrow('repeats direct method') + await expect(ctx.remote.$mount({ + package: '@fixture/scoped-duplicates', + descriptors: [context, { ...context, id: '@fixture/goals#goals/rename-again' }], + })).rejects.toThrow('repeats scoped method') + + 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' }], + })).rejects.toThrow('direct method goals/create is already mounted') + await disposeDirect() + + 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' }], + })).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' }], + })).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 }) + await expect(ctx.remote.$mount({ + package: '@fixture/service-own-property-conflict', + descriptors: [{ ...direct, id: '@fixture/goals#goals/custom', method: 'custom' }], + })).rejects.toThrow('conflicts with its namespace service') + Reflect.deleteProperty(scopedService, 'custom') + await disposeScoped() + + const disposeRemoteTypert = ctx.reflect.provide('remote.typert', { owner: 'fixture' }) + await expect(ctx.remote.$mount({ + package: '@fixture/context-property-conflict', + descriptors: [{ ...context, namespace: 'typert' }], + })).rejects.toThrow('conflicts with an existing Remote namespace') + await disposeRemoteTypert() + + const disposeMultipleScoped = await ctx.remote.$mount({ + package: '@fixture/multiple-scoped', + descriptors: [directDescriptor(), contextDescriptor()], + }) + await expect(agentCtx.remote.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() + }) + + 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 { + await expect(ctx.remote.$mount({ package: '@fixture/failing-batch', descriptors: [first, second] })) + .rejects.toThrow('fixture later-descriptor failure') + } finally { + spy.mockRestore() + } + + expect((ctx.remote as unknown as Record).goals).toBeUndefined() + await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) }) + 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() + }) + + 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() + const context = contextDescriptor() + await expect(ctx.remote.$mount({ + package: '@fixture/weak-parameter', + descriptors: [{ + ...direct, + parameters: direct.parameters.map((parameter, index) => index === 0 + ? { ...parameter, codec: { mode: 'src-json' } } + : parameter), + }], + })).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], + })).rejects.toThrow('has no strict codec') + await expect(ctx.remote.$mount({ + package: '@fixture/malformed-scope', + descriptors: [{ ...direct, scope: { context: 'fixture', wire: 'missingId' } }], + })).rejects.toThrow('scope must select its only lookup parameter') + await expect(ctx.remote.$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 }, + }], + }], + })).rejects.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 = await ctx.remote.$mount({ + package: '@fixture/goals', + descriptors: [descriptor, contextDescriptor()], + }) + 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).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.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.remote.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 = 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.remote as unknown as Record).goals).toBeUndefined() + }) + + 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 + const getWithdrawn = Object.getOwnPropertyDescriptor(namespace, 'create')?.get?.bind(namespace) + + await dispose() + + expect(getWithdrawn).toBeTypeOf('function') + const withdrawn = getWithdrawn?.() as (...args: unknown[]) => Promise + 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 () => { + 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 = await ctx.remote.$mount({ package: '@fixture/prototype', descriptors: [descriptor] }) + + 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() + expect(Object.hasOwn(payload.args, '__proto__')).toBe(true) + expect(payload.args.__proto__).toBe('wire-value') + await dispose() + }) + + 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 === Service.tracker) throw new Error('fixture namespace startup failure') + return defineProperty(target, key, attributes) + }) + try { + 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 = await ctx.remote.$mount({ package: '@fixture/goals-retry', descriptors: [directDescriptor()] }) + expect(ctx.remote.goals.create).toBeTypeOf('function') + 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 { + await expect(ctx.remote.$mount({ + package: '@fixture/direct-method-failure', + descriptors: [directDescriptor()], + })).rejects.toThrow('fixture direct method installation failure') + } finally { + spy.mockRestore() + } + + expect((ctx.remote as unknown as Record).goals).toBeUndefined() + await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) }) + const retry = await ctx.remote.$mount({ + package: '@fixture/direct-method-retry', + descriptors: [directDescriptor()], + }) + expect(ctx.remote.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 { + await expect(ctx.remote.$mount({ package: '@fixture/scoped-failure', descriptors: [contextDescriptor()] })) + .rejects.toThrow('fixture scoped installation failure') + } finally { + spy.mockRestore() + } + + expect(ctx.get('remote.goals')).toBeUndefined() + await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) }) + 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 = await ctx.remote.$mount({ package: '@fixture/scoped', descriptors: [contextDescriptor()] }) + expect(ctx.get('remote.goals')).toBeDefined() + + await dispose() + + expect(ctx.get('remote.goals')).toBeUndefined() + const replacement = { owner: '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 })) + await ctx.remote.$mount({ package: '@fixture/goals', descriptors: [directDescriptor()] }) + + let failure: unknown + try { + await ctx.remote.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/api/gateway/tests/gateway.spec.ts b/packages/api/gateway/tests/gateway.spec.ts new file mode 100644 index 0000000000..4fa0ea80ad --- /dev/null +++ b/packages/api/gateway/tests/gateway.spec.ts @@ -0,0 +1,1317 @@ +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, + RemoteScope, + TypeRTLookupFailure, + 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-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[] = [] + lastSignal: AbortSignal | undefined + nextResult: unknown = undefined + businessError: Error | undefined + + constructor(ctx: Context) { + super(ctx, 'goals') + } + + @Remote + create(agent: FixtureAgent, request: { readonly title: string }, signal: AbortSignal): unknown { + this.calls.push('create') + this.lastSignal = signal + return { + agentId: agent.id, + title: request.title, + scope: (this.ctx as MarkedContext).fixtureScope ?? 'root', + } + } + + @RemoteScope('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: string; 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 + matches: ((endpoint: string) => boolean) | undefined + handler: FakeRpcHandler | undefined + + constructor(ctx: Context) { + super(ctx, 'connection') + } + + get rpc() { + const owner = this.ctx + return { + 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 + } + }), + } + } +} + +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 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' }) + + constructor(ctx: Context) { + super(ctx, 'wrongBinding') + } + + @Remote + run(value: string): string { + return value + } +} + +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') + } + + @RemoteScope('gatewayFixture') + run(agentId: string): string { + return agentId + } +} + +class NoBindingService extends Service { + constructor(ctx: Context) { + super(ctx, 'noBinding') + } + + run(value: string): string { + return value + } +} + +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' }) + + 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() + const agent = { id: 'agent-1' } + 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 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)) + 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, 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 () => { + 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 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)) + + await expect(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'rename', + args: { agentId: 'agent-1', request: { title: 'land' } }, + })).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' } + 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('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()]) + 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: async () => { 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() + + const missing = ctx.typert.lookups.register('gatewayFixture', { + ...agentLookup({ id: 'agent-1' }), + 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 () => { + 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'] } }, + { plugin: NonFinalSignalService, namespace: 'invalid-signal', args: { value: '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') + await expectCode(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'create', + args: [] as unknown as Record, + }), '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('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, + 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('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', { + ...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('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') + 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 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) + const gatewayFiber = ctx.plugin(TypertGatewayService) + await gatewayFiber + await ctx.plugin(GoalService) + const connection = rawConnection(ctx) + 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 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', { + args: { agentId: 'agent-1', request: { title: 'ship' } }, + }, signal)).resolves.toEqual({ + 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, + 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/) + + 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') + } + + 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() + }) + + 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) + 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[] = [] + 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()]) + let strictActive = true + expect(routes).toHaveLength(1) + const server = await serveRoute(routes[0]!) + + try { + const response = await fetch(`${server.origin}/api/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' }, + }, + }) + + 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) + const invalidBody = await invalid.json() as unknown + expect(invalidBody).toMatchObject({ + type: 'server-response', + rpcId: 'rpc-invalid', + result: { + ok: false, + error: { code: 'internal' }, + }, + }) + expect(JSON.stringify(invalidBody)).toContain('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) + const withdrawnBody = await withdrawn.json() as unknown + expect(withdrawnBody).toMatchObject({ + type: 'server-response', + rpcId: 'rpc-withdrawn', + result: { + ok: false, + 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) + } finally { + await server.close() + if (strictActive) 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()), + })), + }, + ], + cancellation: { parameter: 'signal' }, + 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/api/gateway/tsconfig.json b/packages/api/gateway/tsconfig.json new file mode 100644 index 0000000000..fea39663f7 --- /dev/null +++ b/packages/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/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..82947331c5 --- /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: 7f6a2114d900413d972584c0f1c141b7f835ba36 +README.zh.md: cce263747d696570f362811556fa6f5c0be0a0f5 diff --git a/packages/api/remotes/README.md b/packages/api/remotes/README.md new file mode 100644 index 0000000000..7f6a2114d9 --- /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.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, 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.remote` 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..cce263747d --- /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.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` 负责描述符校验、可追踪 namespace Service、直接与作用域方法、调用与取消。Client 入口通过 Cordis 消费共享的 `TypeRTClientRemote` 接口,不导入具体 Gateway。 + +本包不包含传输逻辑或 Host 服务发现逻辑。Web 或未来的 TUI 只要提供同一份不依赖 React 的 `ctx.remote` 契约,均可复用其 Client face。 + +## 模型体验 + +无,因为该 BFF 只选择 Remote 应用方法和身份策略,不注册任何模型接口。 + +#### KV Cache 影响 + +无直接影响;其触发的任何模型可见行为均由已挂载的 Host 能力负责。 + +## 已知限制与暂缓事项 + +- 能力集合由构建时显式导入的值固定确定;Client 不会在运行时发现 Host 中已启用的服务或 Remote 定义。 +- 若要增加能力,必须显式导入相应的 `/remote` 值并在此组合中挂载。 +- 在剩余 BFF 配置迁移到 `api-remotes` 之前,标准 Web Host 仍从旧 API Proxy 提供恢复默认值与 Agent scope 设置。 diff --git a/packages/api/remotes/package.json b/packages/api/remotes/package.json new file mode 100644 index 0000000000..0a1e3ec71d --- /dev/null +++ b/packages/api/remotes/package.json @@ -0,0 +1,64 @@ +{ + "name": "@deepseek-ai/dsh-api-remotes", + "description": "Remote BFF assembly and Host Agent/Session lookup policy", + "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-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" + ], + "dependencies": { + "@deepseek-ai/dsh-type-meta": "workspace:^" + }, + "peerDependencies": { + "@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-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..71d7a76379 --- /dev/null +++ b/packages/api/remotes/src/agent-lookup.ts @@ -0,0 +1,194 @@ +/** 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 { + /** 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 +} + +/** 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) + typeCtx.typert.contexts.configureHost('agent', async sessionId => (await resolveAgent(sessionId)).ctx) + }) + + return agentFor +} diff --git a/packages/api/remotes/src/client/index.ts b/packages/api/remotes/src/client/index.ts new file mode 100644 index 0000000000..be92b02d77 --- /dev/null +++ b/packages/api/remotes/src/client/index.ts @@ -0,0 +1,27 @@ +/** Platform-neutral assembly of generated Host Remote contributions. */ + +import type { Context } from 'cordis' +import goalsRemote from '@deepseek-ai/dsh-goal/remote' +import type { TypeRTClientRemote } 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 Remote namespaces selected by this Client assembly. */ + remote: TypeRTClientRemote + } +} + +/** 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. + * @returns disposer after every selected Remote namespace is ready. + */ +export async function apply(ctx: Context): Promise<() => Promise> { + return await ctx.remote.$mount(goalsRemote) +} 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/api/remotes/src/invariant.ts b/packages/api/remotes/src/invariant.ts new file mode 100644 index 0000000000..3310bed11f --- /dev/null +++ b/packages/api/remotes/src/invariant.ts @@ -0,0 +1,24 @@ +/** 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-api-remotes' + +/** Cordis companion plugin name. */ +export const name = 'api-remotes-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** No runtime invariant: TypeRT and the Agent/Session registries own the observed relationships. */ +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/api/remotes/tests/agent-lookup.spec.ts b/packages/api/remotes/tests/agent-lookup.spec.ts new file mode 100644 index 0000000000..7179f5b2b2 --- /dev/null +++ b/packages/api/remotes/tests/agent-lookup.spec.ts @@ -0,0 +1,154 @@ +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' +import { TypeRTLookupFailure } from '@deepseek-ai/dsh-type-meta' +import TypertRegistry from '@deepseek-ai/dsh-typert-registry' + +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(TypertRegistry) + 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() + } + }) + + 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/api/remotes/tests/built-lib.e2e.ts b/packages/api/remotes/tests/built-lib.e2e.ts new file mode 100644 index 0000000000..af584cba7f --- /dev/null +++ b/packages/api/remotes/tests/built-lib.e2e.ts @@ -0,0 +1,224 @@ +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 shared `/api` 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/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/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))) + +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/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/api/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 || 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() + 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-api-gateway', + '@deepseek-ai/dsh-api-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.remote.goals.create(rootAgent.id, { objective: 1 }) + } catch { + invalidRejected = true + } + 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.remote.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, + 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 } } + rootEdit: { objective: 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 } }, + rootEdit: { objective: 'edited root goal', revision: 2 }, + scopedResult: { ref: { revision: 1 } }, + rootGoal: 'edited root goal', + scopedGoal: 'scoped goal', + rootEvents: 2, + 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/api/remotes/tsconfig.json b/packages/api/remotes/tsconfig.json new file mode 100644 index 0000000000..148804dc0f --- /dev/null +++ b/packages/api/remotes/tsconfig.json @@ -0,0 +1,42 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../core/session" + }, + { + "path": "../../session-persistence/session-persistence" + }, + { + "path": "../../typert/type-meta" + }, + { + "path": "../../typert/registry" + }, + { + "path": "../../ui/commands" + }, + { + "path": "../../goal/goal" + }, + { + "path": "../../session-title/session-title" + }, + { + "path": "../../support/invariants" + } + ] +} 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 f0577552cc..11b23c27be 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-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..9895d8834c 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-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/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 681f0d5121..dc3212a36c 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: 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 29eeb24009..4e2d9cf70b 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-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/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/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 31747bb311..776d21fd46 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -35,10 +35,12 @@ 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' +import type { ClientConnectionRpc } from '../rpc.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[] { @@ -1328,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' }, @@ -1506,31 +1518,141 @@ 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 }, - next: (current: FxGoalProjection) => FxGoalProjection['goal'] | undefined, - ): Promise> => { - const missing = requireSession(request) + 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 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(), + }) + return { ok: true, value: tombstone } + }, + } + + /** 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 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') } + 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 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 }>() @@ -1776,7 +1898,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) => { @@ -2333,60 +2455,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) { @@ -2547,6 +2653,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 } } /** @@ -2558,10 +2694,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 67b47b06c6..c2a6668d46 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 { 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 @@ -69,11 +74,14 @@ 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 fixtureClient = fixture ? new FixtureApiClient() : undefined + const api: IApiClient = fixtureClient ?? new WebApiClient() + const rpc = fixtureClient?.rpc ?? 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/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 new file mode 100644 index 0000000000..f8bacb1553 --- /dev/null +++ b/packages/client/connection/src/client/rpc.ts @@ -0,0 +1,63 @@ +/** 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' +import { randomUuid } from './random-uuid.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(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 + }, + } +} + +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.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..cdf8d12bfe 100644 --- a/packages/client/connection/src/http-bridge.ts +++ b/packages/client/connection/src/http-bridge.ts @@ -5,6 +5,16 @@ import type { IncomingMessage, ServerResponse } from 'node:http' +/** 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 +} + /** * Bridge one node:http request to the fetch-shaped handler (client close * aborts; SSE bodies stream out chunk by chunk). @@ -12,7 +22,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/index.ts b/packages/client/connection/src/index.ts index 2e27a78d70..aefdcdadf4 100644 --- a/packages/client/connection/src/index.ts +++ b/packages/client/connection/src/index.ts @@ -7,15 +7,26 @@ 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, + ConnectionRpcEndpointMatcher, + 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; API Proxy is an optional `/api` fallback. */ +export const inject = ['httpServer'] /** Plugin config: the deployment's non-loopback serving authorities. */ export interface ConnectionConfig { @@ -83,49 +94,61 @@ 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 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) => { - 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) { + if (!isTrustedApiRequest(req, trustedHosts)) { 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) + await bridge(req, res, fetchHandler) }, } 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) - return - } - return handle(req, socket, head) - }, - }), `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) }) + ctx.inject(['apiProxy'], (apiCtx) => { + const downlinks = new WebSocketDownlinks(apiCtx.apiProxy) + 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..7d3e5ff6f5 --- /dev/null +++ b/packages/client/connection/src/rpc-host.ts @@ -0,0 +1,224 @@ +/** 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 RpcErrorDetailsMap, + type RpcId as RpcIdType, + type ServerResponse as RpcServerResponse, +} from '@deepseek-ai/dsh-host-apiproxy/api' +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, + 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_$.-]+$/ + +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. + * @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), + 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) + }, + } + } + + 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`, + ) + } + + 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, +): FetchHandler { + return { + 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 }) + } + + 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) { + return invalidEnvelopeResponse(body, 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 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.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..e1260f00e8 --- /dev/null +++ b/packages/client/connection/src/rpc.ts @@ -0,0 +1,77 @@ +/** 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> + +/** 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 `/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. + */ + handle( + channel: string, + 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. */ +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 `/api`. + * @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..9d9bbd2f26 100644 --- a/packages/client/connection/tests/client-apply.spec.ts +++ b/packages/client/connection/tests/client-apply.spec.ts @@ -203,4 +203,119 @@ describe('connection client apply', () => { expect(sockets).toHaveLength(1) expect(sockets[0]?.readyState).toBe(FakeWebSocket.CLOSED) }) + + 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 }[] = [] + 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('/api', 'goals/create', { args: { agentId: 'agent-1' } })) + .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' } }, + }) + }) + + 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('/api', 'goals/create', {}, abort.signal)) + .rejects.toThrow('HTTP 503') + expect(globalThis.fetch).toHaveBeenCalledWith( + new URL('https://harness.example/api/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('/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/api/goals/create')) + expect(fetch.mock.calls[0]?.[1]).not.toHaveProperty('signal') + } finally { + globalThis.fetch = original + } + + for (const [channel, endpoint] of [ + ['api2', 'goals/create'], + ['/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') + } + }) + + 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() + 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/) + }) }) 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 3015881d2f..59ab8e6102 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,32 @@ 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 +} + +/** 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 } = {} + 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 +194,211 @@ describe('connection node half', () => { expect(declared.state.status).toBe(404) await dispose() }) + + 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(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('/rpc', async (endpoint, payload) => { + calls.push({ endpoint, payload }) + return { ok: true, value: { accepted: true } } + }, { authority: 'trusted-host' }) + const route = routes.find(candidate => candidate.path === '/rpc') + expect(route).toBeDefined() + + const request: ClientRequest = { + type: 'client-request', + 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' }, '/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-dedicated', + result: { ok: true, value: { accepted: true } }, + }) + expect(calls).toEqual([{ + endpoint: 'goals/create', + payload: { args: { agentId: 'agent-1' } }, + }]) + + 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() + }) + + 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('/rpc', async (endpoint) => { + if (endpoint === 'fail') throw new Error('handler broke') + return { ok: true, value: null } + }, { + authority: 'trusted-host', + }) + const route = routes.find(candidate => candidate.path === '/rpc')! + + const denied = fakeResponse() + 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' }, '/rpc/goals/create', { + type: 'client-request', rpcId: 'rpc-bad', method: 'other', payload: {}, + }), 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' }, '/rpc/goals/create'), 404], + [fakePost({ host: 'harness.example' }, '/outside/goals/create', {}), 404], + [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) + 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' }, '/rpc/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' }, '/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' }) + + 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() + }) }) describe('connection node half over a real HTTP server', () => { diff --git a/packages/client/runtime/package.json b/packages/client/runtime/package.json index b64a4de0ec..f507cecf21 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-api-remotes", + "@deepseek-ai/dsh-typert-registry" ], "platform": "web", "immediately": true @@ -48,11 +50,17 @@ }, "peerDependencies": { "@deepseek-ai/dsh-invariants": "^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-api-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", "schemastery": "^3.18.0" diff --git a/packages/client/runtime/src/client/agents/scope.ts b/packages/client/runtime/src/client/agents/scope.ts index af6fa3afcd..25644d24ba 100644 --- a/packages/client/runtime/src/client/agents/scope.ts +++ b/packages/client/runtime/src/client/agents/scope.ts @@ -18,6 +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 { 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 & TypeRTRemoteScopeApi<'agent'> +} /** Context tag written by {@link createScope}. */ const kScope = Symbol('dsh.client.scope') @@ -29,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 + ctx: AgentContext /** Backing fiber (dispose tears down every scope-owned registration). */ fiber: Fiber } @@ -48,15 +54,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 AgentContext 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..2af2ef51c8 100644 --- a/packages/client/runtime/src/client/contract/sessions.ts +++ b/packages/client/runtime/src/client/contract/sessions.ts @@ -12,6 +12,7 @@ import type { RpcResult, SessionId, SubagentAddress, } from '@deepseek-ai/dsh-client-connection/client' 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, @@ -19,6 +20,8 @@ import type { import type { SessionFace } from './session.ts' import type { ObservableSnapshot } from './store.ts' +export type { AgentContext } from '../agents/scope.ts' + /** 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 +98,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 04748b445e..c2b4409c33 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-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' import { SessionsService } from './sessions/service.ts' @@ -28,7 +30,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, @@ -77,6 +79,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 @@ -172,8 +181,8 @@ declare module 'cordis' { } } -/** Required services: the wire handle mounted by the connection plugin. */ -export const inject = ['connection'] +/** 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. @@ -182,6 +191,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 e5691a0619..b700c4c066 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,17 +23,22 @@ 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 = { api, isLoopback: true, + rpc: { + call: () => Promise.reject(new Error('unexpected generic RPC call')), + }, start: (sinks) => { bench.sinks = sinks return { stop: () => { bench.stopped += 1 } } }, } ctx.reflect.provide('connection', handle) + ctx.reflect.provide('remote', {}) 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 21e7f1fc06..dfafcd07aa 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,17 +17,22 @@ 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 = { api, isLoopback: true, + rpc: { + call: () => Promise.reject(new Error('unexpected generic RPC call')), + }, start: (sinks) => { bench.sinks = sinks return { stop: () => {} } }, } ctx.reflect.provide('connection', handle) + ctx.reflect.provide('remote', {}) await ctx.plugin(RuntimeClient).await() return bench } diff --git a/packages/client/runtime/tsconfig.json b/packages/client/runtime/tsconfig.json index 4357200479..2a1ea33b29 100644 --- a/packages/client/runtime/tsconfig.json +++ b/packages/client/runtime/tsconfig.json @@ -23,6 +23,9 @@ { "path": "../schema-form" }, + { + "path": "../../api/remotes" + }, { "path": "../../host/apiproxy" }, @@ -46,6 +49,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/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/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index db4717fc14..a00f3eaebb 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: f59ea54f56318cf10381a3f4e6754e20a2866bab -README.zh.md: 621e6ba5a97501dddaad8168db4b20e95510bf51 +README.md: bf4cc11fa8e5d512f6cd802321dd835135af03f4 +README.zh.md: 2dc09eb4a9408dde615bd2fc982e01291de3840b diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index f59ea54f56..bf4cc11fa8 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 621e6ba5a9..2dc09eb4a9 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-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index 9ba5ed3876..df107d2cd2 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', diff --git a/packages/client/ui-conversation/tests/apply-inject.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.spec.tsx index 6868422eb2..c6eb5407f9 100644 --- a/packages/client/ui-conversation/tests/apply-inject.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.spec.tsx @@ -47,7 +47,7 @@ function sessionFakeFor() { async function bench() { const runtime = await SlotTestRuntime.create() - runtime.provide('connection', { api: { settings: {} }, isLoopback: false }) + runtime.provide('connection', { api: { settings: {} }, isLoopback: false } as never) const sessionFake = sessionFakeFor() await runtime.sessions.add({ id: ROOT, diff --git a/packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx b/packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx index d6c8a8d106..0306b4e546 100644 --- a/packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx +++ b/packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx @@ -96,7 +96,7 @@ function WorkspaceProbe({ open }: EmptyWorkspaceOwnerProps) { async function bench(nodes: ToolResultNode[], opts?: { blank?: boolean }) { const runtime = await SlotTestRuntime.create() - runtime.provide('connection', { api: { settings: {} }, isLoopback: false }) + runtime.provide('connection', { api: { settings: {} }, isLoopback: false } as never) runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() }) const locale = new LocaleService(runtime.ctx) runtime.provide('locale', locale) @@ -184,7 +184,7 @@ describe('terminal card assembly', () => { describe('resident composer', () => { it('renders the locked view state while no session exists at all', async () => { const runtime = await SlotTestRuntime.create() - runtime.provide('connection', { api: { settings: {} }, isLoopback: false }) + runtime.provide('connection', { api: { settings: {} }, isLoopback: false } as never) runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() }) const locale = new LocaleService(runtime.ctx) runtime.provide('locale', locale) @@ -203,7 +203,7 @@ describe('resident composer', () => { it('keeps the complete Hero tree mounted when the first Workspace session appears', async () => { const runtime = await SlotTestRuntime.create() - runtime.provide('connection', { api: { settings: {} }, isLoopback: false }) + runtime.provide('connection', { api: { settings: {} }, isLoopback: false } as never) runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() }) const locale = new LocaleService(runtime.ctx) runtime.provide('locale', locale) @@ -273,7 +273,7 @@ describe('resident composer', () => { describe('prompt rejection through the assembled composer', () => { it('renders the promptError alert strip and keeps the draft in the machine', async () => { const runtime = await SlotTestRuntime.create() - runtime.provide('connection', { api: { settings: {} }, isLoopback: false }) + runtime.provide('connection', { api: { settings: {} }, isLoopback: false } as never) runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() }) const locale = new LocaleService(runtime.ctx) runtime.provide('locale', locale) diff --git a/packages/client/ui-conversation/tests/chat-apply.spec.tsx b/packages/client/ui-conversation/tests/chat-apply.spec.tsx index 1a906235c8..45b62d453e 100644 --- a/packages/client/ui-conversation/tests/chat-apply.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-apply.spec.tsx @@ -24,7 +24,7 @@ const CHILD = 'child-1' as SessionId async function bench() { const runtime = await SlotTestRuntime.create() - runtime.provide('connection', { api: { settings: {} }, isLoopback: false }) + runtime.provide('connection', { api: { settings: {} }, isLoopback: false } as never) await runtime.sessions.add({ id: ROOT, summary: { title: 'R', displayTitle: 'R' } }, { current: false }) await runtime.sessions.add( { id: CHILD, summary: { title: 'C', displayTitle: 'C', parentId: ROOT } }, { current: false }) diff --git a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx index 12e4011964..62dd178864 100644 --- a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx @@ -62,7 +62,7 @@ const LAYOUT_CHILDREN = { */ async function bench(nodes: ToolResultNode[]) { const runtime = await SlotTestRuntime.create() - runtime.provide('connection', { api: { settings: {} }, isLoopback: false }) + runtime.provide('connection', { api: { settings: {} }, isLoopback: false } as never) const layout = { openDetails: vi.fn(), closeDetails: vi.fn() } runtime.provide('layout', layout) const locale = new LocaleService(runtime.ctx) @@ -194,7 +194,7 @@ describe('keyed toolview hole through the real machinery', () => { describe('registrant declaration injection', () => { it('runs the plugin before ui-conversation and waits on the actual toolview declaration', async () => { const runtime = await SlotTestRuntime.create() - runtime.provide('connection', { api: { settings: {} }, isLoopback: false }) + runtime.provide('connection', { api: { settings: {} }, isLoopback: false } as never) runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() }) const locale = new LocaleService(runtime.ctx) runtime.provide('locale', locale) diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index bcd8f25e73..ea35284ad8 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -255,7 +255,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() }) }) @@ -351,7 +351,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 @@ -375,7 +375,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', () => { @@ -400,7 +400,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() }) @@ -418,7 +418,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() }) diff --git a/packages/client/ui-goal/README.i18n.yaml b/packages/client/ui-goal/README.i18n.yaml index 5120d720cd..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: 0ea00b8bf9b07f02b5df0f7b3e7d3d9c6f109fde -README.zh.md: 70bf443118e5d2b1ce46e7bc1479bf932507b3f9 +README.md: a53fb3a89eaee364cb025ca728ca42ce934887b0 +README.zh.md: 1ad9f50aee5b103f6455e4d4b7d29fa9eb29a108 diff --git a/packages/client/ui-goal/README.md b/packages/client/ui-goal/README.md index 0ea00b8bf9..a53fb3a89e 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.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. ## 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..1ad9f50aee 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.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` 组件与注入动词面类型。 ## 模型体验 -间接影响:条带动词提交的 `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..63410707a0 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-api-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-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", @@ -48,8 +49,8 @@ "react": "^18.2.0" }, "devDependencies": { - "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "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 6ee340715c..2c041e0eae 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.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' // 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', 'remote', 'remote.goals', '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, including synchronous namespace lookup failures, onto the strip's inline-render shape. */ +async function settle(invoke: () => Promise): Promise { + try { + await invoke() + 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,8 +70,6 @@ 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 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). */ @@ -77,22 +94,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(() => ctx.remote.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(() => ctx.remote.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(() => ctx.remote.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(() => 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 98d5a0291a..756968136e 100644 --- a/packages/client/ui-goal/tests/browser-plugin.spec.tsx +++ b/packages/client/ui-goal/tests/browser-plugin.spec.tsx @@ -1,16 +1,16 @@ // @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. */ -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' @@ -44,27 +44,45 @@ 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 }), - } } }) + 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: ReturnType | undefined = goals('goals') + 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({ name: 'root', children: { 'conversation.input.dock': { kind: 'list', scope: 'session' } }, @@ -85,6 +103,8 @@ async function bench(options: { projection?: GoalProjection | null | undefined; ctx, 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 @@ -113,12 +133,34 @@ 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('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('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 () => { @@ -133,13 +175,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..263dfceb26 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": "../../api/remotes" }, { "path": "../runtime" 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 a82ea083e3..024382fa79 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) @@ -66,13 +70,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/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/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 2bf1c1b2d9..b7fd6d3c5a 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,9 +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 */', + }, ], }, { @@ -1118,11 +1122,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 +1154,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; lookup-policy and business errors retain identity.\n */', + }, + ], + }, { key: 'userInteraction', summary: '`ctx.userInteraction`: one active UI provider plus an `ask()` surface.', @@ -1849,6 +1863,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}', @@ -2057,6 +2075,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 cancellation?: {\n readonly parameter: \'signal\';\n };\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 readonly signal?: AbortSignal;\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 +3071,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 +3119,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/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/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..6667463d86 100644 --- a/packages/goal/goal/src/index.ts +++ b/packages/goal/goal/src/index.ts @@ -5,13 +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 { 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 { @@ -27,22 +28,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 @@ -178,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({ @@ -271,6 +273,7 @@ export class GoalService extends Service { * @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) @@ -292,6 +295,7 @@ export class GoalService extends Service { * @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') } @@ -303,6 +307,7 @@ export class GoalService extends Service { * @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) @@ -328,6 +333,7 @@ export class GoalService extends Service { * @param ref - expected current revision. * @returns the completed view. */ + @Remote('complete') complete(agent: Agent, ref: GoalRef): GoalView { return this.transition( agent, @@ -367,6 +373,7 @@ export class GoalService extends Service { * @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) @@ -568,6 +575,18 @@ 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. + */ + @Remote('create') + remoteExportCreate(agent: Agent, request: CreateGoalRequest): CreateGoalResult { + const view = this.create(agent, request) + return { ref: { id: view.id, revision: view.revision } } + } } 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. diff --git a/packages/goal/goal/tests/goal.spec.ts b/packages/goal/goal/tests/goal.spec.ts index 38eea7cf61..3c642d2a8c 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('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.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 }) + 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/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/packages/host/apiproxy/package.json b/packages/host/apiproxy/package.json index 426860eebc..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:^", @@ -69,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 5d8462a26d..caaf1be7a5 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -69,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. */ @@ -663,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( @@ -749,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. */ @@ -808,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) @@ -989,116 +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: {} } } - } - } - type SessionReadState = { id: SessionId header: SessionHeader 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/host/apiproxy/tests/api-proxy-cold.spec.ts b/packages/host/apiproxy/tests/api-proxy-cold.spec.ts index 4b6337ede8..8e79c641a8 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, { 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) + }) + 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, { 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) + }) + 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..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" }, 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' }) 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..c5d89b3726 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.Node +} + 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,903 @@ 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 GatewayService or 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[] = [] + let cancellation: InvocationModel['cancellation'] + const wires = new Set() + for (const [parameterIndex, parameter] of method.parameters.entries()) { + 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 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 + 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 Scope ${invocation.context} has no TypeRTContextMap entry`) + } + const wire = `${invocation.context}Id` + if (wires.has(wire)) this.fail(method, `Remote Scope 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, + ...(cancellation === undefined ? {} : { cancellation }), + result: this.remoteBoundary( + resultType, + `${registration.name}#${binding.namespace}/${exportedMethod}:result`, + false, + ), + location: this.location(method.name), + } + } + + 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 + 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') + } + 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, 'Gateway service key must be a string literal') + const service = stringLiteralValue(serviceArgument) + 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) { + 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 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 } + } + + 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 string literal containing only RPC endpoint segment characters') + } + marker = { kind: 'direct', exportName } + } else if (ts.isCallExpression(expression) + && this.isTypeMetaSymbol(expression.expression, 'RemoteScope')) { + if (expression.arguments.length < 1 || expression.arguments.length > 2) { + this.fail(expression, 'RemoteScope() requires a Context key and optional exported method name') + } + const context = stringLiteralValue(expression.arguments[0]) + if (context === undefined || !isRemoteSegment(context)) { + this.fail(expression.arguments[0] ?? expression, 'RemoteScope() key must be a string literal containing only RPC endpoint segment characters') + } + const exportArgument = expression.arguments[1] + const exportName = exportArgument === undefined ? undefined : stringLiteralValue(exportArgument) + if (exportArgument !== undefined && (exportName === undefined || !isRemoteSegment(exportName))) { + this.fail(exportArgument, 'RemoteScope() name must be a string literal containing only RPC endpoint segment characters') + } + 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 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() + 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 contain only RPC endpoint segment characters') + 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 contain only RPC endpoint segment characters') + 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) + 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) + } + } + } + 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, + 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 resolvedType = this.checker.getTypeFromTypeNode(authoredType) + this.assertRemoteJsonType(resolvedType, authoredType, new Set(), false) + 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(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( + 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 +1940,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 +1975,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 +1999,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 +2516,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 === 'RemoteScope') return true + } + } + } if (!ts.isModuleDeclaration(statement) || !ts.isStringLiteral(statement.name) || statement.name.text !== 'cordis' @@ -1588,6 +2550,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 +2562,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 +2633,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 +2791,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 +2803,28 @@ 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 { + // 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 { + 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..cbed0047c3 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,250 @@ 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(' ],') + if (invocation.cancellation !== undefined) { + lines.push(" cancellation: { parameter: 'signal' },") + } + 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 TypeRTRemoteScopeMap {') + 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 keyLength = signature.indexOf(': (') + if (keyLength < 0) throw new TypertEmitError(`Remote signature ${invocation.id} has no property delimiter`) + this.pushMappedRemoteSignature(lines, sourceMap, packageModel, invocation, signature, keyLength) + } + + private pushRemoteNamespaceSignature( + lines: string[], + sourceMap: GenMapping, + packageModel: PackageModel, + invocation: InvocationModel, + referenceNames: ReadonlyMap, + ): void { + const key = renderRemotePropertyName(invocation.method) + const signature = `${key}: ${this.remoteFunctionType(invocation, referenceNames, false)}` + this.pushMappedRemoteSignature(lines, sourceMap, packageModel, invocation, signature, key.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) + 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 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)}`) + if (invocation.cancellation !== undefined) parameters.push('signal?: AbortSignal') + 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 +496,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 +520,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 +535,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 +636,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 +664,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 +682,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 +798,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 +834,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)}` } @@ -441,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/model.ts b/packages/typert/generator/src/model.ts index c6b7ffbc87..81bc6a91a1 100644 --- a/packages/typert/generator/src/model.ts +++ b/packages/typert/generator/src/model.ts @@ -94,6 +94,59 @@ 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 cancellation?: { + readonly parameter: 'signal' + } + readonly result: RemoteBoundaryModel + readonly location: SourceLocation +} + /** Business semantics discovered in one package on one face. */ export interface PackageModel { readonly name: string @@ -103,6 +156,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 +227,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..eca5ad47d2 100644 --- a/packages/typert/generator/src/tsdown-plugin.ts +++ b/packages/typert/generator/src/tsdown-plugin.ts @@ -1,37 +1,75 @@ /** - * 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. + * 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 { existsSync, mkdirSync, readFileSync, rmSync, 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. */ + 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. + * 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 lowers source decorators and emits local and Host-for-Client 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) { + 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 // 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 +78,63 @@ 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 }) + 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 } { + 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..4a303c4bd4 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,37 @@ 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', + } + 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 remoteFiles) { + 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..e84d6fd142 --- /dev/null +++ b/packages/typert/generator/tests/fixtures/remote-model/packages/remote/src/index.ts @@ -0,0 +1,33 @@ +import { GatewayService, Remote, RemoteScope } 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 extends GatewayService { + constructor() { + super(undefined, 'goals') + } + + @Remote + async create(agent: Agent, request: CreateGoalRequest, signal: AbortSignal): Promise { + signal.throwIfAborted() + return { ref: `${agent.id}:${request.title}` } + } + + @RemoteScope('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..707dc84ce9 --- /dev/null +++ b/packages/typert/generator/tests/fixtures/remote-model/type-meta.d.ts @@ -0,0 +1,64 @@ +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 TypeRTRemoteScopeMap {} + + 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 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, + 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 Remote(exportName: string): + ( + method: (this: This, ...args: Args) => Result, + context: ClassMethodDecoratorContext Result>, + ) => void + + export function RemoteScope(key: Extract, exportName?: string): + ( + 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..0e62a56bf4 --- /dev/null +++ b/packages/typert/generator/tests/remote-model.spec.ts @@ -0,0 +1,705 @@ +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 cancellation?: { readonly parameter: 'signal' } + 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' }, + }, + ], + cancellation: { parameter: 'signal' }, + 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, 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, signal?: AbortSignal) => 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?.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) + 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('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(" @RemoteScope('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('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', + 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, RemoteScope }', + 'import { GatewayService, Remote, RemoteScope, 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', + 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, 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', + }, + { + 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', + }, + { + 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) + + 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.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 Scope without a static Context declaration', () => { + const root = copyFixture() + editFile(root, 'packages/remote/src/index.ts', source => source.replace("@RemoteScope('agent')", "@RemoteScope('missing')")) + + 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', () => { + 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 extends GatewayService { + constructor() { + super(undefined, '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(sourceRoot = fixtureRoot): string { + const root = mkdtempSync(join(tmpdir(), 'dsh-typert-remote-model-')) + cpSync(sourceRoot, 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, + 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(sourceRoot) + 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, + TypeRTRemoteScopeMap, + 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: 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' }) +const renamed: Promise = rename({ ref: 'goal-1', title: 'land' }) +declare const ctx: { remote: TypeRTRemoteNamespaceMap } +const navigated: Promise = ctx.remote.goals.create('agent-1', { title: 'navigate' }) +void contribution +void created +void cancellable +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.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) + 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: { 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({ + 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, formatDiagnostics(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..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', @@ -12,6 +13,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 +27,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,11 +59,19 @@ 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 }) }) 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') @@ -80,9 +114,94 @@ 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('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' }) + 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..575d066e0d 100644 --- a/packages/typert/loader/src/index.ts +++ b/packages/typert/loader/src/index.ts @@ -135,6 +135,9 @@ 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}"`) } + for (const value of requireArray(pkgName, manifest.invocations, 'TYPERT.invocations')) { + requireInvocation(pkgName, value) + } return manifest as unknown as TypertContribution } @@ -184,6 +187,94 @@ 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.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`) + } + 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 +293,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 +370,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..750cc92e57 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 @@ -58,6 +60,33 @@ function typertSource(pkgName: string, entryName: string): string { ' face: \'host\',', ` schemas: [{ name: '${entryName}', schema: ${entryName} }],`, ' model: { services: [], events: [], objects: [] },', + ' invocations: [],', + '}', + '', + ].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 },`, + ' }],', + " cancellation: { parameter: 'signal' },", + ` result: { mode: 'strict', typeSymbol: '${pkgName}/types#Result', schema: Text },`, + ' sourceLocation: { file: \'src/index.ts\', line: 8, column: 3 },', + ' }],', '}', '', ].join('\n') @@ -69,6 +98,14 @@ async function boot(): Promise { 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 +142,34 @@ 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' }], + cancellation: { parameter: 'signal' }, + 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') @@ -200,6 +265,7 @@ describe('typert loader', () => { ' face: \'host\',', ' schemas: [{ name: \'Pending\', schema: Pending }],', ' model: { services: [], events: [], objects: [] },', + ' invocations: [],', '}', '', ].join('\n'), @@ -233,7 +299,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' }) @@ -348,6 +414,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') @@ -427,8 +494,169 @@ describe('validateTypertManifest', () => { model: { ...complete.model, objects: [{ ...complete.model.objects[0], exportName: '' }] }, })).toThrow('object has a missing or empty exportName') }) + + 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 = { ...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' }, + parameters: [{ + name: 'agent', + wire: 'agentId', + source: 'lookup', + lookup: 'agent', + codec: strictCodec('pkg#AgentId'), + }, ...descriptor.parameters], + } + expect(validateTypertManifest('pkg', { ...base, invocations: [scoped] }).invocations) + .toEqual([scoped]) + + expect(() => validateTypertManifest('pkg', { ...base, invocations: {} })) + .toThrow('TYPERT.invocations must be an array') + expect(() => validateTypertManifest('pkg', { + ...base, + invocations: [{ ...descriptor, invocation: { kind: 'future' } }], + })).toThrow('receiver kind must be "direct" or "context"') + expect(() => validateTypertManifest('pkg', { + ...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 } }], + })).toThrow('result codec is not backed by a zod v4 schema') + expect(() => validateTypertManifest('pkg', { + ...base, + invocations: [{ + ...descriptor, + parameters: [{ ...descriptor.parameters[0], source: 'future' }], + }], + })).toThrow('parameter source must be "json" or "lookup"') + expect(() => validateTypertManifest('pkg', { + ...base, + invocations: [{ + ...descriptor, + parameters: [{ ...descriptor.parameters[0], source: 'lookup' }], + }], + })).toThrow('lookup parameter has a missing or empty lookup') + expect(() => validateTypertManifest('pkg', { + ...base, + invocations: [{ + ...descriptor, + parameters: [{ ...descriptor.parameters[0], lookup: 'agent' }], + }], + })).toThrow('JSON parameter declares a lookup') + expect(() => validateTypertManifest('pkg', { + ...base, + invocations: [{ + ...descriptor, + parameters: [descriptor.parameters[0], { ...descriptor.parameters[0], name: 'again' }], + }], + })).toThrow('repeats wire field "request"') + expect(() => validateTypertManifest('pkg', { + ...base, + invocations: [{ + ...descriptor, + invocation: { + kind: 'context', + context: 'agent', + wire: 'request', + codec: strictCodec('pkg#AgentId'), + }, + }], + })).toThrow('repeats Context wire field "request"') + expect(() => validateTypertManifest('pkg', { + ...base, + invocations: [{ ...scoped, scope: null }], + })).toThrow('scope must be an object') + expect(() => validateTypertManifest('pkg', { + ...base, + invocations: [{ ...scoped, scope: { wire: 'agentId' } }], + })).toThrow('scope has a missing or empty context') + expect(() => validateTypertManifest('pkg', { + ...base, + invocations: [{ ...scoped, scope: { context: 'agent' } }], + })).toThrow('scope has a missing or empty wire') + expect(() => validateTypertManifest('pkg', { + ...base, + 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', { + ...base, + invocations: [{ ...scoped, scope: { context: 'agent', wire: 'missingId' } }], + })).toThrow('must select its only lookup parameter') + expect(() => validateTypertManifest('pkg', { + ...base, + 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', { + ...base, + invocations: [{ ...scoped, scope: { context: 'other', wire: 'agentId' } }], + })).toThrow('must select its only lookup parameter') + expect(() => validateTypertManifest('pkg', { + ...base, + 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 {}' } @@ -436,6 +664,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/README.i18n.yaml b/packages/typert/registry/README.i18n.yaml index b8c97637c9..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: 83c03ab284abf2b7cab4dd1ee70d7e855184a1e0 -README.zh.md: db2140e51d85be53bbf6eb4d1dd86ec38ceefc58 +README.md: fa227b1c8faf1abd5a6492d4b8fe7d0c51ceeef1 +README.zh.md: 343e43aaca6ddaa0bb5e8d3130c85f37e4b4cb93 diff --git a/packages/typert/registry/README.md b/packages/typert/registry/README.md index 83c03ab284..fa227b1c8f 100644 --- a/packages/typert/registry/README.md +++ b/packages/typert/registry/README.md @@ -9,6 +9,8 @@ Package reflection is keyed by `#`. Schemas are keyed by `#`. - */ -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..353ad4c9e0 --- /dev/null +++ b/packages/typert/registry/src/service.ts @@ -0,0 +1,718 @@ +/** + * 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, + TypeRTHostContextResolver, + TypeRTLocalRegistry, + TypeRTLookupHost, + TypeRTLookupDefinition, + TypeRTLookupMap, + TypeRTLookupProvider, + TypeRTLookupResolver, + 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) + /* 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) + } + 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 () => { + /* 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) + } + }, `typert.remotes.register(${JSON.stringify(contribution.package)})`) + } +} + +class LookupStore { + private readonly providers = new Map>() + private readonly resolvers = new Map>() + private readonly definitions = 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), + 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) + 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 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 { definitions, providers, changes } = this + return ctx.effect(function* () { + definitions.set(key, definition) + 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 }) + } + }, `typert.lookups.register(${JSON.stringify(key)})`) + } +} + +interface LookupResolverEntry { + resolve(id: unknown): Promise +} + +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 hostResolvers = 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), + configureHost: >( + key: K, + resolver: TypeRTHostContextResolver>, + ) => this.configureHost(ctx, key, resolver), + registerClient: >( + key: K, + binder: TypeRTClientContextBinder>, + ) => this.registerClient(ctx, key, binder), + 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) + 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 () => { + /* 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 }) + } + }, `typert.contexts.register(${JSON.stringify(key)})`) + } +} + +interface HostContextResolverEntry { + resolve(id: unknown): Promise +} + +/** + * 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 () => { + /* 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) + } + }, '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}`) + } + 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) { + 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 { + if (value === '.' || value === '..' || !/^[A-Za-z0-9_$.-]+$/.test(value)) { + throw new Error(`typert: invalid ${subject} "${value}" — must contain only RPC endpoint segment characters`) + } +} + +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..4dcfc4b7a1 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,8 @@ export interface TypertContribution { readonly face: TypertFace readonly schemas: readonly TypertSchema[] readonly model: TypertPackageModel + /** Host invocation definitions, empty when the package exports no Remote methods. */ + 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..92b81803c7 100644 --- a/packages/typert/registry/tests/typert.spec.ts +++ b/packages/typert/registry/tests/typert.spec.ts @@ -2,10 +2,28 @@ 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' +import { apply as applyClientRegistry, inject as clientRegistryInject } from '../src/client/index.ts' + +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() @@ -18,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', @@ -42,6 +61,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 +124,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 +200,375 @@ 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('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.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() + 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.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() + }) + + 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('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[] = [] + 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()]) + 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) + }) + + 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 }], + cancellation: { parameter: 'signal' }, + 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(), + cancellation: { parameter: 'abort' } as unknown as { readonly parameter: 'signal' }, + }, 'cancellation parameter'], + [{ + ...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[] = [] + 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..a61602c07b --- /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: 9bd475f8973ec54756fe0e63d5b7fa485381697d +README.zh.md: 10a6309bc47001d572abb3dd3f794ebfcf6252e8 diff --git a/packages/typert/type-meta/README.md b/packages/typert/type-meta/README.md new file mode 100644 index 0000000000..9bd475f897 --- /dev/null +++ b/packages/typert/type-meta/README.md @@ -0,0 +1,36 @@ +# @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 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. +- `@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. + +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. A `GatewayService` exposes the same public readonly `typertGateway` binding that the explicit helper returns. + +## TypeRT protocol + +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. + +## 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..10a6309bc4 --- /dev/null +++ b/packages/typert/type-meta/README.zh.md @@ -0,0 +1,36 @@ +# @deepseek-ai/dsh-type-meta + +[English](README.md) | 中文 + +该包提供不依赖编译器的声明,由业务包、生成的 TypeRT 产物、Host Gateway 和 Client API 共享。它负责 Remote Service 基类、装饰器、显式 binding 回退、可通过声明合并扩展的协议映射、调用描述符、编解码器和提供方契约;它不执行 TypeScript 分析,也不注册具体 Cordis 服务。 + +## Remote 声明 + +- `@Remote` 将公开实例方法标记为可在其注册的 Cordis 服务上直接调用。 +- `@RemoteScope(key)` 标记接收者选自合并声明的作用域 Context 类型的方法。 +- `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 属性、参数元数据或运行时反射字段。`GatewayService` 会暴露与显式 helper 相同的 public readonly `typertGateway` 绑定。 + +## TypeRT 协议 + +业务包扩展 `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` 编解码器标识约束更弱的源码启动路径。 + +## 模型体验 + +无,因为该协议包声明应用反射,不注册任何模型接口。 + +#### 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..e2d7689866 --- /dev/null +++ b/packages/typert/type-meta/package.json @@ -0,0 +1,40 @@ +{ + "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" + ], + "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..1375d7872b --- /dev/null +++ b/packages/typert/type-meta/src/index.ts @@ -0,0 +1,275 @@ +/** + * 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 { 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 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, + InvocationSourceLocation, + TypeRTClientRemote, + TypeRTClientContextBinder, + TypeRTCodec, + TypeRTContext, + TypeRTContextMap, + TypeRTContextRegistry, + TypeRTContextWire, + TypeRTDisposer, + TypeRTHostContextProvider, + TypeRTHostContextResolver, + TypeRTLocalRegistry, + TypeRTLookup, + TypeRTLookupDefinition, + TypeRTLookupHost, + TypeRTLookupMap, + TypeRTLookupProvider, + TypeRTLookupResolver, + TypeRTLookupRegistry, + TypeRTLookupWire, + TypeRTRemoteScopeApi, + TypeRTRemoteScopeMap, + TypeRTRemoteScopeNamespace, + 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 }) +} + +/** 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. + * @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 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 RemoteScope( + key: Extract, + exportName?: string, +): RemoteMethodDecorator { + validateName('Scope 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 (!isTypeRTRemoteSegment(value)) { + throw new TypeError(`type-meta: ${subject} must contain only RPC endpoint segment characters`) + } +} 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..c1d6b3dcf9 --- /dev/null +++ b/packages/typert/type-meta/src/types.ts @@ -0,0 +1,428 @@ +/** + * 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 TypeRTRemoteScopeMap {} + +/** + * 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 TypeRTRemoteScopeNamespace< + Namespace extends string, + ContextKey extends string = string, +> = { + [Endpoint in keyof TypeRTRemoteScopeMap as Endpoint extends `${ContextKey}:${Namespace}/${infer Method}` + ? Method + : never]: TypeRTRemoteScopeMap[Endpoint] +} + +type TypeRTRemoteScopeNamespaceKey< + ContextKey extends string, + Endpoint = keyof TypeRTRemoteScopeMap, +> = Endpoint extends `${ContextKey}:${infer Namespace}/${string}` ? Namespace : never + +/** Generated scoped Remote namespaces available to one Context kind. */ +export type TypeRTRemoteScopeApi = { + [Namespace in TypeRTRemoteScopeNamespaceKey]: + TypeRTRemoteScopeNamespace +} + +/** Merge-extensible direct namespace surface generated for Client Remote 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[] + /** 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. */ + 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[] +} + +/** 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 after namespace services and concrete methods are ready. + */ + $mount(contribution: TypeRTRemoteContribution): Promise +} + +/** + * 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 + /** 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 through the provider's default policy. + * @param id - validated wire identity. + * @returns the object, `undefined` when unavailable, or either asynchronously. + */ + resolve(id: Wire): Host | undefined | Promise +} + +/** 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 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 | 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 { + /** + * 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 + /** + * 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. + * @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[] + /** + * 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 + /** + * 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. + * @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..14eec6610d --- /dev/null +++ b/packages/typert/type-meta/tests/fixtures/source-launch.ts @@ -0,0 +1,32 @@ +import { Context } from 'cordis' +import { + GatewayService, + Remote, + RemoteScope, + remoteMethods, +} from '@deepseek-ai/dsh-type-meta' + +class Goals extends GatewayService { + constructor(ctx: Context) { + super(ctx, 'goals') + } + + @Remote + create(value: string): string { + return value + } + + @RemoteScope('agent') + scoped(value: string): string { + return value + } +} + +const methods = remoteMethods(new Goals(new Context())) +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..bfe99630b9 --- /dev/null +++ b/packages/typert/type-meta/tests/type-meta.spec.ts @@ -0,0 +1,230 @@ +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, + RemoteScope, + 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('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 { + return value + } + + @RemoteScope('metaFixture') + scoped(value: string): string { + return value + } + } + + 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', () => { + 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), + ) + RemoteScope('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('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), + ) + RemoteScope('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(() => Remote('bad#name')).toThrow('export name') + expect(() => Remote('bad name')).toThrow('export name') + expect(() => Remote('.')).toThrow('export name') + expect(() => Remote('..')).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 }, + { ...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), + ) + RemoteScope('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') + 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/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/域名授权。 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2f90a138e9..5b36425fd6 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 @@ -1054,6 +1110,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 @@ -1110,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 @@ -1391,12 +1456,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-api-remotes': + specifier: workspace:^ + version: link:../../api/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 @@ -1606,9 +1680,9 @@ importers: packages/client/ui-goal: devDependencies: - '@deepseek-ai/dsh-client-connection': + '@deepseek-ai/dsh-api-remotes': specifier: workspace:^ - version: link:../connection + version: link:../../api/remotes '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale @@ -2824,6 +2898,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 @@ -2888,6 +2968,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 @@ -3522,6 +3608,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 @@ -3745,6 +3834,9 @@ importers: '@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 @@ -3821,6 +3913,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 @@ -6130,6 +6228,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 @@ -6174,6 +6275,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 @@ -6185,6 +6289,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: @@ -7050,6 +7163,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/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/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/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 } diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index bcf90d1e82..54581a8605 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', @@ -276,6 +277,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 +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/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/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 92df711f3e..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}`) } @@ -463,13 +471,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 { diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index f7669eac7e..29aa995b1d 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'), @@ -240,12 +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'), @@ -339,10 +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' }), + ...ciSharedStaticGates(), ...options.ownsBuild ? [pnpmScript('build', 'build')] : [], ...docSyncLeafGates({ includeDocTypecheck: options.ownsBuild, @@ -601,6 +606,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/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/snapshots/translation-prompt-v4/request-response.expected.json b/scripts/snapshots/translation-prompt-v4/request-response.expected.json index b9d67f4bf6..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\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如果相关的本地检查需要使用构建后的包产物,请先构建一次:\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", diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 603ad20d8e..095fa25b4f 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", @@ -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/api/gateway/src/types.ts" + }, + { + "doc": "docs/core-data-structures/typert.md", + "symbol": "TypertGatewayErrorCode", + "source": "packages/api/gateway/src/types.ts" + }, + { + "doc": "docs/core-data-structures/typert.md", + "symbol": "TypertGateway", + "source": "packages/api/gateway/src/types.ts" + }, + { + "doc": "docs/core-data-structures/typert.md", + "symbol": "TypeRTClientRemote", + "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 53491b89fb..e131d6c403 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/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.' }, @@ -125,6 +126,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/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/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" diff --git a/tsconfig.base.json b/tsconfig.base.json index 001158afe0..b9907348fa 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-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"], "@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-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 03a2b8bb59..9821c0e41b 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -51,6 +51,9 @@ { "path": "./packages/client/modules" }, { "path": "./packages/client/hmr" }, { "path": "./packages/client/connection" }, + { "path": "./packages/typert/registry" }, + { "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 79276dfe7e..6884839536 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/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 20768618f6..5466ace3cf 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -3,7 +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 { 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 @@ -88,7 +88,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 +99,7 @@ export default defineConfig({ // always fork. projects: [ { - plugins: [pathsPlugin()], + plugins: [pathsPlugin(), standardDecoratorPlugin()], test: { name: 'thread-safe', execArgv: vitestExecArgv, @@ -119,7 +119,7 @@ export default defineConfig({ }, }, { - plugins: [pathsPlugin()], + plugins: [pathsPlugin(), standardDecoratorPlugin()], test: { name: 'process-bound', execArgv: vitestExecArgv, @@ -184,6 +184,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/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', 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'],