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-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-29-request-level-llm-config-credentials.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.i18n.yaml index 27da5f7663..5524e0d54d 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.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-request-level-llm-config-credentials.md -2026-07-29-request-level-llm-config-credentials.md: f12a2496a767decc3ce2b065f6be03009aec8992 -2026-07-29-request-level-llm-config-credentials.zh.md: 2d132c0fd0ab2205ca013b49226a9764293cc11d +2026-07-29-request-level-llm-config-credentials.md: 238400ea41f25a716729d1721c113645c2c8ba72 +2026-07-29-request-level-llm-config-credentials.zh.md: b0d04d4303bf0ccf5ebc74af8c4a3e493f861d63 diff --git a/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.md b/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.md index f12a2496a7..238400ea41 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.md +++ b/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.md @@ -14,7 +14,7 @@ The [settings seam](2026-07-28-user-settings-seam.md) shipped without a producti **Per-request resolution, not fiber rebuilds.** The adapters take an options thunk (and a per-stream credential resolver) instead of frozen construction facts, resolving once per operation — the Pi pattern, with its tested semantics: two requests straddling a change see two configurations, one request resolves exactly once, and an in-flight stream keeps the facts it started with. This deletes the entire swap machinery a rebuild design needs (`DUPLICATE_ADAPTER` ordering, `NO_ADAPTER` windows, a deferred-activation state machine) and makes a missing key a *request-time* actionable failure (`MISSING_CREDENTIAL` naming every entry point) while the route stays registered and the catalog stays browsable. The one registration-captured fact — the retry policy the `ctx.llm` registry snapshots at `registerAdapter` (plus pi-ai's route *set*) — re-registers the same adapter instance in one synchronous section when it changes. -**Secrets are references, values live behind `ctx.credentials`.** Configuration (both planes) carries `apiKeyEnv: DEEPSEEK_API_KEY`; the three-package credential seam resolves it per operation. `credentials-local` layers the live process environment (read-only, wins — a launch-time override is operator intent and must be *visibly* read-only, so shadowed writes reject instead of appearing to succeed) over `$DSH_HOME/.env` (writable, byte-preserving line edits, a quoting ladder dotenv reads back verbatim, wholesale snapshot replacement on reload so a deleted entry never lingers — the Claude Code additive-reapply lesson). Resolution order in the adapters is literal `apiKey` first (preserving the historical `config.apiKey ?? env` observable semantics), then the seam, then — only without a mounted seam — the raw environment variable. +**Secrets are references, values live behind `ctx.credentials`.** Configuration (both planes) carries `apiKeyEnv: DEEPSEEK_API_KEY`; the three-package credential seam resolves it per operation. `credentials-local` layers the live process environment (read-only, wins — a launch-time override is operator intent and must be *visibly* read-only, so shadowed writes reject instead of appearing to succeed) over the provider-managed document (writable, wholesale snapshot replacement on reload so a deleted entry never lingers — the Claude Code additive-reapply lesson). That document was `$DSH_HOME/.env` in dotenv form; the [credentials document split](2026-08-04-credentials-yaml-and-user-environment-layer.md) later moved it to `$DSH_HOME/.credentials.yaml` and freed the old path to become the user's environment layer. Adapters resolve the reference through the seam, or — only without a mounted seam — through the environment layers. **Per-plugin namespaces, schema ≡ `Config`.** Each adapter registers its own namespace (`llm-deepseek`, `llm-pi-ai`) with its plugin `Config` schema and its `cordis.yml` entry as the composition `base` — a settings section is the same YAML shape as the entry config, and `resolveAdapterOptions`/`resolveProfiles` stay the one explicit resolve step for both. A live snapshot failing a beyond-schema bound keeps the last good facts (the seam's last-good philosophy extended one level up); the entry config itself still fails load. pi-ai's `providers` became a dict keyed by route so base and user layers merge per provider and the route set is structural; the array shape fails loud with migration directions, and an empty dict is the valid dormant posture — a composition ships the adapter bare and every route stays a user-plane decision. diff --git a/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.zh.md b/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.zh.md index 2d132c0fd0..b0d04d4303 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.zh.md @@ -14,7 +14,7 @@ Status: implemented **按请求解析,而非重建 fiber。**适配器改为接收一个 options thunk(外加按流调用的凭据解析器),不再持有冻结的构造期事实,每个操作解析一次——即 Pi 的模式,连同其经测试固定的语义:跨越一次变更的两个请求看到两份配置,一个请求恰好解析一次,进行中的流保持其起始事实。这删掉了重建式设计所需的整套切换机制(`DUPLICATE_ADAPTER` 顺序问题、`NO_ADAPTER` 窗口、延迟激活状态机),并把密钥缺失变成*请求时*可行动的失败(`MISSING_CREDENTIAL` 点名每个配置入口),同时路由保持注册、catalog 保持可浏览。唯一在注册期捕获的事实——`ctx.llm` 注册表在 `registerAdapter` 时快照的重试策略(外加 pi-ai 的路由*集合*)——在其变化时于一个同步区段内原地重新注册同一适配器实例。 -**机密是引用,值藏在 `ctx.credentials` 背后。**配置(两个面)携带 `apiKeyEnv: DEEPSEEK_API_KEY`;三包凭据 seam 按操作解析它。`credentials-local` 把活跃进程环境(只读、优先——启动时覆盖是操作者意图,必须*可见地*只读,因此被遮蔽的写入直接拒绝而不是表面成功)叠加在 `$DSH_HOME/.env` 之上(可写、保字节行级编辑、dotenv 能逐字读回的引号阶梯、重载时整体替换快照使删除的条目绝不滞留——来自 Claude Code 增量重放(additive reapply)的教训)。适配器内的解析顺序为:字面 `apiKey` 优先(保留历史 `config.apiKey ?? env` 的可观察语义),然后是 seam,最后——仅在未挂载 seam 时——原始环境变量。 +**机密是引用,值藏在 `ctx.credentials` 背后。**配置(两个面)携带 `apiKeyEnv: DEEPSEEK_API_KEY`;三包凭据 seam 按操作解析它。`credentials-local` 把活跃进程环境(只读、优先——启动时覆盖是操作者意图,必须*可见地*只读,因此被遮蔽的写入直接拒绝而不是表面成功)叠加在 provider 管理的文档之上(可写、重载时整体替换快照使删除的条目绝不滞留——来自 Claude Code 增量重放(additive reapply)的教训)。该文档当时是 dotenv 形式的 `$DSH_HOME/.env`;[凭据文档拆分](2026-08-04-credentials-yaml-and-user-environment-layer.md)后来把它移到 `$DSH_HOME/.credentials.yaml`,并让旧路径转为用户的环境层。适配器通过 seam 解析该引用;仅在未挂载 seam 时,才通过各环境层解析。 **按插件划分 namespace,schema ≡ `Config`。**每个适配器注册自己的 namespace(`llm-deepseek`、`llm-pi-ai`),schema 用其插件 `Config` schema,组合 `base` 用其 `cordis.yml` 条目——settings 分节与 entry 配置是同一种 YAML 形状,`resolveAdapterOptions`/`resolveProfiles` 对两者仍是唯一的显式 resolve 步骤。存活快照若违反 schema 之外的约束,则保留最后可用事实(seam 的最后可用值哲学向上延伸一层);entry 配置本身仍会加载失败。pi-ai 的 `providers` 改为以路由为键的字典,base 层与用户层因此按提供方合并,路由集合也由结构直接表达;数组形状响亮失败并给出迁移指引,而空字典是合法的休眠姿态——组合可以裸挂该适配器,把每一条路由都留给用户面决定。 diff --git a/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.i18n.yaml index 1dc80bbf84..4becd41658 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.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-credential-boundaries-and-atomic-registration.md -2026-07-30-credential-boundaries-and-atomic-registration.md: 6fe5f554acbfd804db9625fcaa794d513c8799c4 -2026-07-30-credential-boundaries-and-atomic-registration.zh.md: b58a9e68bb1fa36282ad270542b6a64a0b014726 +2026-07-30-credential-boundaries-and-atomic-registration.md: 94b32c3cfaa3e1c5059573881a2f393d29aed3ac +2026-07-30-credential-boundaries-and-atomic-registration.zh.md: 2506ce391c9125323faf107e3c04c52785e0cc98 diff --git a/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.md b/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.md index 6fe5f554ac..94b32c3cfa 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.md +++ b/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.md @@ -10,15 +10,15 @@ English | [中文](2026-07-30-credential-boundaries-and-atomic-registration.zh.m Review found the credential path leaking across boundaries it had drawn. The shipped surfaces hoisted `$DSH_HOME/.env` into `process.env` before cordis booted, so on the next run `credentials-local` classified every key it had stored itself as a read-only ambient launch override: `describe()` reported `source: 'env'` with `writable: false`, `set`/`unset` rejected as shadowed, and a key stored from the web page or TUI became unrotatable and undeletable while the adapter kept using the value captured at launch. The store's own write path repeated the settings-local defects that same review round fixed (two independent chains, whole-file render from a stale cache), plus editor bugs of its own: a physical line inside another key's quoted multi-line value read as an assignment, CRLF endings degraded to LF, a multi-line entry reported `writable: true` while `set` always threw, and `credentials/updated` was emitted bare after the commit, so one broken observer made a durable write look failed. On the read side, the file's `0600` mode stops other OS users but not the model, whose bash and filesystem tools run as the same user. -Two request-path defects sat beside them. DeepSeek's per-request resolution kept connection facts in a last-good snapshot but re-read the literal `apiKey` from the raw configuration, so a settings generation the resolver rejected could still put its key on the previous generation's endpoint. pi-ai handed the SDK `undefined` when a configured `apiKeyEnv` resolved to nothing, letting pi-ai's own environment discovery authenticate with an unrelated provider key — another tenant, silently billed. And its route swap disposed the old registration before creating the new one: a route another adapter owned dropped every existing route, after which the facts cache could equal the registry's, so restoring the working configuration never re-applied. +Two request-path defects sat beside them. DeepSeek resolved connection and credential facts independently, so a settings generation the resolver rejected could still pair its credential choice with the previous generation's endpoint. pi-ai handed the SDK `undefined` when a configured `apiKeyEnv` resolved to nothing, letting pi-ai's own environment discovery authenticate with an unrelated provider key — another tenant, silently billed. And its route swap disposed the old registration before creating the new one: a route another adapter owned dropped every existing route, after which the facts cache could equal the registry's, so restoring the working configuration never re-applied. ## Decision -**`$DSH_HOME/.env` belongs to the credential provider alone.** No surface loads it into `process.env`. The genuine launch environment and the invoking directory's `.env` (loaded by the bin) stay the read-only ambient layer, so a composition without the provider resolves keys exactly as before, while a stored key stays file-sourced and writable across restarts — proven by a real restart in the loader composition rather than by a unit assertion about `describe()`. +**The credential document belongs to the credential provider alone.** No surface loads it into `process.env`. It was `$DSH_HOME/.env` here; the [credentials document split](2026-08-04-credentials-yaml-and-user-environment-layer.md) later moved it to `$DSH_HOME/.credentials.yaml`, so today it is the old path that is loaded — as the user's ordinary environment layer, holding no provider-managed secret. The genuine launch environment and the invoking directory's `.env` (loaded by the bin) stay the read-only ambient layer, so a composition without the provider resolves keys exactly as before, while a stored key stays file-sourced and writable across restarts — proven by a real restart in the loader composition rather than by a unit assertion about `describe()`. **The stored credential has no boundary against the model, and the READMEs say so.** `0600` under a `0700` directory stops other OS users; the model's bash and filesystem tools run as that same user, and the shipped default confines nothing. What the harness does hold to is narrower and stated as exactly that: no surface hoists the document into `process.env`, and the model is never handed a resolved path to it, so reaching the value takes a deliberate read of a path it was not given. An OS-keychain provider — a store the model's processes cannot read at all — is recorded as the real answer rather than implied by a partial one. -**One request, one generation.** DeepSeek's resolved snapshot carries the credential facts (literal key and reference) beside the endpoint, and `resolveApiKey` receives that snapshot instead of re-reading configuration. A rejected generation now contributes nothing at all. pi-ai defers to provider-native discovery only for a profile naming no credential; a configured reference that misses fails with `MISSING_CREDENTIAL` naming the route and the reference. The boot-time credential probe is deleted: it could run before the credentials service mounted and reported every failure as a missing key, while the first request already gives the accurate error. +**One request, one generation.** DeepSeek's resolved snapshot carries the credential reference beside the endpoint, and `resolveApiKey` receives that snapshot instead of re-reading configuration. A rejected generation now contributes nothing at all. pi-ai defers to provider-native discovery only for a profile naming no credential; a configured reference that misses fails with `MISSING_CREDENTIAL` naming the route and the reference. The boot-time credential probe is deleted: it could run before the credentials service mounted and reported every failure as a missing key, while the first request already gives the accurate error. **Route replacement is a registry operation, not a caller sequence.** `registerAdapter` returns a handle carrying `replace(providers)`: the candidate set is validated in full first (conflicts, names, provider metadata), then swapped in one synchronous section. A refused replacement leaves the previous routes registered and serving, and the caller's facts cache only advances after the registry actually holds the new set, so reverting to a working configuration re-applies. pi-ai's registration facts are sorted by provider, so a settings document that merely reorders its keys is no longer a route change. diff --git a/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.zh.md b/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.zh.md index b58a9e68bb..2506ce391c 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.zh.md @@ -14,15 +14,15 @@ Status: implemented 在读取一侧,文件的 `0600` 权限挡得住其他 OS 用户,却挡不住模型:它的 bash 与文件系统工具就以同一个用户身份运行。 -与之并排的还有两个请求路径缺陷。DeepSeek 的按请求解析把连接事实保存在最后可用快照里,却仍从原始配置重新读取字面 `apiKey`,于是被 resolver 拒绝的那一代设置,照样能把自己的密钥送到上一代的端点上。配置了 `apiKeyEnv` 却解析不到值时,pi-ai 会把 `undefined` 交给 SDK,让 pi-ai 自己的环境发现拿一个毫不相干的提供方密钥完成鉴权——那是另一个租户,账单还悄悄记在它头上。而且它的路由替换是先释放旧注册、再创建新注册:只要有一条路由已被别的适配器占有,现有路由就会被全部丢掉,此后事实缓存可能与注册表中的事实相等,于是把配置改回可用状态也不会重新生效。 +与之并排的还有两个请求路径缺陷。DeepSeek 分别解析连接事实与凭据事实,因此被 resolver 拒绝的那一代设置仍可能把自己的凭据选择与上一代的端点配在一起。配置了 `apiKeyEnv` 却解析不到值时,pi-ai 会把 `undefined` 交给 SDK,让 pi-ai 自己的环境发现拿一个毫不相干的提供方密钥完成鉴权——那是另一个租户,账单还悄悄记在它头上。而且它的路由替换是先释放旧注册、再创建新注册:只要有一条路由已被别的适配器占有,现有路由就会被全部丢掉,此后事实缓存可能与注册表中的事实相等,于是把配置改回可用状态也不会重新生效。 ## 决策 -**`$DSH_HOME/.env` 只归凭据提供方所有。**没有任何一个面会把它加载进 `process.env`。真正的启动环境,以及调用目录中由 bin 加载的 `.env`,仍然是那一层只读的环境来源,因此不挂载该提供方的组合,解析密钥的方式与从前完全一致,而存下的密钥跨重启仍然来源于文件、仍然可写——这一点由 Loader 组合中的一次真实重启来证明,而不是靠对 `describe()` 的单元断言。 +**凭据文档只归凭据提供方所有。**没有任何一个面会把它加载进 `process.env`。当时该文档是 `$DSH_HOME/.env`;[凭据文档拆分](2026-08-04-credentials-yaml-and-user-environment-layer.md)后来把它移到 `$DSH_HOME/.credentials.yaml`,因此如今被加载的正是那条旧路径——作为用户的普通环境层,其中不含任何 provider 管理的密钥。真正的启动环境,以及调用目录中由 bin 加载的 `.env`,仍然是那一层只读的环境来源,因此不挂载该提供方的组合,解析密钥的方式与从前完全一致,而存下的密钥跨重启仍然来源于文件、仍然可写——这一点由 Loader 组合中的一次真实重启来证明,而不是靠对 `describe()` 的单元断言。 **存下的凭据对模型没有边界,而 README 就是这么写的。**`0700` 目录下的 `0600` 挡得住其他 OS 用户;模型的 bash 与文件系统工具正是以同一用户身份运行,而已交付的默认值不约束任何东西。harness 真正守住的更窄,也就照这个宽度写下来:没有任何一个面会把该文档提升进 `process.env`,模型也从不会拿到它的解析后路径,因此要拿到这个值,需要刻意去读一条并未交给它的路径。OS 钥匙串(keychain)提供方——一个模型的进程根本读不到的存储——被记录为真正的答案,而不是靠一个残缺的方案去暗示它。 -**一次请求,一代设置。**DeepSeek 解析出的快照在端点旁一并携带凭据事实(字面密钥与引用),`resolveApiKey` 接收这份快照,而不再重新读取配置。被拒绝的那一代如今完全不再贡献任何东西。只有当一个 profile 完全没有点名凭据时,pi-ai 才交给提供方原生的发现流程;配置了引用却解析不到,就以 `MISSING_CREDENTIAL` 失败,并点名该路由与该引用。启动时的凭据探测被删除:它可能在凭据服务挂载之前就运行,并把每一种失败都报成密钥缺失,而第一次请求本就会给出准确的错误。 +**一次请求,一代设置。**DeepSeek 解析出的快照在端点旁一并携带凭据引用,`resolveApiKey` 接收这份快照,而不再重新读取配置。被拒绝的那一代如今完全不再贡献任何东西。只有当一个 profile 完全没有点名凭据时,pi-ai 才交给提供方原生的发现流程;配置了引用却解析不到,就以 `MISSING_CREDENTIAL` 失败,并点名该路由与该引用。启动时的凭据探测被删除:它可能在凭据服务挂载之前就运行,并把每一种失败都报成密钥缺失,而第一次请求本就会给出准确的错误。 **路由替换是注册表的操作,不是调用方的一串步骤。**`registerAdapter` 返回一个携带 `replace(providers)` 的句柄:候选集合先被完整校验(冲突、名称、提供方元数据),再在一个同步区段内完成替换。被拒绝的替换会让先前的路由保持注册并继续服务,而调用方的事实缓存只有在注册表确实持有新集合之后才会推进,因此改回可用配置时会重新生效。pi-ai 的注册事实按提供方排序,因此仅仅调换键顺序的设置文档不再算作路由变更。 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-30-web-details-default-closed.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.i18n.yaml similarity index 55% rename from .agents/notes/implemented/bug-fix/2026-07-30-web-details-default-closed.i18n.yaml rename to .agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.i18n.yaml index 0f0ff3020b..22935b42ca 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-web-details-default-closed.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.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-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 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md +2026-08-04-configuration-source-ownership.md: ef30a22c120af1437f348e52843e1dd45c9837ae +2026-08-04-configuration-source-ownership.zh.md: c1567c6e823a0bc8ed8d1f9ed50a11a5d204ede8 diff --git a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md new file mode 100644 index 0000000000..ef30a22c12 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md @@ -0,0 +1,73 @@ +# Agent Note: One ordering for configuration sources, and what a discovered file may not decide + +Status: implemented + +English | [中文](2026-08-04-configuration-source-ownership.zh.md) + +## Problem + +`$DSH_HOME/.env` had just [become an ordinary environment layer](2026-08-04-credentials-yaml-and-user-environment-layer.md), which left the harness resolving user-facing values from a flattened `process.env` that could no longer say where a value came from. Three consequences followed. + +A key stored through the web page stayed shadowed by an older key in the user's own `.env`, because the credential provider compared "the environment" against its file and the environment now included that file. The migration dead end the split was supposed to remove had simply moved. + +An endpoint could be redirected by the project. The invoking directory's `.env` is materialized like every other layer, and a base URL decides where a resolved API key is sent — so a `DEEPSEEK_BASE_URL` written into a workspace the model can edit would send the user's own credential, and the prompts carrying their code, to whatever host that file named. Nothing about the flattened view could distinguish that from the operator exporting the same variable. + +And `!!js process.env.X` in the shipped composition made the same value reachable twice: once through the entry config and once through whatever ladder its consumer applied, with the winner decided by layer order rather than by what the value means. + +## Decision + +**One ordering for non-secret values.** Every configurable value that is not itself a credential resolves in the same order; the domains differ only in which tiers exist. + +```text +explicit for this run per-operation override, CLI argument +> user settings settings.yaml +> composition profile bundles, user patch layers, --patch overlays +> this launch's shell inherited process environment +> discovered file /.env, then $DSH_HOME/.env +> defaults schema default, provider public default +``` + +Settings sit above composition because that is what the [settings seam](2026-07-28-user-settings-seam.md) does: a plugin registers its cordis entry config as the `base` layer and the user's section layers over it, and the seam cannot tell a value a profile's bundles set from one its user patch layer or a `--patch` overlay set — all arrive as entry config. The product CLI has no lever above stored settings, so a deployment that must pin a field against a user's settings ships its own bin or loader tree, or mounts no settings provider at all. Composition still outranks the environment, so a stale `DEEPSEEK_BASE_URL` in a shell cannot rewrite a configured endpoint. + +**Credentials keep a narrower, separate ordering**, and this note does not unify them: + +```text +inherited process environment (read-only, wins) +> $DSH_HOME/.credentials.yaml (provider-managed, writable) +> /.env +> $DSH_HOME/.env +``` + +The launching environment wins because `DEEPSEEK_API_KEY=… dsh`, a CI secret, and a container `-e` are the one override an operator must be able to apply per run without editing machine state, and because it cannot be edited from inside it must be *visibly* read-only. Configuration is meant to carry only the *reference* — which name to resolve — and that name follows the non-secret ordering above. + +**The project the harness is launched in is trusted, by default and without a prompt.** A checkout may carry its own endpoint, its own ordinary variables, and its own key; the key ranks below the managed store, so a key stored through the Models page is never displaced by one a checkout happens to contain. `EnvironmentSnapshot.getFrom(name, sources)` still searches only the layers a caller names, and omitting one is a refusal rather than a demotion — the mechanism exists for the decisions where a layer must be unreachable, not because the project is one of them today. + +**Trust does not extend to changing the harness itself.** `loadLayeredEnv` rejects, at load and before anything is materialized, any `.env` that sets a variable governing how a process launches (`PATH`, `SHELL`, `NODE_OPTIONS`, `LD_PRELOAD`), what code a runtime executes before the program it was asked to run (`BASH_ENV`, `PERL5OPT`, `PYTHONSTARTUP`, `RUBYOPT`, `JAVA_TOOL_OPTIONS`, the Git hook commands), where model-visible instructions load from (the whole `DSH_*` namespace, `HOME`, `XDG_*`), or how the network is reached and trusted (proxy and CA variables). Matching is case-insensitive, so `https_proxy` is not a bypass. + +The line is that these take effect with no user action, before any turn, outside the permission policy and the sandbox. `DSH_PERMISSION_MODE` would switch off the approvals that make trusting a project meaningful at all, and `BASH_ENV` runs a file of the project's choosing on every single `bash -c` the bash tool issues — the project's code running under the agent's policy is the deal; the project rewriting that policy is not. Enumerating these is a losing game one variable at a time, which is why the whole `DSH_*` namespace is denied rather than an audited subset, and why the list is organised by what a variable *does* rather than by which runtime owns it. There is no opt-out: an escape hatch would have to be readable from somewhere, and anything a discovered file could set is the hole itself. + +**`packages/util/environment` owns the snapshot**, deliberately as a utility rather than a three-package capability seam. The snapshot is frozen before Cordis starts and injected once by the launcher, so there is no runtime implementation to swap; consumers need types and pure functions, which a `util/` package gives them without depending on a UI package. `environmentOf(ctx)` returns the launcher's snapshot, or the inherited environment as the only layer — an SDK host or bare `cordis.yml` discovered no files, so its single layer really is what it was launched with, and the same trusted lookups keep working there unchanged. + +**`verify-config-source-ownership`** is a narrow tripwire for the ordinary single-line form of an `apiKey`/`baseURL`/`headers` environment inline in shipped Cordis configuration. Removing those inlines is what makes the deployment tier meaningful — with the shipped tree silent on `baseURL`, a present value means a human or deployment set it. Adapters own actual resolution; the gate makes no repository-wide claim about `process.env` access. + +## Consequences + +- The web credential form now takes effect against an older key in the user's `.env`; only a key exported in the launching shell still makes it read-only, and the diagnostic says so. +- A `.env` holding `DSH_*`, `PATH`, or a proxy variable fails the launch instead of being applied. Developers keeping switches in a repository `.env` move them to their shell — a deliberate, loud break. +- Composition is no longer overridable by a stale shell endpoint. It is still overridable by a user's stored `settings.yaml`, which is the settings seam's layering and not something this note changes; the product CLI offers no flag above it, so a deployment that must win against stored settings owns its own bin or loader tree. +- Not solved: the layers are still materialized into `process.env`, so ordinary project variables continue to reach child processes under the subprocess scrub. Bootstrap variables cannot come from a file at all; the environment package records the remaining subprocess reach as a limitation. +- Exa and Perplexity still capture their key at load time rather than through the credential seam. They no longer read raw `process.env` — they resolve through the trusted layers — but converting them to per-request seam resolution is separate work. + +## Alternatives considered + +**Unify credentials into the non-secret ordering, by who authored each source.** Attempted and abandoned: it reads well, but the settings seam already fixes composition *below* the user section, so "authored by deployment" is not a tier the seam can express — and moving `.credentials.yaml` above the launching environment would take away the one override CI, containers, and a per-run `DEEPSEEK_API_KEY=…` depend on. Two orderings that each say why they are shaped that way beat one that describes neither accurately. + +**Withhold routing and credentials from the invoking project until it is explicitly trusted.** Rejected as the product's stance: a checkout is trusted by default, with no prompt and no stored trust record. The residual is real and worth naming — cloning a repository that carries a `.env` naming another endpoint or key routes that session through it — and a later project-trust gate is where that gets addressed, not a rule that makes the common case require ceremony. + +**Audit an allowlist of `DSH_*` variables a `.env` may set.** Rejected: the list would have to be re-audited on every new switch, and the failure mode of forgetting is silent. Denying the namespace fails safe. + +**Rank a bootstrap variable below the process layer instead of rejecting it.** Rejected: `PATH` and `NODE_OPTIONS` have no meaningful "loser" behavior — a user who put one in a `.env` believes it applies, and silently ignoring it is the "my setting has no effect" failure this whole series exists to remove. + +**Build the snapshot as a three-package capability seam (`environment` / `environment-local` / consumers).** Rejected as premature: the producer runs before Cordis exists and there is no second implementation to select. The repository rule is to not split preemptively. + +**Stop materializing the layers into `process.env`.** Deferred, not rejected: it would keep project variables out of child processes entirely, but it silently breaks any user patch layer that reads `!!js process.env.X`. The snapshot is already the authority for everything the harness resolves, so this can land later without changing any ladder. diff --git a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md new file mode 100644 index 0000000000..c1567c6e82 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md @@ -0,0 +1,75 @@ +# Agent Note: 配置来源的统一顺序,以及被发现的文件不得决定什么 + +Status: implemented + +[English](2026-08-04-configuration-source-ownership.md) | 中文 + +## Problem + +`$DSH_HOME/.env` 刚刚[变成普通环境层](2026-08-04-credentials-yaml-and-user-environment-layer.md),这使得 harness 解析面向用户的值时面对的是一个压平的 `process.env`,再也说不清某个值来自哪里。由此产生三个后果。 + +通过 Web 页面存下的密钥仍然被用户自己 `.env` 里更旧的密钥遮蔽,因为凭据 provider 是拿「环境」与自己的文件比较,而现在环境包含了那个文件。这次拆分本该消除的迁移死路,只是换了个位置。 + +endpoint 可以被项目重定向。调用目录的 `.env` 和其他层一样会被物化,而 base URL 决定已解析的 API key 发往何处——于是写进模型可编辑工作区的 `DEEPSEEK_BASE_URL`,会把用户自己的凭据、以及承载其代码的提示词,一起发给该文件指定的任何主机。压平的视图无法把这件事和运维显式 export 同一个变量区分开。 + +而已交付组合里的 `!!js process.env.X` 让同一个值有两条抵达路径:一条经 entry config,一条经消费方各自的 ladder,胜负取决于层序而非这个值的语义。 + +## Decision + +**非密钥值走同一条顺序。** 每个本身不是凭据的可配置值都按同一顺序解析;各领域的差别只在于哪些层存在。 + +```text +explicit for this run per-operation override, CLI argument +> user settings settings.yaml +> composition profile bundles, user patch layers, --patch overlays +> this launch's shell inherited process environment +> discovered file /.env, then $DSH_HOME/.env +> defaults schema default, provider public default +``` + +自上而下依次是:本次运行的显式意图、用户 settings、composition、本次启动的 shell、被发现的文件、默认值。 + +settings 在 composition 之上,因为 [settings seam](2026-07-28-user-settings-seam.md) 就是这么做的:插件把自己的 cordis entry config 注册为 `base` 层,用户 section 叠加其上,而 seam 无法区分某个值是 profile 的 bundle 设的,还是它的用户 patch 层或某个 `--patch` overlay 设的——它们都以 entry config 的形式抵达。产品 CLI(命令行界面)没有高于已存 settings 的手段,因此需要把某字段钉死、不被用户已存 settings 覆盖的部署方,应自带 bin 或 loader 配置树,或者干脆不挂载 settings provider。composition 仍然高于环境,所以 shell 里陈旧的 `DEEPSEEK_BASE_URL` 无法改写已配置的 endpoint。 + +**凭据保留一条更窄的独立顺序**,本 Note 不把它并入上表: + +```text +inherited process environment (read-only, wins) +> $DSH_HOME/.credentials.yaml (provider-managed, writable) +> /.env +> $DSH_HOME/.env +``` + +继承环境优先,因为 `DEEPSEEK_API_KEY=… dsh`、CI 机密与容器 `-e` 是运维必须能按次施加、且无需改动机器状态的那一种覆盖;而它无法从进程内部修改,就必须*可见地*只读。配置本应只携带*引用*——解析哪个名字——该名字本身遵循上面的非密钥顺序。 + +**harness 被启动于其中的项目默认可信,且不做询问。** 一个 checkout 可以携带自己的 endpoint、自己的普通变量和自己的密钥;密钥排在受管存储之下,因此通过 Models 页存下的密钥绝不会被 checkout 中恰好带有的那一个顶掉。`EnvironmentSnapshot.getFrom(name, sources)` 仍然只搜索调用方点名的层,省略某层仍是拒绝而不是降级——该机制是为「某一层必须不可达」的那些决策准备的,而项目层今天不在其列。 + +**信任不延伸到改变 harness 本身。** `loadLayeredEnv` 会在加载时、且在物化任何内容之前,拒绝任何设置了下列变量的 `.env`:决定进程如何启动的(`PATH`、`SHELL`、`NODE_OPTIONS`、`LD_PRELOAD`)、决定运行时在执行被要求运行的程序之前先执行哪些代码的(`BASH_ENV`、`PERL5OPT`、`PYTHONSTARTUP`、`RUBYOPT`、`JAVA_TOOL_OPTIONS`、Git 的钩子命令)、决定模型可见指令从哪里加载的(整个 `DSH_*` 命名空间、`HOME`、`XDG_*`),以及决定网络如何抵达与信任的(proxy 与 CA 变量)。匹配不区分大小写,因此 `https_proxy` 不是绕过手段。 + +这条界线在于:它们无需任何用户动作、在任何一轮开始之前、且在权限策略与沙箱之外就生效。`DSH_PERMISSION_MODE` 会关掉让「信任项目」根本成立的那道审批,而 `BASH_ENV` 会在 bash 工具发出的每一次 `bash -c` 上执行项目指定的文件——项目的代码在 agent 的策略下运行是约定,项目改写那份策略不是。一个变量一个变量地枚举是必输的游戏,所以整个 `DSH_*` 命名空间被拒绝而不是只拒绝一份经审查的子集,也所以这份清单是按变量*做什么*而不是按哪个运行时拥有它来组织的。不设逃生门:逃生门本身总得从某处读取,而任何被发现的文件能设置的东西,就是那个漏洞本身。 + +**`packages/util/environment` 拥有该快照**,刻意做成 utility 而不是三包能力 seam。快照在 Cordis 启动前就冻结,并由启动器一次性注入,因此不存在需要切换的运行时实现;消费方需要的只是类型和纯函数,而 `util/` 包能提供这些且不必依赖 UI 包。`environmentOf(ctx)` 返回启动器的快照,或者返回只含继承环境的那一层——SDK 宿主或裸 `cordis.yml` 从未发现过任何文件,它那唯一一层确实就是它被启动时的环境,因此同样的受信查询在那里原样继续工作。 + +**`verify-config-source-ownership`** 仅作为一道窄门禁,检查已交付 Cordis 配置中从环境内联 `apiKey`/`baseURL`/`headers` 的普通单行写法。删除这些内联正是「部署层」得以成立的原因——已交付配置树对 `baseURL` 保持沉默之后,「有值」就意味着「人或部署设过它」。实际解析由适配器负责;该门禁不声称覆盖仓库范围内的 `process.env` 访问。 + +## Consequences + +- Web 凭据表单现在能压过用户 `.env` 里更旧的密钥;只有在启动 shell 里 export 的密钥才会让它变成只读,诊断信息也会这么说。 +- 含 `DSH_*`、`PATH` 或 proxy 变量的 `.env` 会导致启动失败而不是被应用。把开关放在仓库 `.env` 里的开发者需要改放到 shell——这是一次刻意且响亮的破坏。 +- composition 不再会被陈旧的 shell endpoint 覆盖。但它仍然会被用户已存的 `settings.yaml` 覆盖,这是 settings seam 的分层方式,本 Note 不改变它;产品 CLI 没有高于它的标志,因此需要压过已存 settings 的部署方要自带 bin 或 loader 配置树。 +- 未解决的:各层仍然会被物化进 `process.env`,因此普通项目变量继续按子进程清洗规则抵达子进程。bootstrap 变量完全不能来自文件;其余变量抵达子进程的限制记录在环境包中。 +- Exa 与 Perplexity 仍在加载时捕获密钥,而不是经凭据 seam。它们不再读裸 `process.env`——改为经受信层解析——但把它们改造成按请求经 seam 解析是另一件事。 + +## Alternatives considered + +**按「来源由谁书写」把凭据并入非密钥顺序。** 尝试过并放弃:它读起来很顺,但 settings seam 已经把 composition 固定在用户 section *之下*,因此「部署授权」根本不是该 seam 能表达的一层;而把 `.credentials.yaml` 抬到启动环境之上,会夺走 CI、容器和一次性 `DEEPSEEK_API_KEY=…` 所依赖的那唯一一种覆盖。两条各自说清自身形状成因的顺序,好过一条两边都描述不准的顺序。 + +**在项目被显式信任之前,不给它路由与凭据能力。** 作为产品立场被否决:checkout 默认可信,不询问,也不存储信任记录。残留风险是真实的、值得写明——克隆一个携带 `.env`、其中指定了另一个 endpoint 或密钥的仓库,会让该会话经由它——处理它的地方是日后的 project trust 门禁,而不是一条让常见情形都要走仪式的规则。 + +**审查出一份 `.env` 可设置的 `DSH_*` 白名单。** 否决:每新增一个开关都要重新审查,而遗漏的失败模式是静默的。拒绝整个命名空间是 fail safe。 + +**把 bootstrap 变量排在 process 层之下,而不是拒绝它。** 否决:`PATH` 和 `NODE_OPTIONS` 没有有意义的「输了之后」行为——把它写进 `.env` 的用户认为它生效,而静默忽略正是整个系列要消除的那种「我的设置没有效果」。 + +**把快照做成三包能力 seam(`environment` / `environment-local` / 消费方)。** 作为过早拆分而否决:生产方在 Cordis 存在之前就运行,也没有第二个实现需要选择。仓库规则是不要预先拆分。 + +**不再把各层物化进 `process.env`。** 延后而非否决:它能让项目变量彻底进不了子进程,但会静默破坏任何读 `!!js process.env.X` 的用户 patch 层。快照已经是 harness 解析一切的依据,因此这件事以后落地也不改变任何 ladder。 diff --git a/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-04-credentials-yaml-and-user-environment-layer.i18n.yaml similarity index 52% rename from .agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.i18n.yaml rename to .agents/notes/implemented/architecture/2026-08-04-credentials-yaml-and-user-environment-layer.i18n.yaml index b74717afb9..ba4444166c 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-04-credentials-yaml-and-user-environment-layer.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/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 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-04-credentials-yaml-and-user-environment-layer.md +2026-08-04-credentials-yaml-and-user-environment-layer.md: 4ecbc41adf4e22c74ecf425c2caf628efdf7cf54 +2026-08-04-credentials-yaml-and-user-environment-layer.zh.md: 370179b442783f4f8ecd8e3badbd236a924f5f81 diff --git a/.agents/notes/implemented/architecture/2026-08-04-credentials-yaml-and-user-environment-layer.md b/.agents/notes/implemented/architecture/2026-08-04-credentials-yaml-and-user-environment-layer.md new file mode 100644 index 0000000000..4ecbc41adf --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-04-credentials-yaml-and-user-environment-layer.md @@ -0,0 +1,49 @@ +# Agent Note: Splitting the credential store from the user environment layer + +Status: implemented + +English | [中文](2026-08-04-credentials-yaml-and-user-environment-layer.zh.md) + +## Problem + +`$DSH_HOME/.env` carried two incompatible jobs. It was the writable secret store of [`credentials-local`](../../../../packages/credentials/credentials-local/README.md), so no surface could hoist it into `process.env` — hoisting would make every stored key read as a read-only launch override and block rotation from the Models page. But its name and dotenv format promise an environment file, so users put non-secrets in it and those values reached nothing: a `DEEPSEEK_BASE_URL` beside a working `DEEPSEEK_API_KEY` in the same file was silently ignored, because only the credential provider read the document and it addresses credential references alone. + +One file cannot be both a store the Harness owns and isolates and a layer that propagates by ordinary environment rules. The [request-level credential decision](2026-07-29-request-level-llm-config-credentials.md) chose dotenv to match peer products' home `.env`, and the conflation was not visible until a non-secret needed the same file. + +## Decision + +The two jobs become two files under the Harness home. + +**`.credentials.yaml` is the provider-managed store.** A strict YAML mapping of `CredentialRef` to non-empty string, with no `version` field and no wrapper level: + +```yaml +DEEPSEEK_API_KEY: sk-… +OPENAI_API_KEY: sk-… +``` + +Because the document holds credentials and nothing else, every deviation is a rejection rather than a skipped entry: a non-mapping root, a key that is not a POSIX identifier, a non-string value, an empty string, a duplicate key, and malformed YAML all fail — loud at boot and at a write, warn-and-keep-the-last-good-snapshot on a live reload. A silently ignored key would read as "the secret I stored has no effect", which is the failure this change exists to remove. The dotenv physical-line editor is replaced by a patch of the parsed document, so comments and untouched entries keep their formatting, any string value round-trips (multi-line included), and no entry is unwritable for want of a quoting style. The writer lock, read-modify-write, atomic `0600` write under a `0700` directory, exact-path watcher, content-equality self-write suppression, and quiescent disposal are unchanged. + +**`$DSH_HOME/.env` is the user's ordinary environment layer.** `loadLayeredEnv` in [`dsh-app-boot`](../../../../packages/ui/app-boot/README.md) parses the invoking directory's `.env` and then the Harness home's, giving `user < project < inherited` by materializing each accepted value only when the process has no higher-layer value. The Harness home is resolved from the inherited environment *before* either file loads, so a project `.env` cannot redirect which user document is read. Only the product CLI layers these files; SDK and example bins keep loading their own directory through `loadEnv` and must not inherit a developer's `$DSH_HOME`. + +Credential precedence distinguishes the inherited environment from discovered files: the inherited value stays the read-only per-run override, the managed document wins next, and project then user `.env` values remain writable fallbacks. A `set` therefore replaces a discovered-file value instead of rejecting a write that only the flattened `process.env` view would consider shadowed. + +There is no migration. A key already in `$DSH_HOME/.env` keeps resolving as a fallback, while the managed document wins as soon as the Models page stores that reference. + +## Consequences + +- Given up: a key left in `$DSH_HOME/.env` is materialized into `process.env`, so it reaches subprocesses under the [subprocess credential scrub](../../../../packages/subprocess/subprocess/README.md) rather than staying inside the provider. It remains a writable fallback below `.credentials.yaml`; a secret the Harness should own and isolate belongs in the managed document, which is never materialized. +- Bought: a non-secret in the user's `.env` finally takes effect, which was the original defect; the document format can reject what it cannot serve; and `0600` covers a file that holds only secrets instead of a file users are told to put ordinary configuration in. +- The `0600` the provider writes is also enforced on what it reads: on POSIX, a document with any group or other permission bit fails the launch before its contents are read, at boot and on every reload, and the diagnostic names the `chmod 600` repair. Windows has no mode to inspect — its ACLs are not expressible here — so the check is skipped rather than faked. +- The `0600` boundary still stops other OS users and not the model, unchanged by this split — the [provider README](../../../../packages/credentials/credentials-local/README.md) owns that limit and the keychain-provider deferral. + +## Alternatives considered + +**Keep one `$DSH_HOME/.env` and teach the CLI to hoist it.** Rejected: hoisting the store is precisely what makes stored keys unrotatable, which is why [app-boot documented the exclusion](../../../../packages/ui/app-boot/README.md) in the first place. The conflict is the file's two jobs, not the loader. + +**`$DSH_HOME/.credentials.env` — a second dotenv file.** Rejected: dotenv suits an environment layer but cannot express "a managed document indexed by credential reference". It cannot reject a non-string or an unaddressable key, and its line editor already refused values it could not quote, leaving entries readable but unwritable. + +**Add a `version` field to the new document.** Rejected: the format is one schema-constrained string mapping with no historical variant to discriminate. While the product is unreleased, changing the structure and rejecting the old one beats promising a migration protocol. + +**Migrate credential-shaped keys out of `$DSH_HOME/.env` on first run.** Rejected: migration code turns a short-lived format into a long-lived maintenance surface, and classifying which keys in an unknown file are secrets is exactly the ambiguity this split removes. The old file keeps working as environment, which is a truthful outcome rather than a silent one. + +**Drop the user `.env` layer entirely and keep only the inherited environment.** Rejected here as out of scope: it is a coherent design (fewer layers, one place per value), but it removes a workflow users have, and the layering question belongs with the deferred precedence decision rather than with this split. diff --git a/.agents/notes/implemented/architecture/2026-08-04-credentials-yaml-and-user-environment-layer.zh.md b/.agents/notes/implemented/architecture/2026-08-04-credentials-yaml-and-user-environment-layer.zh.md new file mode 100644 index 0000000000..370179b442 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-04-credentials-yaml-and-user-environment-layer.zh.md @@ -0,0 +1,49 @@ +# Agent Note: 把凭据存储与用户环境层拆开 + +Status: implemented + +[English](2026-08-04-credentials-yaml-and-user-environment-layer.md) | 中文 + +## Problem + +`$DSH_HOME/.env` 同时承担了两件互不相容的工作。它是 [`credentials-local`](../../../../packages/credentials/credentials-local/README.md) 的可写密钥存储,因此任何表层都不能把它提升进 `process.env`——一旦提升,每个已存密钥都会读作只读的启动时覆盖,从而阻断从 Models 页轮换密钥。但它的文件名和 dotenv 格式承诺的是一个环境文件,于是用户把非密钥值放进去,而那些值哪儿也到不了:同一个文件里,一个能用的 `DEEPSEEK_API_KEY` 旁边的 `DEEPSEEK_BASE_URL` 会被静默忽略,因为只有凭据 provider 读这份文档,而它只寻址凭据引用。 + +一个文件无法既是由 Harness 拥有并隔离的存储,又是按普通环境规则传播的层。[请求级凭据决策](2026-07-29-request-level-llm-config-credentials.md)当初选择 dotenv 是为了对齐同类产品的 home `.env`,而这种混同直到有非密钥值需要用同一个文件时才暴露出来。 + +## Decision + +两件工作在 Harness home 下拆成两个文件。 + +**`.credentials.yaml` 是 provider 管理的存储。** 一个从 `CredentialRef` 到非空字符串的严格 YAML mapping,没有 `version` 字段,也没有包装层: + +```yaml +DEEPSEEK_API_KEY: sk-… +OPENAI_API_KEY: sk-… +``` + +因为该文档只存放凭据、别无他物,任何偏离都是拒绝而不是跳过条目:非 mapping 的根、非 POSIX 标识符的键、非字符串值、空字符串、重复键以及格式错误的 YAML 全部失败——启动时和写入时响亮失败,运行期热重载则告警并保留最后可用快照。被静默忽略的键读起来就是「我存进去的密钥没有生效」,而这正是本次变更要消除的失败。dotenv 物理行编辑器被替换为对已解析文档打补丁,因此注释与未触及条目的排版都会保留,任何字符串值都能往返(含多行),也不会再有条目因为缺少可用引号样式而不可写。写锁、read-modify-write、`0700` 目录下的 `0600` 原子写、精确路径 watcher、按内容相等抑制自写、以及 dispose 时的完全停稳,均保持不变。 + +**`$DSH_HOME/.env` 是用户的普通环境层。** [`dsh-app-boot`](../../../../packages/ui/app-boot/README.md) 中的 `loadLayeredEnv` 先解析调用目录的 `.env`,再解析 Harness home 的,并且只在进程中没有更高层值时物化每个已接受的值,从而得到 `用户 < 项目 < 继承`。Harness home 在两个文件加载*之前*就从继承的环境解析完毕,因此项目 `.env` 无法改变读取哪份用户文档。只有产品 CLI(命令行界面)叠加这两个文件;SDK 与示例 bin 仍通过 `loadEnv` 加载各自的目录,绝不继承开发者的 `$DSH_HOME`。 + +凭据优先级会区分继承环境与发现的文件:继承值仍是只读的按次覆盖,其后是受管文档,再后是仍可写的项目与用户 `.env` 后备值。因此 `set` 会替换发现文件中的值,而不是因为扁平化的 `process.env` 视图认为写入会被遮蔽就加以拒绝。 + +不做迁移。已经放在 `$DSH_HOME/.env` 里的密钥会继续作为后备值解析;Models 页一旦存储该引用,受管文档就会优先。 + +## Consequences + +- 放弃的:留在 `$DSH_HOME/.env` 里的密钥会被物化进 `process.env`,因而会按[子进程凭据清洗](../../../../packages/subprocess/subprocess/README.md)的规则抵达子进程,而不再留在 provider 内部。它仍是 `.credentials.yaml` 之下的可写后备值;需要由 Harness 拥有并隔离的密钥属于受管文档,后者永不物化。 +- 换来的:用户 `.env` 里的非密钥值终于生效,这正是最初的缺陷;文档格式可以拒绝它无法承担的内容;`0600` 保护的是一个只存密钥的文件,而不是一个我们同时叫用户往里写普通配置的文件。 +- provider 写入时用的 `0600` 同样约束它读取的内容:在 POSIX 上,只要文档带有任何 group 或 other 权限位,就会在读取内容之前让启动失败——启动时与每次 reload 都检查,诊断里给出 `chmod 600` 的修复命令。Windows 没有可检查的 mode(其 ACL 无法在此表达),因此跳过该检查而不是伪造它。 +- `0600` 这条边界仍然只挡其他 OS 用户、挡不住模型,本次拆分未改变这一点——该限制及 keychain provider 的延后项归 [provider README](../../../../packages/credentials/credentials-local/README.md) 所有。 + +## Alternatives considered + +**保留单一的 `$DSH_HOME/.env`,让 CLI 去提升它。** 否决:提升存储本身正是让已存密钥无法轮换的原因,这也是 [app-boot 当初记录该排除](../../../../packages/ui/app-boot/README.md)的理由。冲突来自这个文件的两份工作,而不是加载器。 + +**`$DSH_HOME/.credentials.env`——第二个 dotenv 文件。** 否决:dotenv 适合环境层,却无法表达「一份按凭据引用索引的受管文档」。它无法拒绝非字符串或无法寻址的键,而且它的行编辑器本来就会拒绝无法加引号的值,留下可读却不可写的条目。 + +**给新文档加 `version` 字段。** 否决:该格式只有一个受 schema 约束的字符串 mapping,没有需要判别的历史变体。在未发布阶段,直接修改结构并拒绝旧结构,好过提前承诺迁移协议。 + +**首次运行时把形似凭据的键从 `$DSH_HOME/.env` 迁出。** 否决:迁移代码会把短命格式变成长期维护面,而判断一个未知文件里哪些键是密钥,恰恰是本次拆分要消除的歧义。旧文件继续作为环境工作,这是诚实的结果,而不是静默的结果。 + +**彻底取消用户 `.env` 层,只保留继承的环境。** 在此处否决为超出范围:它本身是自洽的设计(层次更少、每个值只有一处来源),但会移除用户已有的工作流,而分层问题属于那个被延后的优先级决策,不属于本次拆分。 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-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/bug-fix/2026-08-06-api-key-format-validation.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.i18n.yaml index 123058ac7e..e1c3ac3ef8 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.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-06-api-key-format-validation.md -2026-08-06-api-key-format-validation.md: d1f6d31d362b76392514704be780f553b45d36ad -2026-08-06-api-key-format-validation.zh.md: 75b3fa247bdf449964a874e909e6e3bc9e0694fa +2026-08-06-api-key-format-validation.md: e9ca76ede06080f2b868f6436998d163e642adbc +2026-08-06-api-key-format-validation.zh.md: 5666a884d4c9478291072375681d8d3526b2632a diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.md b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.md index d1f6d31d36..e9ca76ede0 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.md +++ b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.md @@ -12,7 +12,7 @@ Pasting a key containing an emoji, CJK text, or a full-width punctuation mark in `llm-pi-ai` was worse on the same input. Its discovery probe builds the same header with a bare `fetch` in [discovery.ts](../../../../packages/llm/llm-pi-ai/src/discovery.ts) and wrapped every failure as `could not reach `, so a local key fault was reported as an unreachable network. The probe is reachable from the unsaved draft: `ProviderEditor` puts the typed `keyDraft` into its probe request, so the model-listing button sent an illegal key before anything was stored. -Whitespace passed every check. `ProviderEditor` tested `keyDraft.length` and `resolveAdapterOptions` tested `config.apiKey.length`, so a key of three spaces stored and then authenticated as `Bearer` plus blanks. `llm-pi-ai` rejected an empty literal `apiKey` in `resolveProfiles`, but applied no check whatsoever to a credential- or environment-sourced key — the path the Models page writes, and therefore the path users actually take. +Whitespace passed every check. `ProviderEditor` tested `keyDraft.length`, so a key of three spaces was stored and then authenticated as `Bearer` plus blanks. Neither adapter checked a credential- or environment-sourced key — the path the Models page writes, and therefore the path users actually take. Sources: deepseek-harness#1594 and #1595; dsh-external#247, #249, #266, and #210. @@ -32,13 +32,13 @@ The shape rule is a guess about how people paste, so it runs **only in the brows ### Absence is a configuration state, not a missing key -"No API key" means three different things here, and only one of them is an error. The rule applies to a value that was *provided*; deciding whether one was provided at all stays with each caller. +The rule applies to a value that was *provided*; deciding whether one was provided at all stays with each caller. -**Omitted.** A profile naming neither `apiKey` nor `apiKeyEnv` is authenticated by something other than a harness-held key. `routeAuth` in [provider.ts](../../../../packages/llm/llm-pi-ai/src/provider.ts) keeps the installed catalog provider's own auth precisely so provider-native ambient discovery survives, and `openai-codex` — shipped in that catalog — authenticates through OAuth and refuses an explicit key outright. `namesCredential` carries this distinction. In `llm-deepseek`, an absent `apiKey` likewise falls through to `apiKeyEnv`. Omission is never validated. +**No named credential.** A pi-ai profile omitting `apiKeyEnv` may authenticate outside the harness-held credential path. `routeAuth` in [provider.ts](../../../../packages/llm/llm-pi-ai/src/provider.ts) keeps the installed catalog provider's own auth precisely so provider-native ambient discovery survives, and `openai-codex` — shipped in that catalog — authenticates through OAuth. `namesCredential` carries this distinction; omission is not a value to validate. **A blank field in the web UI.** The key input opens empty even for a provider whose key is already stored — the `keyStored` copy reads "Configured — enter a new value to replace" — so blank means *keep what is stored*. `ProviderEditor` skips `credentials.set` entirely when the draft is empty, and that stays a no-op: a blank field never blocks submit, or editing a base URL would demand re-entering the key. -**Provided, but empty or whitespace-only.** What this means depends on what absence selects for that surface, and the two adapters differ for a reason. In `llm-pi-ai` it is an error, because absence there switches authentication mode — to the installed provider's ambient discovery or OAuth — so a blank key leaves genuine ambiguity about which was meant; its wording names the legitimate alternative rather than just refusing (*has an empty apiKey; omit it to use ambient authentication*). In `llm-deepseek` absence merely selects a different *source* for the same key, `apiKeyEnv`, so a blank literal resolves through that fallback exactly as an omitted one does. In the browser it is always a failure, on both cards: the field is where a person just typed, and silently discarding what they typed is never the right answer. +**A resolved value that is whitespace-only.** This is invalid at both adapters because it cannot authenticate a request. In the browser it is also a field-level failure: the field is where a person just typed, and silently discarding what they typed is never the right answer. `normalizeApiKey` therefore takes `string`, never `string | undefined`. @@ -55,9 +55,7 @@ The client cannot import any of this: client packages reference only client pack | Surface | Behavior | |---|---| | `dsh-llm` | Owns `normalizeApiKey`, `assertUsableApiKey`, and `INVALID_CREDENTIAL_CODE`, which is deliberately outside `DEFAULT_RETRYABLE_CODES`. | -| `llm-deepseek` `resolveAdapterOptions` | Refuses a literal `apiKey` no header can carry, beside the other beyond-schema bounds; uses the trimmed value. An absent or blank one falls through to `apiKeyEnv`. | | `llm-deepseek` `resolveApiKey` | Normalizes what the credentials seam or environment returns, rejecting with `INVALID_CREDENTIAL` naming the Models page and never echoing the key. | -| `llm-pi-ai` `resolveProfiles` | Applies the shared rule, keeping its "omit it to use ambient authentication" wording, and writes the trimmed value into the resolved profile. | | `llm-pi-ai` `resolveApiKey` | Normalizes the credential and environment paths. A profile naming no credential still returns `undefined`, so ambient and OAuth routes are unaffected. | | `llm-pi-ai` `discoverModels` | Normalizes before building the header, so an illegal key is a credential fault rather than an unreachable endpoint. A probe carrying no key stays unauthenticated. | | `ui-models` | Mirrors the charset rule, adds the shape heuristic, trims `keyDraft` before probe and `credentials.set`, and fixes the `stringAt` emptiness test. A blank field remains a no-op that submits; a field holding only whitespace is a field-level failure. Submit **and the endpoint interrogation** are both gated, so a refused key never spends a round trip to be told what the field already says, and the failure renders on the field, matching the existing `modelFailure` pattern. | @@ -68,8 +66,6 @@ The client cannot import any of this: client packages reference only client pack ## Alternatives considered -**A `.pattern()` on the `apiKey` schema field.** Vendored schemastery supports it, and the pattern would serialize to the browser with the rest of the namespace schema — one rule, delivered rather than mirrored. It lost because a pattern cannot trim first: `cordis.yml` would then reject a padded key while `.env` tolerated one, and the resolver would disagree with the schema about the same string. Validating in `resolveAdapterOptions` keeps every surface trim-then-validate, and that function is already where this package re-judges bounds the schema cannot express. - **A validation module shared by client and host.** Rejected by the source-plane layout: client packages reference only client packages plus `vendor/cordis` and `support/invariants`, and widening that to reach a host package would collide the two `Context` merges the split exists to keep apart. Mirroring a one-line predicate with a test on each side is the established shape here. **A per-adapter thrower in each of `llm-deepseek` and `llm-pi-ai`.** The first plan gave each adapter its own, differing only by the package prefix in the message, with a duplication-gate exemption to excuse the pair. Rejected before implementation: `LlmError` is declared in the seam, so the seam can own the diagnosis outright, and an exemption there would have hidden exactly the duplication it was covering for. @@ -100,7 +96,7 @@ The costliest way to get this wrong would have been to treat absence as invalidi `packages/llm/llm/tests/api-key.spec.ts` drives `normalizeApiKey` and `assertUsableApiKey` over the whole input table — empty, whitespace-only, padded, interior-space, C0 control, emoji, CJK, full-width, latin-1, and the printable-ASCII boundary — and pins that a refusal carries `INVALID_CREDENTIAL` and no part of the key. -`packages/llm/llm-deepseek/tests/` covers the literal-config path in `adapter.spec.ts` and the stored-credential path end to end in `dynamic-config.spec.ts`, through the real credentials seam rather than a stub. `packages/llm/llm-pi-ai/tests/` covers `resolveProfiles` — including that the trimmed value reaches the resolved profile, which the `...rest` spread would otherwise discard — and the discovery probe, including that a probe with no key sends no `authorization` header. +`packages/llm/llm-deepseek/tests/` covers the stored-credential path end to end in `dynamic-config.spec.ts`, through the real credentials seam rather than a stub. `packages/llm/llm-pi-ai/tests/` covers the discovery probe, including that a probe with no key sends no `authorization` header. `packages/client/ui-models/tests/` pins `apiKeyFailure` over the same table plus the paste-shape cases, and drives both cards: a blank field submits without writing a credential, a whitespace-only field fails on the field, an illegal or wrapped key blocks submit and the interrogation alike, a padded key is trimmed before `credentials.set` and before an interrogation, and a hand-declared route can be created with no key at all. diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.zh.md b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.zh.md index 75b3fa247b..5666a884d4 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.zh.md @@ -12,7 +12,7 @@ Status: implemented 同样的输入在 `llm-pi-ai` 上更糟。它的探测路径在 [discovery.ts](../../../../packages/llm/llm-pi-ai/src/discovery.ts) 里用裸 `fetch` 构造同一个 header,并把一切失败包装成 `could not reach `,于是一个本地的 Key 故障被报成网络不可达。这条探测在保存之前就够得着:`ProviderEditor` 把用户输入的 `keyDraft` 直接放进探测请求,所以「获取模型列表」按钮会在任何东西落盘之前就把非法 Key 发出去。 -空白字符能通过每一道检查。`ProviderEditor` 判的是 `keyDraft.length`,`resolveAdapterOptions` 判的是 `config.apiKey.length`,于是三个空格构成的 Key 会被存下,随后以 `Bearer` 加若干空格去认证。`llm-pi-ai` 在 `resolveProfiles` 中拒绝空的字面量 `apiKey`,却对来自凭据或环境的 Key 完全不做检查——而那正是模型设置页写入的路径,也就是用户真正走的路径。 +空白字符能通过每一道检查。`ProviderEditor` 判的是 `keyDraft.length`,于是三个空格构成的 Key 会被存下,随后以 `Bearer` 加若干空格去认证。两个适配器都不检查来自凭据或环境的 Key——而那正是 Models 页写入的路径,也就是用户真正走的路径。 来源:deepseek-harness#1594 与 #1595;dsh-external#247、#249、#266、#210。 @@ -32,13 +32,13 @@ Status: implemented ### 「没有 Key」是一种配置状态,不是缺失 -在这里,「没有 API Key」意味着三件完全不同的事,其中只有一件是错误。规则作用于**已提供**的值;至于究竟有没有提供,由各个调用方自行判断。 +规则作用于*已提供*的值;至于究竟有没有提供,由各个调用方自行判断。 -**未指定。** 既不写 `apiKey` 也不写 `apiKeyEnv` 的 profile,是由 harness 所持有的 Key 之外的东西来鉴权的。[provider.ts](../../../../packages/llm/llm-pi-ai/src/provider.ts) 中的 `routeAuth` 保留内置 catalog provider 自身的鉴权,正是为了让 provider 原生的 ambient 发现得以存活;而该 catalog 附带的 `openai-codex` 通过 OAuth 鉴权,并会直接拒绝一个显式的 Key。`namesCredential` 承载着这一区分。在 `llm-deepseek` 中,缺省的 `apiKey` 同样会回落到 `apiKeyEnv`。未指定的情形永不参与校验。 +**未点名凭据。** 省略 `apiKeyEnv` 的 pi-ai profile 可以在 harness 持有的凭据路径之外鉴权。[provider.ts](../../../../packages/llm/llm-pi-ai/src/provider.ts) 中的 `routeAuth` 保留内置 catalog provider 自身的鉴权,正是为了让 provider 原生的 ambient 发现继续工作;而该 catalog 附带的 `openai-codex` 通过 OAuth 鉴权。`namesCredential` 承载这一区分;省略不是需要校验的值。 **Web UI 中留空的输入框。** 即便某个 provider 的 Key 已经存好,该输入框也是空着打开的——`keyStored` 的文案写的是「已配置——输入新值以替换」——所以留空意味着*保持已存储的值*。`ProviderEditor` 在草稿为空时完全跳过 `credentials.set`,这一点保持不变:留空绝不拦截提交,否则改一个 base URL 都得重新输一遍 Key。 -**已提供,但为空或纯空白。** 它意味着什么,取决于「缺失」在该界面上选中了什么,而两个适配器的差异是有依据的。在 `llm-pi-ai` 中它是错误,因为那里的缺失切换的是**鉴权方式**——转向内置 provider 的 ambient 发现或 OAuth——因此一个空 Key 究竟想选哪一种是真有歧义;它的措辞指明了合法替代路径而非单纯拒绝(*has an empty apiKey; omit it to use ambient authentication*)。在 `llm-deepseek` 中,缺失只是为同一把 Key 选择了另一个**来源** `apiKeyEnv`,因此空白字面量会像缺省一样经该回落解析。在浏览器中它始终是失败,两张卡片皆然:字段是人刚刚敲过字的地方,静默丢弃他敲进去的内容永远不是正确答案。 +**解析得到的值只含空白。** 两个适配器都将其视为非法,因为它无法为请求鉴权。在浏览器中,这同样是字段级失败:字段是人刚刚敲过字的地方,静默丢弃他敲进去的内容永远不是正确答案。 因此 `normalizeApiKey` 接受 `string`,而绝非 `string | undefined`。 @@ -55,9 +55,7 @@ Status: implemented | 界面 | 行为 | |---|---| | `dsh-llm` | 拥有 `normalizeApiKey`、`assertUsableApiKey` 与 `INVALID_CREDENTIAL_CODE`,后者刻意不进 `DEFAULT_RETRYABLE_CODES`。 | -| `llm-deepseek` `resolveAdapterOptions` | 拒绝标头无法承载的字面量 `apiKey`,与其他超出 schema 的边界检查并排;使用 trim 后的值。缺省或空白的 `apiKey` 回落到 `apiKeyEnv`。 | | `llm-deepseek` `resolveApiKey` | 归一化凭据 seam 或环境返回的值,以 `INVALID_CREDENTIAL` 拒绝,消息指明模型设置页,绝不回显 Key。 | -| `llm-pi-ai` `resolveProfiles` | 施加这条共享规则,保留其「omit it to use ambient authentication」的措辞,并把 trim 后的值写进解析后的 profile。 | | `llm-pi-ai` `resolveApiKey` | 归一化凭据与环境路径。不指定任何凭据的 profile 仍返回 `undefined`,ambient 与 OAuth 路由不受影响。 | | `llm-pi-ai` `discoverModels` | 在构造 header 之前归一化,使非法 Key 成为凭据故障而非端点不可达。不带 Key 的探测保持未鉴权。 | | `ui-models` | 镜像字符集规则,加入形状启发式,在探测与 `credentials.set` 之前 trim `keyDraft`,并修正 `stringAt` 的空值判断。留空的输入框仍是可以提交的空操作;只含空白的输入框则是字段级失败。提交**与端点探测**同时受拦截,因此被拒绝的密钥不会白花一次往返去换取字段上已经写明的答案;失败呈现在字段上,与既有的 `modelFailure` 模式一致。 | @@ -68,8 +66,6 @@ Status: implemented ## Alternatives considered -**在 `apiKey` schema 字段上加 `.pattern()`。** vendor 中的 schemastery 支持它,且该 pattern 会随命名空间 schema 一同序列化到浏览器——一条规则,投递而非镜像。它落败于 pattern 无法先行 trim:那样 `cordis.yml` 会拒绝带首尾空白的 Key 而 `.env` 却容忍,resolver 与 schema 会对同一个字符串给出分歧。在 `resolveAdapterOptions` 中校验可以让每一层都是 trim-then-validate,而该函数本就是本包重新裁定 schema 无法表达的边界之处。 - **由 client 与 host 共享一个校验模块。** 被 source plane 布局否决:client 包只 reference client 包外加 `vendor/cordis` 与 `support/invariants`,把它放宽到够得着 host 包会撞上这一分割本就要隔开的两份 `Context` 合并。在两侧各镜像一行断言并各配一份测试,是此处的既定形态。 **在 `llm-deepseek` 与 `llm-pi-ai` 中各留一个抛错 helper。** 最初的计划正是各留一份,差别仅在消息中的包名前缀,并配一个重复检测豁免来放行这一对。在实现之前即被否决:`LlmError` 声明在 seam 中,因此 seam 完全可以自己拥有这句诊断,而那里的一个豁免恰恰会掩盖它本要遮掩的重复。 @@ -100,7 +96,7 @@ Status: implemented `packages/llm/llm/tests/api-key.spec.ts` 以整张输入表驱动 `normalizeApiKey` 与 `assertUsableApiKey`——空值、纯空白、带首尾空白、含中间空格、C0 控制字符、emoji、中文、全角、latin-1,以及可打印 ASCII 的边界字符——并钉住一次拒绝携带 `INVALID_CREDENTIAL` 且不含 Key 的任何部分。 -`packages/llm/llm-deepseek/tests/` 在 `adapter.spec.ts` 中覆盖字面量配置路径,在 `dynamic-config.spec.ts` 中经真实凭据 seam(而非 stub)端到端覆盖已存储凭据路径。`packages/llm/llm-pi-ai/tests/` 覆盖 `resolveProfiles`——包括 trim 后的值确实到达解析后的 profile,否则会被 `...rest` 展开丢弃——以及探测路径,包括不带 Key 的探测不会发出 `authorization` 标头。 +`packages/llm/llm-deepseek/tests/` 在 `dynamic-config.spec.ts` 中经真实凭据 seam(而非 stub)端到端覆盖已存储凭据路径。`packages/llm/llm-pi-ai/tests/` 覆盖探测路径,包括不带 Key 的探测不会发出 `authorization` 标头。 `packages/client/ui-models/tests/` 以同一张表加上形状用例钉住 `apiKeyFailure`,并驱动两张卡片:留空的输入框可提交且不写入凭据、只含空白的输入框在字段上失败、非法或被包裹的 Key 同时拦截提交与探测、带首尾空白的 Key 在 `credentials.set` 与探测之前被 trim,以及手工声明的路由可以完全不带 Key 创建。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-provider-credential-lifecycle.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-06-provider-credential-lifecycle.i18n.yaml index 9f16a183b9..de0b20d867 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-provider-credential-lifecycle.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-06-provider-credential-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-08-06-provider-credential-lifecycle.md -2026-08-06-provider-credential-lifecycle.md: ce45207e7ac7224f44e34945e36ba85db0971f09 -2026-08-06-provider-credential-lifecycle.zh.md: c476417517b8ed72036344a13720a8ba378775e6 +2026-08-06-provider-credential-lifecycle.md: c28788921e8f1b233b44e19b29ad4d4acaa25022 +2026-08-06-provider-credential-lifecycle.zh.md: 2ea3b21fb6ceb4fa38a0cad0daf47c3b6a98a664 diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-provider-credential-lifecycle.md b/.agents/notes/implemented/bug-fix/2026-08-06-provider-credential-lifecycle.md index ce45207e7a..c28788921e 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-provider-credential-lifecycle.md +++ b/.agents/notes/implemented/bug-fix/2026-08-06-provider-credential-lifecycle.md @@ -12,7 +12,7 @@ The Models editor spans independent settings and credential RPC domains. It prev Provider save remains a two-stage settings-then-credentials operation over the existing wire domains, but the card treats the successful settings response as a commit checkpoint. It replaces its comparison subtree and expected revision with the returned redacted descriptor before attempting `credentials.set`; if that second stage fails, the draft key and card stay visible, and retry produces no settings ops and repeats only the credential write. Genuine concurrent changes before the first settings commit still fail with `settings-conflict`. Typed keys are trimmed at the UI and direct DeepSeek resolver boundaries, and pi-ai records a derived reference only when the normalized key is non-empty; saving a blank key materializes an empty, reference-free profile for provider-native discovery. -Deletion removes a credential only when the joined row identifies the exact `_API_KEY` reference derived by this page and reports it configured and writable. It unsets that credential before the user-layer profile so a settings-stage failure leaves the row and its frozen target visible for retry; both unsets are idempotent. Custom references, environment credentials, missing credentials, and targets the join cannot identify are retained. The row's accessible Edit/Delete names and the destructive dialog title, description, and final action all use the same stable `Display Name (route-id)` identity, collapsing to the route id when both strings match. The dialog states whether the stored key will be removed and owns operation failures instead of replacing the whole page with a load-error banner. Rows expose API-key state only from the value-free join: a confirmed literal or referenced credential is a green solid dot, a confirmed missing named reference is a red solid dot, and reference-free provider-native authentication or unavailable credential enrichment has no dot. Each dot has accessible copy and a tooltip, while successful Apply uses the same provider identity in a local status message and never echoes secret material. +Deletion removes a credential only when the joined row identifies the exact `_API_KEY` reference derived by this page and reports it configured and writable. It unsets that credential before the user-layer profile so a settings-stage failure leaves the row and its frozen target visible for retry; both unsets are idempotent. Custom references, environment credentials, missing credentials, and targets the join cannot identify are retained. The row's accessible Edit/Delete names and the destructive dialog title, description, and final action all use the same stable `Display Name (route-id)` identity, collapsing to the route id when both strings match. The dialog states whether the stored key will be removed and owns operation failures instead of replacing the whole page with a load-error banner. Rows expose API-key state only from the value-free join: a confirmed referenced credential is a green solid dot, a confirmed missing named reference is a red solid dot, and reference-free provider-native authentication or unavailable credential enrichment has no dot. Each dot has accessible copy and a tooltip, while successful Apply uses the same provider identity in a local status message and never echoes secret material. ## Alternatives considered @@ -20,8 +20,8 @@ Deletion removes a credential only when the joined row identifies the exact `_API_KEY` 引用,并将其报告为已配置且可写时,删除操作才会清除该凭据。它会先取消设置该凭据,再取消设置用户层 profile;如果 settings 阶段失败,该行及其已冻结的目标仍可见,便于重试。两项 unset 都具备幂等性。自定义引用、环境凭据、缺失的凭据,以及联接无法识别目标的凭据均会保留。行的无障碍 Edit/Delete 名称以及破坏性对话框的标题、说明和最终操作都使用同一个稳定的 `Display Name (route-id)` 标识;当两个字符串相同时,标识会简化为路由 id。对话框会说明是否一并删除已存密钥,并在自身内显示操作失败,而不是用加载错误横幅替换整个页面。行只根据不含值的联接结果展示 API 密钥状态:确认已配置的字面密钥或引用凭据显示为绿色实心点,确认缺失的具名引用显示为红色实心点,无引用的提供方原生认证或无法取得凭据补充信息时则不显示状态点。每个状态点都有无障碍文案和工具提示;「应用」成功后的本地状态消息会使用同一个提供方标识,且绝不回显任何机密内容。 +只有当联接所得的行识别出该页面派生的精确 `_API_KEY` 引用,并将其报告为已配置且可写时,删除操作才会清除该凭据。它会先取消设置该凭据,再取消设置用户层 profile;如果 settings 阶段失败,该行及其已冻结的目标仍可见,便于重试。两项 unset 都具备幂等性。自定义引用、环境凭据、缺失的凭据,以及联接无法识别目标的凭据均会保留。行的无障碍 Edit/Delete 名称以及破坏性对话框的标题、说明和最终操作都使用同一个稳定的 `Display Name (route-id)` 标识;当两个字符串相同时,标识会简化为路由 id。对话框会说明是否一并删除已存密钥,并在自身内显示操作失败,而不是用加载错误横幅替换整个页面。行只根据不含值的联接结果展示 API 密钥状态:确认已配置的引用凭据显示为绿色实心点,确认缺失的具名引用显示为红色实心点,无引用的提供方原生认证或无法取得凭据补充信息时则不显示状态点。每个状态点都有无障碍文案和工具提示;「应用」成功后的本地状态消息会使用同一个提供方标识,且绝不回显任何机密内容。 ## 曾考虑的替代方案 @@ -20,8 +20,8 @@ Models 编辑器横跨互相独立的 settings 与凭据 RPC 领域。之前它 **删除被移除 profile 所指定的每一个凭据引用。**自定义引用可能被共享、由外部管理,或有意在 profile 反复增删时存留。与该页面派生目标精确相等,再加上已配置且可写的状态,是页面所能获得的最小范围证据;比这更弱的判定都有可能删除不属于它的凭据。 -**先删除 settings,再重建 profile 以作补偿。**浏览器只持有脱敏后的子树,无法忠实重建已存的字面机密或并发编辑。先删除凭据可以让权威 profile 在部分失败时仍然可见,并且无需合成配置就能安全重试。 +**先删除 settings,再重建 profile 以作补偿。**浏览器只持有脱敏后的子树,无法忠实重建并发编辑。先删除凭据可以让权威 profile 在部分失败时仍然可见,并且无需合成配置就能安全重试。 ## 后果 -Models 页可以从任一第二阶段失败中恢复,无需重新加载,也不会泄露机密或产生虚假的并发冲突;空密钥的 pi-ai profile 会保留 Bedrock、Vertex 与其他提供方原生认证。已确认的状态清晰可见,同时不会把路由存活状态、原生认证或凭据查询失败误报为错误;即使该行继续显示绿色,密钥替换成功也仍然可观察。删除由页面管理的提供方不再遗留可重用的本地密钥,而存在歧义的凭据会有意保留,交由手动管理。保存与删除在跨持久存储时仍非原子操作:进程可能在两个阶段之间崩溃,但它们的顺序与幂等性会留下可观察、可重试的状态。组件测试固定了部分成功后的重试、空密钥原生认证、标准化字面值、状态可见性、目标标识、清理所有权,以及凭据/settings 拒绝顺序;无密钥的浏览器场景固定了双语无障碍文案,并验证确认删除会同时清除 `settings.yaml` profile 与 `.env` 凭据。此决策细化了 [web 配置平面 note](../architecture/2026-07-30-web-config-plane.md) 中记录的 Models 应用语义。 +Models 页可以从任一第二阶段失败中恢复,无需重新加载,也不会泄露机密或产生虚假的并发冲突;空密钥的 pi-ai profile 会保留 Bedrock、Vertex 与其他提供方原生认证。已确认的状态清晰可见,同时不会把路由存活状态、原生认证或凭据查询失败误报为错误;即使该行继续显示绿色,密钥替换成功也仍然可观察。删除由页面管理的提供方不再遗留可重用的本地密钥,而存在歧义的凭据会有意保留,交由手动管理。保存与删除在跨持久存储时仍非原子操作:进程可能在两个阶段之间崩溃,但它们的顺序与幂等性会留下可观察、可重试的状态。组件测试固定了部分成功后的重试、空密钥原生认证、密钥首尾空白处理、状态可见性、目标标识、清理所有权,以及凭据/settings 拒绝顺序;无密钥的浏览器场景固定了双语无障碍文案,并验证确认删除会同时清除 `settings.yaml` profile 与 `.credentials.yaml` 条目。此决策细化了 [web 配置平面 note](../architecture/2026-07-30-web-config-plane.md) 中记录的 Models 应用语义。 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-deepseek-onboarding-credential-setup.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.i18n.yaml index 34baccb6f5..418c81f17d 100644 --- a/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.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-30-deepseek-onboarding-credential-setup.md -2026-07-30-deepseek-onboarding-credential-setup.md: c732758bc567376a0be4ac348aa9129aa8126ac4 -2026-07-30-deepseek-onboarding-credential-setup.zh.md: 2dc7e0ccf5f9a99ad35c859a7ecfb9f98d93d530 +2026-07-30-deepseek-onboarding-credential-setup.md: 419ea0aea56e82e301189d90d5ca78495da2da71 +2026-07-30-deepseek-onboarding-credential-setup.zh.md: 936402c9ed83eccc3d5e78a57247f06347522c0f diff --git a/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.md b/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.md index c732758bc5..419ea0aea5 100644 --- a/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.md +++ b/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.md @@ -10,7 +10,7 @@ The [web configuration plane](../architecture/2026-07-30-web-config-plane.md) ma ## Decision -**One readiness projection owns both Models and onboarding facts.** `ui-models` keeps a single store that joins `llm.providers({})`, redacted `settings.describe({})`, and batched `credentials.describe({refs})`. The onboarding projection selects the `deepseek-official` configurable-provider entry owned by the `llm-deepseek` namespace and empty settings path, reads the effective `apiKeyEnv`, and evaluates the matching credential descriptor. A live route with the same provider id but no matching configurable-provider declaration is adapter-absent for onboarding. A configured literal `apiKey` secret sidecar is also ready, so compatibility configuration does not trigger a false prompt; a configured process-environment credential is ready and remains read-only. +**One readiness projection owns both Models and onboarding facts.** `ui-models` keeps a single store that joins `llm.providers({})`, redacted `settings.describe({})`, and batched `credentials.describe({refs})`. The onboarding projection selects the `deepseek-official` configurable-provider entry owned by the `llm-deepseek` namespace and empty settings path, reads the effective `apiKeyEnv`, and evaluates the matching credential descriptor. A live route with the same provider id but no matching configurable-provider declaration is adapter-absent for onboarding. A configured process-environment credential is ready and remains read-only. **The settings shell contributes ordering and navigation, not provider policy.** `ui-settings` declares a root-scoped `settings.onboarding` list slot and mounts one ordered step at a time while the current surface is the empty Hero. The active registrant receives `complete()` and a private `openSection(id)` callback; completion transfers ownership to the next entry. `ui-models` registers the DeepSeek step and its Models section through `slots.inject()`, so each contribution follows its declaration lifetime without making plugin load order a contract, and independently contributed dialogs cannot stack. The product-wide welcome step that precedes it is owned separately by [the versioned welcome decision](2026-07-30-versioned-gui-welcome-onboarding.md). @@ -30,4 +30,4 @@ The [web configuration plane](../architecture/2026-07-30-web-config-plane.md) ma ## Consequences -The ordered flow leads from the product notice to the shipped adapter's existing editor without restarting: a keyless browser test boots the real Web composition under an isolated harness home, acknowledges the notice, follows the DeepSeek page to Models, stores a generated key through that page into the home's `.env`, verifies no key reaches DOM, ARIA, or browser console output, and confirms the running page reports configured. The full keyless Web replay lane also pins that a non-configurable replay route with the same provider id does not block unrelated journeys. Pure readiness and React tests pin literal, file, process-environment, missing-provider, missing-capability, navigation, cancellation, external-invalidation, and coordinator-transfer behavior. The flow deliberately inherits the configuration plane's documented base limitations rather than adding local secret storage, redaction, or settings replacement workarounds. +The ordered flow leads from the product notice to the shipped adapter's existing editor without restarting: a keyless browser test boots the real Web composition under an isolated harness home, acknowledges the notice, follows the DeepSeek page to Models, stores a generated key through that page into the home's `.credentials.yaml`, verifies no key reaches DOM, ARIA, or browser console output, and confirms the running page reports configured. The full keyless Web replay lane also pins that a non-configurable replay route with the same provider id does not block unrelated journeys. Pure readiness and React tests pin managed-file and process-environment credentials, missing providers and capabilities, navigation, cancellation, external invalidation, and coordinator transfer. The flow deliberately inherits the configuration plane's documented base limitations rather than adding local secret storage, redaction, or settings replacement workarounds. diff --git a/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.zh.md b/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.zh.md index 2dc7e0ccf5..936402c9ed 100644 --- a/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决策 -**Models 与首次使用引导共享同一个就绪状态投影。**`ui-models` 维护一个 store,把 `llm.providers({})`、脱敏后的 `settings.describe({})` 和批量调用的 `credentials.describe({refs})` 联接为同一份状态。首次使用投影选取由 `llm-deepseek` namespace 与空 settings path 持有的 `deepseek-official` 可配置提供方条目,读取生效的 `apiKeyEnv`,并检查对应的凭据描述符。同 provider id 但没有匹配可配置提供方声明的存活路由,在首次使用引导中视为适配器缺失。若 `apiKey` 字面量对应的 secret 槽位标记为已设置,也会判定为就绪,兼容配置因此不会误触发页面;通过进程环境提供的凭据若已配置,同样判定为就绪并保持只读。 +**Models 与首次使用引导共享同一个就绪状态投影。**`ui-models` 维护一个 store,把 `llm.providers({})`、脱敏后的 `settings.describe({})` 和批量调用的 `credentials.describe({refs})` 联接为同一份状态。首次使用投影选取由 `llm-deepseek` namespace 与空 settings path 持有的 `deepseek-official` 可配置提供方条目,读取生效的 `apiKeyEnv`,并检查对应的凭据描述符。同 provider id 但没有匹配可配置提供方声明的存活路由,在首次使用引导中视为适配器缺失。通过进程环境提供的凭据若已配置,则判定为就绪并保持只读。 **设置外壳只贡献排序与导航,不持有提供方策略。** `ui-settings` 声明一个根作用域的 `settings.onboarding` list slot,并在当前界面为空白 Hero 时,每次只挂载一个有序步骤。当前注册方会收到 `complete()` 和私有 `openSection(id)` 回调;完成当前步骤后,所有权转交给下一项。`ui-models` 通过 `slots.inject()` 注册 DeepSeek 步骤及其 Models 分区,使每项贡献都跟随自身的声明生命周期,不让插件加载顺序成为契约;独立贡献的对话框也无法堆叠。排在它之前的产品级欢迎步骤由[版本化欢迎决策](2026-07-30-versioned-gui-welcome-onboarding.md)单独持有。 @@ -30,4 +30,4 @@ Status: implemented ## 后果 -有序流程从产品声明页开始,无需重启即可引导用户前往随产品提供的适配器已有的编辑器:无密钥浏览器测试在隔离的 harness 家目录下启动真实 Web 组合,确认声明后依照 DeepSeek 页面前往 Models,通过该页面把生成的密钥存入该目录的 `.env`,验证密钥未进入 DOM、ARIA 或浏览器控制台输出,并确认运行中的页面报告已配置。完整的无密钥 Web 回放也固定了同 id 的不可配置回放路由不会阻塞无关流程。纯就绪状态测试与 React 测试固化了字面量凭据、文件凭据、进程环境凭据、提供方缺失、能力缺失、导航、取消、外部失效和协调器移交行为。该流程直接继承配置平面已记录的基础限制,不会另加局部的机密存储、脱敏或设置替换变通方案。 +有序流程从产品声明页开始,无需重启即可引导用户前往随产品提供的适配器已有的编辑器:无密钥浏览器测试在隔离的 harness 家目录下启动真实 Web 组合,确认声明后依照 DeepSeek 页面前往 Models,通过该页面把生成的密钥存入该目录的 `.credentials.yaml`,验证密钥未进入 DOM、ARIA 或浏览器控制台输出,并确认运行中的页面报告已配置。完整的无密钥 Web 回放也固定了同 id 的不可配置回放路由不会阻塞无关流程。纯就绪状态测试与 React 测试固化了受管文件凭据与进程环境凭据、提供方与能力缺失、导航、取消、外部失效和协调器移交。该流程直接继承配置平面已记录的基础限制,不会另加局部的机密存储、脱敏或设置替换变通方案。 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..65c826786d 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 | @@ -56,7 +57,6 @@ External packages that a workspace package resolves at runtime. `scripts/install | [`clsx`](https://github.com/lukeed/clsx) | MIT | | [`commander`](https://github.com/tj/commander.js) | MIT | | [`diff`](https://github.com/kpdecker/jsdiff) | BSD-3-Clause | -| [`dotenv`](https://github.com/motdotla/dotenv) | BSD-2-Clause | | [`eventsource-parser`](https://github.com/rexxars/eventsource-parser) | MIT | | [`handlebars`](https://github.com/handlebars-lang/handlebars.js) | MIT | | [`immer`](https://github.com/immerjs/immer) | MIT | 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/cli/package.json b/apps/cli/package.json index 11078458e6..5e4f189517 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -41,6 +41,7 @@ "@deepseek-ai/dsh-frontend-static": "workspace:^", "@deepseek-ai/dsh-host-apiproxy": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", + "@deepseek-ai/dsh-llm-mock-server": "workspace:^", "@deepseek-ai/dsh-loader-smoke": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml index 22ed72dfb9..2b209d9e93 100644 --- a/apps/cli/reference/README.i18n.yaml +++ b/apps/cli/reference/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/cli/reference/README.md -README.md: 8264e2e9f57a6687a1cddae15012324790b555ad -README.zh.md: 84bd37e43a982a307a2df8045f7f3703e5b9a8b4 +README.md: 1715c4ba3cfc809fc5d6e1c9e150685ee75c55c0 +README.zh.md: 52ec89b28d21e00d62e38ae27e5742e2b663ae00 diff --git a/apps/cli/reference/README.md b/apps/cli/reference/README.md index 8264e2e9f5..1715c4ba3c 100644 --- a/apps/cli/reference/README.md +++ b/apps/cli/reference/README.md @@ -59,7 +59,7 @@ New sessions default to the `workspace-write` permission preset. Bash and filesy ## Shared deployment behavior -The base bundle mounts the native DeepSeek adapter, settings and credential providers, stable `web_search`, repository Plugin support, and session telemetry. Provider credentials live in `$DSH_HOME/.env` or the ambient environment and remain rotatable because the launcher never hoists the credential file into `process.env`. Search uses `DEEPSEEK_API_KEY` and accepts `DEEPSEEK_SEARCH_BASE_URL`; `web_fetch` is disabled unless a patch layer inserts a provider and enables it. +The base bundle mounts the native DeepSeek adapter, settings and credential providers, stable `web_search`, repository Plugin support, and session telemetry. Provider credentials resolve from the inherited environment, `$DSH_HOME/.credentials.yaml`, the invoking directory's `.env`, then `$DSH_HOME/.env`; the managed document is never materialized into `process.env`, while both `.env` files are ordinary launch environment layers. Search uses `DEEPSEEK_API_KEY` and accepts `DEEPSEEK_SEARCH_BASE_URL`; `web_fetch` is disabled unless a patch layer inserts a provider and enables it. Session events stream as OTLP/HTTP logs by default. `DSH_TELEMETRY_OTLP_URL` selects another collector. Any non-empty `DSH_TELEMETRY_DISABLED` disables the telemetry row before boot. The shipped base has no telemetry redaction rule, so exported records can contain message text, tool arguments and results, and workspace paths; the [telemetry Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md) owns that deployment decision. diff --git a/apps/cli/reference/README.zh.md b/apps/cli/reference/README.zh.md index 84bd37e43a..52ec89b28d 100644 --- a/apps/cli/reference/README.zh.md +++ b/apps/cli/reference/README.zh.md @@ -59,7 +59,7 @@ dsh web --dump-config ## 共享部署行为 -基础组合包挂载原生 DeepSeek 适配器、settings 与凭据提供方、稳定的 `web_search`、repository Plugin 支持和会话遥测。提供方凭据存放在 `$DSH_HOME/.env` 或环境中;启动器从不把凭据文件提升到 `process.env`,因此凭据可以轮换。搜索使用 `DEEPSEEK_API_KEY` 并接受 `DEEPSEEK_SEARCH_BASE_URL`;只有 patch 层插入提供方并启用 `web_fetch` 后,该工具才可用。 +基础组合包挂载原生 DeepSeek 适配器、settings 与凭据提供方、稳定的 `web_search`、repository Plugin 支持和会话遥测。提供方凭据依次从继承环境、`$DSH_HOME/.credentials.yaml`、调用目录的 `.env` 和 `$DSH_HOME/.env` 解析;受管文档从不物化进 `process.env`,而两个 `.env` 文件都是普通启动环境层。搜索使用 `DEEPSEEK_API_KEY` 并接受 `DEEPSEEK_SEARCH_BASE_URL`;只有 patch 层插入提供方并启用 `web_fetch` 后,该工具才可用。 会话事件默认作为 OTLP/HTTP 日志流式发送。`DSH_TELEMETRY_OTLP_URL` 选择其他 collector。任何非空 `DSH_TELEMETRY_DISABLED` 都会在启动前禁用遥测配置行。随附基础配置没有遥测脱敏规则,因此导出的记录可能包含消息文本、工具参数与结果以及 workspace 路径;该部署决策由[遥测 Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md)负责。 diff --git a/apps/cli/src/bin.ts b/apps/cli/src/bin.ts index 4783c2d175..4a209b2796 100644 --- a/apps/cli/src/bin.ts +++ b/apps/cli/src/bin.ts @@ -10,7 +10,7 @@ import { readFileSync } from 'node:fs' import { fileURLToPath } from 'node:url' -import { loadEnv } from '@deepseek-ai/dsh-app-boot' +import { loadLayeredEnv } from '@deepseek-ai/dsh-app-boot' import { parseDshArgs } from './args.ts' // Both the source tree (apps/cli/src) and the bundled bin (apps/cli/lib) sit @@ -24,13 +24,13 @@ function readVersion(): string { return typeof manifest.version === 'string' ? manifest.version : '0.0.0' } -loadEnv('dsh') const invocation = parseDshArgs(process.argv.slice(2), readVersion()) switch (invocation.mode) { case 'profile': { const { runProfile } = await import('./profile-boot.ts') await runProfile({ + environment: loadLayeredEnv('dsh'), profile: invocation.profile, patchFiles: invocation.patches, ...invocation.task !== undefined && { task: invocation.task }, @@ -39,7 +39,7 @@ switch (invocation.mode) { } case 'web': { const { runWeb } = await import('./web.ts') - await runWeb(invocation) + await runWeb(invocation, loadLayeredEnv('dsh')) break } case 'plugin': { diff --git a/apps/cli/src/profile-boot.ts b/apps/cli/src/profile-boot.ts index 6dab902845..e3f6615997 100644 --- a/apps/cli/src/profile-boot.ts +++ b/apps/cli/src/profile-boot.ts @@ -25,6 +25,7 @@ import { type Profile, } from '@deepseek-ai/dsh-app-boot' import { resolveDshHome } from '@deepseek-ai/dsh-paths' +import { DSH_ENVIRONMENT_KEY, type EnvironmentSnapshot } from '@deepseek-ai/dsh-environment' import type { HeadlessIo } from '@deepseek-ai/dsh-headless' import { createProcessShutdown, type ProcessShutdown } from './process-shutdown.ts' import { resolveWindowsShellLayer } from './windows-shell.ts' @@ -174,6 +175,8 @@ export interface RunProfileOptions { task?: string /** Surface setup registered after Loader installation and before any config-tree entry mounts. */ prepare?: (ctx: Context, rows: ProfileRows) => Promise | void + /** This run's frozen environment snapshot, provided to the tree before any entry mounts. */ + environment: EnvironmentSnapshot } /** @@ -238,6 +241,9 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con // application must not mutate the objects later reloads recompose from. const ctx = await boot(NAME, rootConfig, structuredClone(allPatches(composed)), async (hostCtx) => { app.current = hostCtx + // Before any config-tree entry mounts, so a plugin that resolves a + // user-facing value at construction already sees this run's layers. + hostCtx.provide(DSH_ENVIRONMENT_KEY, options.environment) if (options.task !== undefined) { const io: HeadlessIo = { stdout: process.stdout, diff --git a/apps/cli/src/web.ts b/apps/cli/src/web.ts index 64051416c4..72abee67d0 100644 --- a/apps/cli/src/web.ts +++ b/apps/cli/src/web.ts @@ -13,6 +13,7 @@ import { fileURLToPath } from 'node:url' import type { Context } from 'cordis' import type { PatchOptions } from '@cordisjs/plugin-include' import { addHarnessSourceSection } from '@deepseek-ai/dsh-app-boot' +import type { EnvironmentSnapshot } from '@deepseek-ai/dsh-environment' import { runProfile, type ProfileRows } from './profile-boot.ts' const SOURCE_ROOT = fileURLToPath(new URL('../../..', import.meta.url)) @@ -124,9 +125,11 @@ export function webSurfaceContextEnabled(rows: ProfileRows): boolean { * every boot. The URL line is printed by the web-app bundle's runtime row * after Loader settlement. * @param flags - the parsed `dsh web` flag family. + * @param environment - this run's frozen environment snapshot. */ -export async function runWeb(flags: WebFlags): Promise { +export async function runWeb(flags: WebFlags, environment: EnvironmentSnapshot): Promise { await runProfile({ + environment, profile: 'web', patchFiles: flags.patches, deriveFlagPatches: rows => deriveWebFlagPatches(rows, flags), diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index ede3d17134..20ed3fb160 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -2,6 +2,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' +import { startMockLlmServer } from '@deepseek-ai/dsh-llm-mock-server' import { execa } from 'execa' import { afterEach, beforeEach, describe, expect, it } from 'vitest' @@ -13,14 +14,21 @@ const invalidProvider = fileURLToPath(new URL('./fixtures/invalid-provider.cordi async function runBuiltBin( args: readonly string[] = [], - env: Record = {}, + env: Readonly> = {}, + cwd?: string, ): Promise<{ stdout: string; code: number; stderr: string }> { + const childEnv = Object.fromEntries( + Object.entries({ ...process.env, ...env }) + .filter((entry): entry is [string, string] => entry[1] !== undefined), + ) const result = await execa(process.execPath, [dshBin, ...args], { input: '', timeout: 25_000, killSignal: 'SIGKILL', reject: false, - env, + env: childEnv, + extendEnv: false, + ...cwd === undefined ? {} : { cwd }, }) if (result.timedOut) { throw new Error(`dsh built bin did not exit within 25s. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`) @@ -127,6 +135,44 @@ function startProfileLifecycle(fixture: ProfileLifecycleFixture) { }) } +function createEnvironmentProbeProfile(home: string, project: string): void { + const pluginFile = join(project, 'environment-probe.mjs') + writeFileSync(pluginFile, [ + "export const name = 'environment-probe'", + "export const inject = ['llm']", + 'export function apply(ctx) {', + ' void ctx.loader.await().then(async () => {', + " let text = ''", + ' for await (const chunk of ctx.llm.stream({', + " provider: 'deepseek-official',", + " model: 'deepseek-v4-flash',", + ' messages: [],', + ' maxTokens: 32,', + ' })) {', + " if (chunk.type === 'text-delta') text += chunk.text", + ' }', + ' process.stdout.write(`${text}\\n`)', + " process.kill(process.pid, 'SIGTERM')", + ' })', + '}', + '', + ].join('\n')) + const profileDir = join(home, 'profiles', 'environment-probe') + mkdirSync(profileDir, { recursive: true }) + writeFileSync(join(profileDir, 'package.json'), JSON.stringify({ + name: 'dsh-profile-environment-probe', + private: true, + dependencies: {}, + dsh: { profile: { bundles: ['@deepseek-ai/dsh-base'] } }, + }, undefined, 2)) + writeFileSync(join(profileDir, 'cordis.patch.yml'), [ + '- insert:', + ' - id: environment-probe', + ` name: ${pathToFileURL(pluginFile).href}`, + '', + ].join('\n')) +} + describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', () => { it('requires --profile and rejects removed commands', async () => { const bare = await runBuiltBin() @@ -144,6 +190,17 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', } }, 30_000) + it('does not load a project environment for --version', async () => { + const project = mkdtempSync(join(tmpdir(), 'dsh-version-project-')) + writeFileSync(join(project, '.env'), 'PATH=/project-only-path\n') + try { + const result = await runBuiltBin(['--version'], {}, project) + expect(result).toEqual({ code: 0, stdout: '0.0.1', stderr: '' }) + } finally { + rmSync(project, { recursive: true, force: true }) + } + }) + it('fails loud on a nonexistent profile with the plugin-command hint', async () => { const home = mkdtempSync(join(tmpdir(), 'dsh-missing-profile-')) try { @@ -156,6 +213,47 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', } }, 30_000) + it('uses the Harness-home environment and managed credential through the published entry', async () => { + const apiKey = 'built-home-layer-key' + const server = await startMockLlmServer({ + sequence: ['success'], + apiKey, + successText: 'home environment reached the mock', + }) + const home = mkdtempSync(join(tmpdir(), 'dsh-home-environment-')) + const project = mkdtempSync(join(tmpdir(), 'dsh-home-project-')) + writeFileSync(join(home, '.env'), `DEEPSEEK_BASE_URL=${server.baseURL}\n`) + writeFileSync(join(home, '.credentials.yaml'), `DEEPSEEK_API_KEY: ${apiKey}\n`, { mode: 0o600 }) + createEnvironmentProbeProfile(home, project) + try { + const result = await runBuiltBin( + ['--profile', 'environment-probe'], + { + DSH_HOME: home, + DSH_TELEMETRY_DISABLED: '1', + DEEPSEEK_API_KEY: undefined, + DEEPSEEK_BASE_URL: undefined, + }, + project, + ) + expect( + result.code, + `${result.stderr}\nstdout:\n${result.stdout}\nmock requests: ${String(server.requests.length)}`, + ).toBe(0) + expect(result.stdout).toBe('home environment reached the mock') + expect(result.stdout).not.toContain(apiKey) + expect(result.stderr).not.toContain(apiKey) + expect(server.requests).toHaveLength(1) + expect(server.requests[0]?.path).toBe('/chat/completions') + expect(server.requests[0]?.headers.authorization).toBe(`Bearer ${apiKey}`) + expect(JSON.stringify(server.requests[0]?.body)).not.toContain(apiKey) + } finally { + await server.close() + rmSync(home, { recursive: true, force: true }) + rmSync(project, { recursive: true, force: true }) + } + }, 30_000) + it('reports a patch-overlay boot failure without hanging', async () => { // The HMR main watcher's initial scan once refreshed the include // mid-initial-apply, deadlocking the failing apply's rollback against the 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 0e168ba9fe..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,18 +14,21 @@ import { afterEach, beforeEach, vi } from 'vitest' import type { WebBootEntry } from '@deepseek-ai/dsh-client-modules/client' import { AppWebEntry } from '@deepseek-ai/dsh-client-web' -/** Boot entries for the minimal assembled graph, each carrying the workspace directory its bundle is read from. */ -const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [ - { id: '@deepseek-ai/dsh-client-connection', dir: 'connection', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true }, - { id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true }, - { id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true }, - { id: '@deepseek-ai/dsh-client-locale', dir: 'locale', url: '/plugins/locale.js', rev: 'fx', inject: [], immediately: true }, - { id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] }, - { id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, - { id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, +/** Boot entries for the minimal assembled graph, each carrying the workspace bundle it loads. */ +const PLUGINS: readonly (WebBootEntry & { bundlePath: string })[] = [ + { id: '@deepseek-ai/dsh-typert-registry', bundlePath: 'packages/typert/registry/lib/client.js', url: '/plugins/typert-registry.js', rev: 'fx', inject: [], immediately: true }, + { id: '@deepseek-ai/dsh-client-connection', bundlePath: 'packages/client/connection/lib/client.js', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true }, + { id: '@deepseek-ai/dsh-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: [ @@ -34,12 +37,12 @@ const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [ '@deepseek-ai/dsh-client-ui-sidebar', ], }, - { id: '@deepseek-ai/dsh-client-ui-trajectory', dir: 'ui-trajectory', url: '/plugins/ui-trajectory.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-conversation'] }, + { id: '@deepseek-ai/dsh-client-ui-trajectory', bundlePath: 'packages/client/ui-trajectory/lib/client.js', url: '/plugins/ui-trajectory.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-conversation'] }, ] const bundles = new Map(PLUGINS.map(plugin => [ plugin.url, - readFileSync(join(process.cwd(), 'packages/client', plugin.dir, 'lib/client.js'), 'utf8'), + readFileSync(join(process.cwd(), plugin.bundlePath), 'utf8'), ])) interface FixtureWindow extends Window { @@ -97,7 +100,7 @@ export function mountAssembledApp(): void { const root = document.createElement('div') root.id = 'root' document.body.appendChild(root) - win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ dir: _dir, ...plugin }) => plugin) } + win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ bundlePath: _bundlePath, ...plugin }) => plugin) } act(() => { const entry = new AppWebEntry(root, { loadBundle: async (url) => { diff --git a/apps/web/tests/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/models-settings.e2e.ts b/apps/web/tests/models-settings.e2e.ts index d539c391c0..be3c6f18a1 100644 --- a/apps/web/tests/models-settings.e2e.ts +++ b/apps/web/tests/models-settings.e2e.ts @@ -134,7 +134,7 @@ describe('web e2e: Models settings page configures a dormant provider', () => { await dialog.getByRole('textbox', { name: 'API 密钥', exact: true }).fill('sk-e2e-minimax') await dialog.getByRole('button', { name: '保存', exact: true }).click() // The profile lands in settings.yaml with only the derived reference, the - // key value lands in the harness home's .env, the dormant route + // key value lands in the harness home's .credentials.yaml, the dormant route // registers, and the topology frame invalidates the page into the row. await expect.poll( async () => dialog.getByRole('textbox', { name: 'API 密钥', exact: true }).count(), @@ -146,11 +146,11 @@ describe('web e2e: Models settings page configures a dormant provider', () => { expect(document).toContain('minimax-cn:') expect(document).toContain('apiKeyEnv: MINIMAX_CN_API_KEY') expect(document).not.toContain('sk-e2e-minimax') - const credentialFile = join(scaffold.harnessHome, '.env') + const credentialFile = join(scaffold.harnessHome, '.credentials.yaml') await expect.poll( async () => readFile(credentialFile, 'utf8').catch(() => ''), { timeout: 10_000 }, - ).toContain('MINIMAX_CN_API_KEY=sk-e2e-minimax') + ).toContain('MINIMAX_CN_API_KEY: sk-e2e-minimax') expect(await page.content()).not.toContain('sk-e2e-minimax') }, 60_000) @@ -230,7 +230,7 @@ describe('web e2e: Models settings page configures a dormant provider', () => { async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'), { timeout: 10_000 }, ).not.toContain('minimax-cn:') - expect(await readFile(join(scaffold.harnessHome, '.env'), 'utf8')) + expect(await readFile(join(scaffold.harnessHome, '.credentials.yaml'), 'utf8')) .not.toContain('MINIMAX_CN_API_KEY') await expect.poll( async () => page.getByRole('dialog', { name: '删除 minimax-cn?' }).count(), diff --git a/apps/web/tests/onboarding-deepseek-config.e2e.ts b/apps/web/tests/onboarding-deepseek-config.e2e.ts index 85eb76e3e3..14b741eb80 100644 --- a/apps/web/tests/onboarding-deepseek-config.e2e.ts +++ b/apps/web/tests/onboarding-deepseek-config.e2e.ts @@ -113,8 +113,8 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup await settings.getByRole('button', { name: '保存', exact: true }).click() await keyInput.waitFor({ state: 'detached', timeout: 15_000 }) - const stored = await readFile(join(scaffold.harnessHome, '.env'), 'utf8') - expect(stored.includes(`DEEPSEEK_API_KEY=${secret}`)).toBe(true) + const stored = await readFile(join(scaffold.harnessHome, '.credentials.yaml'), 'utf8') + expect(stored.includes(`DEEPSEEK_API_KEY: ${secret}`)).toBe(true) expect((await page.content()).includes(secret)).toBe(false) expect((await page.locator('body').ariaSnapshot()).includes(secret)).toBe(false) expect(browserConsole.some(line => line.includes(secret))).toBe(false) 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 43500585d8..e6b10664af 100644 --- a/apps/web/tests/settings-chrome.e2e.ts +++ b/apps/web/tests/settings-chrome.e2e.ts @@ -1,7 +1,8 @@ // Web e2e scenarios: the settings surface — the modal shell (trigger, nav, // section switching, both close paths), the Appearance preference row (the // real theme gesture — click 深色 and the whole cascade runs: ThemeService preference -> localStorage dsh.theme -// -> theme/change -> ui-layout's presenter -> body attribute -> alias token) +// -> theme/change -> ui-layout's presenter -> body attribute -> alias token + +// browser theme-color metadata) // the Language row (settings-scoped localization + persisted dsh.locale), // the busy-state Enter preference, plus Permission as the persisted default // for subsequently created sessions. @@ -154,17 +155,37 @@ describe('web e2e: settings modal and General preferences', () => { it('flips the theme through the Appearance cubes and persists across reload', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-appearance')) - const readState = async (): Promise<{ attr: boolean; token: string; stored: string | null }> => - await page.evaluate(() => ({ + interface ThemeState { + attr: boolean + background: string + stored: string | null + themeColor: string | null + themeColorCount: number + token: string + } + const readState = async (): Promise => await page.evaluate(() => { + const metas = document.head.querySelectorAll('meta[name="theme-color"]') + const computed = getComputedStyle(document.body) + return { attr: document.body.hasAttribute('data-ds-dark-theme'), - token: getComputedStyle(document.body).getPropertyValue('--dsw-alias-bg-base').trim(), + background: computed.backgroundColor, stored: localStorage.getItem('dsh.theme'), - })) + themeColor: metas[0]?.content ?? null, + themeColorCount: metas.length, + token: computed.getPropertyValue('--dsw-alias-bg-base').trim(), + } + }) + const expectThemeColorSynchronized = (state: ThemeState): void => { + expect(state.themeColorCount).toBe(1) + expect(state.background).not.toBe('rgba(0, 0, 0, 0)') + expect(state.themeColor).toBe(state.background) + } // Pin the OS scheme to light so the default `system` preference resolves // light and the dark flip below is unambiguously the gesture's doing. await page.emulateMedia({ colorScheme: 'light' }) const light = await readState() expect(light.attr).toBe(false) + expectThemeColorSynchronized(light) await page.getByRole('button', { name: '设置', exact: true }).click() const dialog = page.getByRole('dialog', { name: '设置' }) @@ -179,6 +200,7 @@ describe('web e2e: settings modal and General preferences', () => { expect(dark.attr).toBe(true) expect(dark.stored).toBe('dark') expect(dark.token).not.toBe(light.token) + expectThemeColorSynchronized(dark) await page.keyboard.press('Escape') // Reload: the preference survives boot (restore + presenter initial apply). @@ -190,6 +212,7 @@ describe('web e2e: settings modal and General preferences', () => { const reloaded = await readState() expect(reloaded.attr).toBe(true) expect(reloaded.stored).toBe('dark') + expectThemeColorSynchronized(reloaded) // `system` follows the emulated OS scheme (dark stays dark, light clears). await page.getByRole('button', { name: '设置', exact: true }).click() @@ -197,12 +220,15 @@ describe('web e2e: settings modal and General preferences', () => { await systemCube.click() await expect.poll(() => systemCube.getAttribute('aria-pressed'), { timeout: 5_000 }).toBe('true') await expect.poll(async () => (await readState()).attr, { timeout: 5_000 }).toBe(false) + expectThemeColorSynchronized(await readState()) await page.emulateMedia({ colorScheme: 'dark' }) await expect.poll(async () => (await readState()).attr, { timeout: 5_000 }).toBe(true) + expectThemeColorSynchronized(await readState()) // Restore for the specs that follow: light preference beats the emulated // dark OS scheme, leaving the shared page in the light default. await page.getByRole('dialog', { name: '设置' }).getByRole('button', { name: '浅色' }).click() await expect.poll(async () => (await readState()).attr, { timeout: 5_000 }).toBe(false) + expectThemeColorSynchronized(await readState()) await page.keyboard.press('Escape') expect(tripwire.pageErrors).toEqual([]) }, 90_000) diff --git a/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 a5f89567f5..f4697e1267 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` @@ -426,7 +426,7 @@ Source: [`packages/compact/compact-tool-result-prune/src/types.ts:4`](../package ```ts config-catalog /** Plugin config: file location and hot-reload behavior. */ export interface Config { - /** Credentials document path; defaults to `.env` under the harness home. */ + /** Credentials document path; defaults to `.credentials.yaml` under the harness home. */ path?: string /** Harness home used when `path` is omitted; defaults to `$DSH_HOME` or `~/.dsh`. */ dshHome?: string @@ -437,7 +437,7 @@ export interface Config { } ``` -Source: [`packages/credentials/credentials-local/src/index.ts:26`](../packages/credentials/credentials-local/src/index.ts) +Source: [`packages/credentials/credentials-local/src/index.ts:54`](../packages/credentials/credentials-local/src/index.ts) ## `@deepseek-ai/dsh-frontend-static` @@ -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` @@ -679,17 +679,9 @@ Requires: `llm` * reasoning effort resolves to `high`. */ export interface Config { - /** - * Trimmed literal API key; whitespace-only is absent, so it resolves through - * {@link apiKeyEnv} like an omitted one. Prefer {@link apiKeyEnv} to keep - * secrets out of configuration files. {@link resolveAdapterOptions} also - * format-checks what remains: a value no HTTP header can carry fails there - * rather than inside `fetch`. - */ - apiKey?: string /** Credential reference (environment-variable name) resolved per request; defaults to `DEEPSEEK_API_KEY`. */ apiKeyEnv?: string - /** Endpoint base; falls back to $DEEPSEEK_BASE_URL, then the public API. */ + /** Endpoint base; falls back to $DEEPSEEK_BASE_URL from a trusted environment layer, then the public API. */ baseURL?: string /** Deployment thinking policy; `disabled` limits every conversation request to `off`. */ thinking?: 'enabled' | 'disabled' @@ -724,7 +716,7 @@ export interface DeepSeekCatalogModel { Depends on: [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) -Source: [`packages/llm/llm-deepseek/src/index.ts:60`](../packages/llm/llm-deepseek/src/index.ts) +Source: [`packages/llm/llm-deepseek/src/index.ts:61`](../packages/llm/llm-deepseek/src/index.ts) ## `@deepseek-ai/dsh-llm-pi-ai` @@ -743,12 +735,6 @@ export interface Config { /** Configuration for one pi-ai provider route; the `providers` dict key IS the route. */ export interface PiAiProviderProfile { - /** - * Literal provider credential; prefer {@link apiKeyEnv}. With both absent pi-ai uses its - * provider-native ambient discovery. Trimmed and format-checked by {@link resolveProfiles}; a - * value no HTTP header can carry fails there rather than inside `fetch`. - */ - apiKey?: string /** Credential reference (environment-variable name) resolved per request through `ctx.credentials`. */ apiKeyEnv?: string /** Name shown by configuration surfaces; defaults to the route key. */ @@ -819,7 +805,7 @@ export interface PiAiModelProfile { Depends on: `CacheRetention` (`@earendil-works/pi-ai`) · `ModelThinkingLevel` (`@earendil-works/pi-ai`) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) · `ThinkingBudgets` (`@earendil-works/pi-ai`) · `Transport` (`@earendil-works/pi-ai`) -Source: [`packages/llm/llm-pi-ai/src/config.ts:126`](../packages/llm/llm-pi-ai/src/config.ts) +Source: [`packages/llm/llm-pi-ai/src/config.ts:120`](../packages/llm/llm-pi-ai/src/config.ts) ## `@deepseek-ai/dsh-llm-replay` @@ -2433,7 +2419,7 @@ export interface Config { } ``` -Source: [`packages/web/web-search-deepseek/src/index.ts:43`](../packages/web/web-search-deepseek/src/index.ts) +Source: [`packages/web/web-search-deepseek/src/index.ts:44`](../packages/web/web-search-deepseek/src/index.ts) ## `@deepseek-ai/dsh-web-search-exa` @@ -2455,7 +2441,7 @@ export interface Config { } ``` -Source: [`packages/web/web-search-exa/src/index.ts:37`](../packages/web/web-search-exa/src/index.ts) +Source: [`packages/web/web-search-exa/src/index.ts:38`](../packages/web/web-search-exa/src/index.ts) ## `@deepseek-ai/dsh-web-search-perplexity` @@ -2477,7 +2463,7 @@ export interface Config { } ``` -Source: [`packages/web/web-search-perplexity/src/index.ts:31`](../packages/web/web-search-perplexity/src/index.ts) +Source: [`packages/web/web-search-perplexity/src/index.ts:32`](../packages/web/web-search-perplexity/src/index.ts) ## `@deepseek-ai/dsh-workflow-workerthread` @@ -2540,6 +2526,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)) @@ -2583,7 +2571,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)) @@ -2623,6 +2610,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. - `@deepseek-ai/dsh-client-ui-slots` ([`packages/client/ui-slots/src/index.ts`](../packages/client/ui-slots/src/index.ts)) - `@deepseek-ai/dsh-client-web` ([`packages/client/web/src/index.ts`](../packages/client/web/src/index.ts)) - `@deepseek-ai/dsh-client-web-react` ([`packages/client/web-react/src/index.ts`](../packages/client/web-react/src/index.ts)) +- `@deepseek-ai/dsh-environment` ([`packages/util/environment/src/index.ts`](../packages/util/environment/src/index.ts)) - `@deepseek-ai/dsh-helper` ([`packages/sdk/helper/src/index.ts`](../packages/sdk/helper/src/index.ts)) - `@deepseek-ai/dsh-hook-protocol` ([`packages/hooks/hook-protocol/src/index.ts`](../packages/hooks/hook-protocol/src/index.ts)) - `@deepseek-ai/dsh-jsonrpc-demo` ([`packages/examples/jsonrpc-demo/src/index.ts`](../packages/examples/jsonrpc-demo/src/index.ts)) @@ -2641,4 +2629,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/cordis-tutorial/05-config.i18n.yaml b/docs/cordis-tutorial/05-config.i18n.yaml index 01047ad516..db300b1745 100644 --- a/docs/cordis-tutorial/05-config.i18n.yaml +++ b/docs/cordis-tutorial/05-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 docs/cordis-tutorial/05-config.md -05-config.md: fc19add239636fa9e7071d9c77e48595caec1f08 -05-config.zh.md: 0c8170f518f0c87ab5c754606436496a4ff9d51e +05-config.md: 8d4043e33a58fc425d82d9846ff82473bcdef4c1 +05-config.zh.md: e9463bd34e9c72dbae7b1ceb9907e35edf7b773b diff --git a/docs/cordis-tutorial/05-config.md b/docs/cordis-tutorial/05-config.md index fc19add239..8d4043e33a 100644 --- a/docs/cordis-tutorial/05-config.md +++ b/docs/cordis-tutorial/05-config.md @@ -69,12 +69,12 @@ The plugin's fiber goes to FAILED, and this tutorial's launcher exits with statu ## Computed config values -The loader used in this repo supports a `!!js` tag for config values that must be computed at load time, such as reading an API key from the environment: +The loader used in this repo supports a `!!js` tag for config values that must be computed at load time: ```yaml -- name: '@deepseek-ai/dsh-llm-deepseek' +- name: './config-demo.ts' config: - apiKey: !!js process.env.DEEPSEEK_API_KEY + greeting: !!js process.env.DEMO_GREETING ?? 'Hello' ``` `!!js` works **only inside `config`**. Entry metadata (`name`, `id`, `disabled`, `inject`, ...) is static; `disabled: !!js ...` produces a truthy expression object that always disables the entry. See [loader configuration](../cordis-primer.md#loader-configuration). diff --git a/docs/cordis-tutorial/05-config.zh.md b/docs/cordis-tutorial/05-config.zh.md index 0c8170f518..e9463bd34e 100644 --- a/docs/cordis-tutorial/05-config.zh.md +++ b/docs/cordis-tutorial/05-config.zh.md @@ -69,12 +69,12 @@ ValidationError: invalid config: ## 计算得到的配置值 -本仓库使用的 loader 支持 `!!js` 标签,用于必须在加载时计算的配置值,例如从环境中读取 API key: +本仓库使用的 loader 支持 `!!js` 标签,用于必须在加载时计算的配置值: ```yaml -- name: '@deepseek-ai/dsh-llm-deepseek' +- name: './config-demo.ts' config: - apiKey: !!js process.env.DEEPSEEK_API_KEY + greeting: !!js process.env.DEMO_GREETING ?? 'Hello' ``` `!!js` **仅在 `config` 内有效**。Cordis 配置项的元数据(`name`、`id`、`disabled`、`inject` 等)是静态的;`disabled: !!js ...` 会生成一个真值表达式对象,始终禁用该 Cordis 配置项。详见 [loader 配置](../cordis-primer.md#loader-configuration)。 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/credentials.i18n.yaml b/docs/core-data-structures/credentials.i18n.yaml index 23bb940afe..d44275d97e 100644 --- a/docs/core-data-structures/credentials.i18n.yaml +++ b/docs/core-data-structures/credentials.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/credentials.md -credentials.md: 3f6fcd127d01e2c49e17c70c002bebe9f363e951 -credentials.zh.md: b5d2d9e164a85ce090790635c438b768cae4c9ca +credentials.md: ef74ddeb4346e18f8d5d33488657e5d50f1d754e +credentials.zh.md: 09cf374a2346fd93aa834e3372321e6eeece6ed8 diff --git a/docs/core-data-structures/credentials.md b/docs/core-data-structures/credentials.md index 3f6fcd127d..ef74ddeb43 100644 --- a/docs/core-data-structures/credentials.md +++ b/docs/core-data-structures/credentials.md @@ -24,7 +24,7 @@ type CredentialRef = Branded<'CredentialRef'> interface ResolvedCredential { /** The non-empty secret value. */ value: string - /** Provider-defined source layer id (the local provider uses `env` and `file`). */ + /** Provider-defined source layer id (the local provider uses `env`, `file`, `project-env`, and `user-env`). */ source: string } ``` diff --git a/docs/core-data-structures/credentials.zh.md b/docs/core-data-structures/credentials.zh.md index b5d2d9e164..09cf374a23 100644 --- a/docs/core-data-structures/credentials.zh.md +++ b/docs/core-data-structures/credentials.zh.md @@ -24,7 +24,7 @@ type CredentialRef = Branded<'CredentialRef'> interface ResolvedCredential { /** The non-empty secret value. */ value: string - /** Provider-defined source layer id (the local provider uses `env` and `file`). */ + /** Provider-defined source layer id (the local provider uses `env`, `file`, `project-env`, and `user-env`). */ source: string } ``` 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 286c0ee4c2..9d486c46f6 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 3657523e5b..dddf086bb0 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -10,6 +10,7 @@ flowchart TD subgraph group_util["packages/util"] pkg_atomic_write["atomic-write"] pkg_brand["brand"] + pkg_environment["environment"] pkg_native_command["native-command"] pkg_paths["paths"] pkg_retention["retention"] @@ -147,6 +148,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"] @@ -274,6 +279,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"] @@ -289,6 +295,7 @@ flowchart TD end pkg_atomic_write --> pkg_invariants pkg_brand --> pkg_invariants + pkg_environment --> pkg_invariants pkg_native_command --> pkg_invariants pkg_paths --> pkg_invariants pkg_retention --> pkg_invariants @@ -300,7 +307,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 @@ -314,6 +320,7 @@ flowchart TD pkg_sandbox_windows_acl --> 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 @@ -324,22 +331,6 @@ flowchart TD pkg_client_hmr --> pkg_client_modules pkg_client_hmr --> pkg_host_webserver pkg_client_hmr --> pkg_invariants - pkg_client_locale --> pkg_client_runtime - pkg_client_locale --> pkg_client_ui_primitives - pkg_client_locale --> pkg_client_ui_slots - pkg_client_locale --> pkg_invariants - pkg_client_test_runtime --> pkg_client_runtime - pkg_client_test_runtime --> pkg_client_ui_slots - pkg_client_test_runtime --> pkg_client_web_react - pkg_client_test_runtime --> pkg_host_apiproxy - pkg_client_test_runtime --> pkg_invariants - pkg_client_ui_settings --> pkg_client_runtime - pkg_client_ui_settings --> pkg_client_ui_primitives - pkg_client_ui_settings --> pkg_client_ui_slots - pkg_client_ui_settings --> pkg_invariants - pkg_client_ui_trajectory --> pkg_client_runtime - pkg_client_ui_trajectory --> pkg_client_ui_primitives - pkg_client_ui_trajectory --> pkg_invariants pkg_credentials --> pkg_brand pkg_credentials --> pkg_invariants pkg_frontend_static --> pkg_host_webserver @@ -364,11 +355,13 @@ flowchart TD pkg_typert_loader --> pkg_invariants pkg_typert_loader --> pkg_typert_registry pkg_llm_deepseek --> pkg_credentials + pkg_llm_deepseek --> pkg_environment pkg_llm_deepseek --> pkg_invariants pkg_llm_deepseek --> pkg_llm pkg_llm_deepseek --> pkg_settings pkg_llm_deepseek --> pkg_timeout pkg_llm_pi_ai --> pkg_credentials + pkg_llm_pi_ai --> pkg_environment pkg_llm_pi_ai --> pkg_invariants pkg_llm_pi_ai --> pkg_llm pkg_llm_pi_ai --> pkg_settings @@ -377,50 +370,18 @@ 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_ui_models --> pkg_client_connection - pkg_client_ui_models --> pkg_client_runtime - pkg_client_ui_models --> pkg_client_schema_form - pkg_client_ui_models --> pkg_client_ui_primitives - pkg_client_ui_models --> pkg_client_ui_slots - pkg_client_ui_models --> pkg_client_web_react - pkg_client_ui_models --> pkg_invariants - pkg_client_ui_question --> pkg_client_locale - pkg_client_ui_question --> pkg_invariants - pkg_client_ui_settings_general --> pkg_client_connection - pkg_client_ui_settings_general --> pkg_client_locale - pkg_client_ui_settings_general --> pkg_client_runtime - pkg_client_ui_settings_general --> pkg_client_ui_primitives - pkg_client_ui_settings_general --> pkg_client_ui_settings - pkg_client_ui_settings_general --> pkg_client_ui_slots - pkg_client_ui_settings_general --> pkg_client_web_react - pkg_client_ui_settings_general --> pkg_invariants - pkg_client_ui_sidebar --> pkg_client_locale - pkg_client_ui_sidebar --> pkg_client_runtime - pkg_client_ui_sidebar --> pkg_client_ui_primitives - pkg_client_ui_sidebar --> pkg_client_ui_slots - pkg_client_ui_sidebar --> pkg_invariants - pkg_client_ui_slash --> pkg_client_locale - pkg_client_ui_slash --> pkg_client_runtime - pkg_client_ui_slash --> pkg_client_ui_primitives - pkg_client_ui_slash --> pkg_client_ui_slots - pkg_client_ui_slash --> pkg_invariants - pkg_client_ui_theme --> pkg_client_locale - pkg_client_ui_theme --> pkg_client_runtime - pkg_client_ui_theme --> pkg_client_ui_primitives - pkg_client_ui_theme --> pkg_client_ui_slots - pkg_client_ui_theme --> pkg_invariants - pkg_client_ui_workspace --> pkg_client_locale - pkg_client_ui_workspace --> pkg_client_runtime - pkg_client_ui_workspace --> pkg_client_ui_primitives - pkg_client_ui_workspace --> pkg_client_ui_slots - pkg_client_ui_workspace --> pkg_invariants + pkg_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_environment pkg_credentials_local --> pkg_invariants pkg_credentials_local --> pkg_paths pkg_lsp --> pkg_brand @@ -437,6 +398,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 @@ -450,8 +412,10 @@ flowchart TD pkg_web_fetch_local --> pkg_invariants pkg_web_fetch_local --> pkg_timeout pkg_web_fetch_local --> pkg_web + pkg_web_search_exa --> pkg_environment pkg_web_search_exa --> pkg_invariants pkg_web_search_exa --> pkg_web + pkg_web_search_perplexity --> pkg_environment pkg_web_search_perplexity --> pkg_invariants pkg_web_search_perplexity --> pkg_web pkg_spill --> pkg_brand @@ -464,27 +428,14 @@ flowchart TD pkg_llm_replay --> pkg_invariants pkg_llm_replay --> pkg_llm pkg_llm_replay --> pkg_session + pkg_app_boot --> pkg_environment pkg_app_boot --> pkg_invariants pkg_app_boot --> pkg_paths pkg_app_boot --> pkg_system_prompt - 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_code_runtime_worker --> pkg_code_runtime pkg_code_runtime_worker --> pkg_invariants pkg_code_runtime_worker --> pkg_session pkg_code_runtime_worker --> pkg_timeout - pkg_host_directory_picker_browse --> pkg_client_locale - pkg_host_directory_picker_browse --> pkg_client_runtime - pkg_host_directory_picker_browse --> pkg_client_ui_primitives - pkg_host_directory_picker_browse --> pkg_client_ui_slots - pkg_host_directory_picker_browse --> pkg_client_ui_workspace - pkg_host_directory_picker_browse --> pkg_invariants - pkg_host_directory_picker_native --> pkg_client_runtime - pkg_host_directory_picker_native --> pkg_client_ui_slots - pkg_host_directory_picker_native --> pkg_client_ui_workspace - pkg_host_directory_picker_native --> pkg_invariants pkg_lsp_local --> pkg_brand pkg_lsp_local --> pkg_invariants pkg_lsp_local --> pkg_llm @@ -513,6 +464,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 @@ -531,6 +483,7 @@ flowchart TD pkg_skill_local --> pkg_skill pkg_web_search_deepseek --> pkg_agent pkg_web_search_deepseek --> pkg_credentials + pkg_web_search_deepseek --> pkg_environment pkg_web_search_deepseek --> pkg_invariants pkg_web_search_deepseek --> pkg_session pkg_web_search_deepseek --> pkg_web @@ -577,10 +530,6 @@ flowchart TD pkg_tmux_context --> pkg_bash pkg_tmux_context --> pkg_invariants pkg_tmux_context --> pkg_session - pkg_host_directory_picker_auto --> pkg_host_directory_picker_browse - pkg_host_directory_picker_auto --> pkg_host_directory_picker_native - pkg_host_directory_picker_auto --> pkg_host_webserver - pkg_host_directory_picker_auto --> pkg_invariants pkg_pty --> pkg_agent pkg_pty --> pkg_brand pkg_pty --> pkg_invariants @@ -677,13 +626,12 @@ flowchart TD pkg_permission --> pkg_session_projection pkg_permission --> pkg_settings pkg_permission --> pkg_user_approval - 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 @@ -840,35 +788,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 @@ -1000,42 +923,29 @@ flowchart TD pkg_web_app --> pkg_bash_env pkg_web_app --> pkg_invariants pkg_web_app --> pkg_system_prompt - pkg_client_ui_model --> pkg_client_connection - pkg_client_ui_model --> pkg_client_locale - pkg_client_ui_model --> pkg_client_runtime - pkg_client_ui_model --> pkg_client_ui_command - pkg_client_ui_model --> pkg_client_ui_conversation - pkg_client_ui_model --> pkg_client_ui_primitives - pkg_client_ui_model --> pkg_client_ui_slash - pkg_client_ui_model --> pkg_client_ui_slots - pkg_client_ui_model --> pkg_invariants - pkg_client_ui_permission --> pkg_client_connection - pkg_client_ui_permission --> pkg_client_locale - pkg_client_ui_permission --> pkg_client_runtime - pkg_client_ui_permission --> pkg_client_schema_form - pkg_client_ui_permission --> pkg_client_ui_command - pkg_client_ui_permission --> pkg_client_ui_primitives - pkg_client_ui_permission --> pkg_client_ui_slash - pkg_client_ui_permission --> pkg_client_ui_slots - pkg_client_ui_permission --> pkg_invariants - pkg_client_ui_permission --> pkg_permission - pkg_client_ui_plan --> pkg_client_connection - pkg_client_ui_plan --> pkg_client_locale - pkg_client_ui_plan --> pkg_client_runtime - pkg_client_ui_plan --> pkg_client_ui_conversation - pkg_client_ui_plan --> pkg_client_ui_primitives - pkg_client_ui_plan --> pkg_client_ui_slots - pkg_client_ui_plan --> pkg_invariants - pkg_client_ui_plan --> pkg_plan_mode - pkg_client_ui_subagent --> pkg_client_locale - pkg_client_ui_subagent --> pkg_client_runtime - pkg_client_ui_subagent --> pkg_client_ui_conversation - pkg_client_ui_subagent --> pkg_client_ui_primitives - pkg_client_ui_subagent --> pkg_client_ui_slash - pkg_client_ui_subagent --> pkg_client_ui_slots - pkg_client_ui_subagent --> pkg_invariants - pkg_client_ui_subagent --> pkg_subagent - pkg_client_ui_subagent --> pkg_token_meter + pkg_client_locale --> pkg_client_runtime + pkg_client_locale --> pkg_client_ui_primitives + pkg_client_locale --> pkg_client_ui_slots + pkg_client_locale --> pkg_invariants + pkg_client_test_runtime --> pkg_client_runtime + pkg_client_test_runtime --> pkg_client_ui_slots + pkg_client_test_runtime --> pkg_client_web_react + pkg_client_test_runtime --> pkg_host_apiproxy + pkg_client_test_runtime --> pkg_invariants + pkg_client_ui_models --> pkg_client_connection + pkg_client_ui_models --> pkg_client_runtime + pkg_client_ui_models --> pkg_client_schema_form + pkg_client_ui_models --> pkg_client_ui_primitives + pkg_client_ui_models --> pkg_client_ui_slots + pkg_client_ui_models --> pkg_client_web_react + pkg_client_ui_models --> pkg_invariants + pkg_client_ui_settings --> pkg_client_runtime + pkg_client_ui_settings --> pkg_client_ui_primitives + pkg_client_ui_settings --> pkg_client_ui_slots + pkg_client_ui_settings --> pkg_invariants + pkg_client_ui_trajectory --> pkg_client_runtime + pkg_client_ui_trajectory --> pkg_client_ui_primitives + pkg_client_ui_trajectory --> pkg_invariants pkg_sdk_protocol --> pkg_invariants pkg_sdk_protocol --> pkg_llm pkg_sdk_protocol --> pkg_session @@ -1078,6 +988,36 @@ flowchart TD pkg_jsonrpc --> pkg_sdk_protocol pkg_jsonrpc --> pkg_session pkg_jsonrpc --> pkg_subagent + pkg_client_ui_question --> pkg_client_locale + pkg_client_ui_question --> pkg_invariants + pkg_client_ui_settings_general --> pkg_client_connection + pkg_client_ui_settings_general --> pkg_client_locale + pkg_client_ui_settings_general --> pkg_client_runtime + pkg_client_ui_settings_general --> pkg_client_ui_primitives + pkg_client_ui_settings_general --> pkg_client_ui_settings + pkg_client_ui_settings_general --> pkg_client_ui_slots + pkg_client_ui_settings_general --> pkg_client_web_react + pkg_client_ui_settings_general --> pkg_invariants + pkg_client_ui_sidebar --> pkg_client_locale + pkg_client_ui_sidebar --> pkg_client_runtime + pkg_client_ui_sidebar --> pkg_client_ui_primitives + pkg_client_ui_sidebar --> pkg_client_ui_slots + pkg_client_ui_sidebar --> pkg_invariants + pkg_client_ui_slash --> pkg_client_locale + pkg_client_ui_slash --> pkg_client_runtime + pkg_client_ui_slash --> pkg_client_ui_primitives + pkg_client_ui_slash --> pkg_client_ui_slots + pkg_client_ui_slash --> pkg_invariants + pkg_client_ui_theme --> pkg_client_locale + pkg_client_ui_theme --> pkg_client_runtime + pkg_client_ui_theme --> pkg_client_ui_primitives + pkg_client_ui_theme --> pkg_client_ui_slots + pkg_client_ui_theme --> pkg_invariants + pkg_client_ui_workspace --> pkg_client_locale + pkg_client_ui_workspace --> pkg_client_runtime + pkg_client_ui_workspace --> pkg_client_ui_primitives + pkg_client_ui_workspace --> pkg_client_ui_slots + pkg_client_ui_workspace --> pkg_invariants pkg_agent_spine_demo --> pkg_agent pkg_agent_spine_demo --> pkg_agent_loop pkg_agent_spine_demo --> pkg_bash_env @@ -1111,6 +1051,17 @@ flowchart TD pkg_subagent_dsh_sdk --> pkg_session pkg_subagent_dsh_sdk --> pkg_subagent pkg_subagent_dsh_sdk --> pkg_subprocess + pkg_client_ui_conversation --> pkg_client_locale + pkg_client_ui_conversation --> pkg_client_runtime + pkg_client_ui_conversation --> pkg_client_ui_primitives + pkg_client_ui_conversation --> pkg_client_ui_slash + pkg_client_ui_conversation --> pkg_client_ui_slots + pkg_client_ui_conversation --> pkg_invariants + pkg_client_ui_conversation --> pkg_token_meter + pkg_client_ui_layout --> pkg_client_runtime + pkg_client_ui_layout --> pkg_client_ui_slots + pkg_client_ui_layout --> pkg_client_ui_theme + pkg_client_ui_layout --> pkg_invariants pkg_acp_demo --> pkg_acp pkg_acp_demo --> pkg_agent_spine_demo pkg_acp_demo --> pkg_app_boot @@ -1131,6 +1082,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 | @@ -1138,6 +1168,7 @@ flowchart TD | [`invariants`](../packages/support/invariants) | `support` | — | | [`atomic-write`](../packages/util/atomic-write) | `util` | [`invariants`](../packages/support/invariants) | | [`brand`](../packages/util/brand) | `util` | [`invariants`](../packages/support/invariants) | +| [`environment`](../packages/util/environment) | `util` | [`invariants`](../packages/support/invariants) | | [`native-command`](../packages/util/native-command) | `util` | [`invariants`](../packages/support/invariants) | | [`paths`](../packages/util/paths) | `util` | [`invariants`](../packages/support/invariants) | | [`retention`](../packages/util/retention) | `util` | [`invariants`](../packages/support/invariants) | @@ -1149,7 +1180,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) | @@ -1163,15 +1193,12 @@ flowchart TD | [`sandbox-windows-acl`](../packages/sandbox/sandbox-windows-acl) | `sandbox` | [`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-locale`](../packages/client/locale) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-test-runtime`](../packages/client/test-runtime) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`host-apiproxy`](../packages/host/apiproxy), [`invariants`](../packages/support/invariants) | -| [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`invariants`](../packages/support/invariants) | | [`credentials`](../packages/credentials/credentials) | `credentials` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`frontend-static`](../packages/host/frontend-static) | `host` | [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess) | @@ -1182,49 +1209,40 @@ flowchart TD | [`storage-sqlite`](../packages/storage/storage-sqlite) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) | | [`subprocess-local`](../packages/subprocess/subprocess-local) | `subprocess` | [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`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) | +| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`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), [`environment`](../packages/util/environment), [`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), [`type-meta`](../packages/typert/type-meta) | | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`web`](../packages/web/web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | -| [`client-ui-models`](../packages/client/ui-models) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-schema-form`](../packages/client/schema-form), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) | -| [`client-ui-question`](../packages/client/ui-question) | `client` | [`client-locale`](../packages/client/locale), [`invariants`](../packages/support/invariants) | -| [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) | -| [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-slash`](../packages/client/ui-slash) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | +| [`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), [`environment`](../packages/util/environment), [`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) | | [`web-fetch-local`](../packages/web/web-fetch-local) | `web` | [`invariants`](../packages/support/invariants), [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) | -| [`web-search-exa`](../packages/web/web-search-exa) | `web` | [`invariants`](../packages/support/invariants), [`web`](../packages/web/web) | -| [`web-search-perplexity`](../packages/web/web-search-perplexity) | `web` | [`invariants`](../packages/support/invariants), [`web`](../packages/web/web) | +| [`web-search-exa`](../packages/web/web-search-exa) | `web` | [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`web`](../packages/web/web) | +| [`web-search-perplexity`](../packages/web/web-search-perplexity) | `web` | [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`web`](../packages/web/web) | | [`spill`](../packages/spill/spill) | `spill` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`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-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) | +| [`app-boot`](../packages/ui/app-boot) | `ui` | [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`system-prompt`](../packages/core/system-prompt) | | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | -| [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | -| [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | | [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`session-projection`](../packages/session-projection/session-projection) | `session-projection` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`token-meter`](../packages/llm/token-meter) | `llm` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection) | -| [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection) | +| [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection), [`type-meta`](../packages/typert/type-meta) | | [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`pwsh-local`](../packages/bash/pwsh-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | | [`fs-policy`](../packages/fs/fs-policy) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | | [`skill-local`](../packages/skill/skill-local) | `skill` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`skill`](../packages/skill/skill) | -| [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`web`](../packages/web/web) | +| [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`web`](../packages/web/web) | | [`spill-local`](../packages/spill/spill-local) | `spill` | [`invariants`](../packages/support/invariants), [`spill`](../packages/spill/spill) | | [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl) | `session-persistence` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) | @@ -1236,7 +1254,6 @@ flowchart TD | [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | -| [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`pty`](../packages/pty/pty) | `pty` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`scripts`](../packages/sdk/scripts) | `sdk` | [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants) | @@ -1257,7 +1274,7 @@ 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-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) | | [`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) | @@ -1284,10 +1301,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) | @@ -1309,10 +1323,11 @@ flowchart TD | [`repository-plugin`](../packages/cordis/repository-plugin) | `cordis` | [`invariants`](../packages/support/invariants), [`mcp-client`](../packages/mcp/mcp-client), [`paths`](../packages/util/paths), [`skill-local`](../packages/skill/skill-local) | | [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`web-app`](../packages/bundle/web-app) | `bundle` | [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`system-prompt`](../packages/core/system-prompt) | -| [`client-ui-model`](../packages/client/ui-model) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-command`](../packages/client/ui-command), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-permission`](../packages/client/ui-permission) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-schema-form`](../packages/client/schema-form), [`client-ui-command`](../packages/client/ui-command), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`permission`](../packages/ui/permission) | -| [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`plan-mode`](../packages/plan/plan-mode) | -| [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) | +| [`client-locale`](../packages/client/locale) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-test-runtime`](../packages/client/test-runtime) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`host-apiproxy`](../packages/host/apiproxy), [`invariants`](../packages/support/invariants) | +| [`client-ui-models`](../packages/client/ui-models) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-schema-form`](../packages/client/schema-form), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) | +| [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`invariants`](../packages/support/invariants) | | [`sdk-protocol`](../packages/sdk/sdk-protocol) | `sdk` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | @@ -1320,8 +1335,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-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`bash-env`](../packages/bash/bash-env), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks-local`](../packages/tasks/tasks-local), [`tool-bash`](../packages/bash/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | | [`sdk-client`](../packages/sdk/sdk-client) | `sdk` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/sdk-protocol), [`session`](../packages/core/session) | | [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/sdk-client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | +| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`token-meter`](../packages/llm/token-meter) | +| [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/support/invariants) | | [`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/docs/user/guide/config.i18n.yaml b/docs/user/guide/config.i18n.yaml index 172af1bcac..954eab633d 100644 --- a/docs/user/guide/config.i18n.yaml +++ b/docs/user/guide/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 docs/user/guide/config.md -config.md: 0f1a99ed0afdab13d052ae2714fc2e1718d3d85e -config.zh.md: 74e14e4e6e1fbb38a7a8d5167a4747080a662128 +config.md: 34ab38c60cc7a9b6026f5be2be47f440eb6cb08d +config.zh.md: ce965dfc67c759ffcbad7b044b74abb2025851c2 diff --git a/docs/user/guide/config.md b/docs/user/guide/config.md index 0f1a99ed0a..34ab38c60c 100644 --- a/docs/user/guide/config.md +++ b/docs/user/guide/config.md @@ -18,10 +18,6 @@ A minimal configuration is a list of plugin entries: ```yaml - id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' - config: - apiKey: !!js process.env.DEEPSEEK_API_KEY - models: - - deepseek-v4-flash - id: bash name: '@deepseek-ai/dsh-bash-local' @@ -53,15 +49,14 @@ Cordis starts sibling entries concurrently. A plugin declares required services `dsh --profile ` composes the profile's bundle patch layers (its manifest's `dsh.profile.bundles` list, in order) over an empty root, then the profile's own `~/.dsh/profiles//cordis.patch.yml`, then each `--patch ` overlay, then CLI-flag patches. Later layers win per row. -A patch replaces a row's entire `config` value; it does not deep-merge keys. For example, patching `llm-deepseek` with only `config: { thinking: disabled }` also removes that row's configured `apiKey` and `baseURL`, so restate every key the row must retain. +A patch replaces a row's entire `config` value; it does not deep-merge keys. For example, patching `llm-deepseek` with only `config: { thinking: disabled }` also removes that row's configured `apiKeyEnv` and `baseURL`, so restate every key the row must retain. ## JavaScript values and environment variables -The Cordis loader evaluates runtime expressions tagged with `!!js`. Keep API keys and other secrets in the gitignored `.env` file at the repository root, never in committed configuration. +The Cordis loader evaluates runtime expressions tagged with `!!js` for non-secret runtime values. Bundled LLM adapters carry credential references such as `apiKeyEnv`; the value belongs in an environment layer or `$DSH_HOME/.credentials.yaml`, not Cordis configuration. ```yaml config: - apiKey: !!js process.env.DEEPSEEK_API_KEY cwd: !!js process.cwd() ``` diff --git a/docs/user/guide/config.zh.md b/docs/user/guide/config.zh.md index 74e14e4e6e..ce965dfc67 100644 --- a/docs/user/guide/config.zh.md +++ b/docs/user/guide/config.zh.md @@ -18,10 +18,6 @@ Harness 使用 `cordis.yml` 描述 agent(智能体)加载哪些插件以及 ```yaml - id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' - config: - apiKey: !!js process.env.DEEPSEEK_API_KEY - models: - - deepseek-v4-flash - id: bash name: '@deepseek-ai/dsh-bash-local' @@ -53,15 +49,14 @@ Cordis 会并发启动同级配置项。插件通过 `inject` 声明必需服务 `dsh --profile ` 按该 profile 的 manifest(元数据清单)中 `dsh.profile.bundles` 列表的顺序,在空根之上组合各组合包补丁层,随后依次应用该 profile 自己的 `~/.dsh/profiles//cordis.patch.yml`、每个 `--patch ` overlay,最后是 CLI(命令行界面)标志补丁。同一行以较后的层为准。 -补丁会替换目标行的整个 `config` 值,而不是深度合并各个键。例如,只用 `config: { thinking: disabled }` 修补 `llm-deepseek`,也会移除该行原有的 `apiKey` 与 `baseURL`;因此必须重新写出该行需要保留的全部键。 +补丁会替换目标行的整个 `config` 值,而不是深度合并各个键。例如,只用 `config: { thinking: disabled }` 修补 `llm-deepseek`,也会移除该行原有的 `apiKeyEnv` 与 `baseURL`;因此必须重新写出该行需要保留的全部键。 ## JavaScript 值和环境变量 -Cordis loader 使用 `!!js` 标签读取运行时表达式。API key 等凭据应放在仓库根目录、已被 Git 忽略的 `.env` 中,不能提交到配置文件。 +Cordis loader 会求值以 `!!js` 标记的运行时表达式,用于非机密的运行时值。仓库内置的 LLM(大语言模型)适配器携带 `apiKeyEnv` 等凭据引用;对应的值应放在环境层或 `$DSH_HOME/.credentials.yaml`,而不是 Cordis 配置中。 ```yaml config: - apiKey: !!js process.env.DEEPSEEK_API_KEY cwd: !!js process.cwd() ``` diff --git a/docs/user/guide/index.i18n.yaml b/docs/user/guide/index.i18n.yaml index 137a9697c1..b722161724 100644 --- a/docs/user/guide/index.i18n.yaml +++ b/docs/user/guide/index.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/user/guide/index.md -index.md: 4bb9f2e0056792a160877515f142eb36d4f680ac -index.zh.md: 2792547a146b5ca6186bcb69c1c026744e80b326 +index.md: ede09506a996193fe5cf4ae6cd9b64d3529798a6 +index.zh.md: 5f72a6d3099d2d4721eccebae92eacfe72d33bce diff --git a/docs/user/guide/index.md b/docs/user/guide/index.md index 4bb9f2e005..ede09506a9 100644 --- a/docs/user/guide/index.md +++ b/docs/user/guide/index.md @@ -11,8 +11,6 @@ Harness implements every capability an AI agent needs—including LLM calls, too ```yaml # Select the LLM backend - name: '@deepseek-ai/dsh-llm-deepseek' - config: - apiKey: !!js process.env.DEEPSEEK_API_KEY # Select the one-shot application - id: cli-agent diff --git a/docs/user/guide/index.zh.md b/docs/user/guide/index.zh.md index 2792547a14..5f72a6d309 100644 --- a/docs/user/guide/index.zh.md +++ b/docs/user/guide/index.zh.md @@ -11,8 +11,6 @@ Harness 将一个 AI Agent(智能体) 所需要的所有能力——LLM 调 ```yaml # Select the LLM backend - name: '@deepseek-ai/dsh-llm-deepseek' - config: - apiKey: !!js process.env.DEEPSEEK_API_KEY # Select the one-shot application - id: cli-agent diff --git a/docs/user/guide/providers.i18n.yaml b/docs/user/guide/providers.i18n.yaml index 82c2f2781a..db888b165e 100644 --- a/docs/user/guide/providers.i18n.yaml +++ b/docs/user/guide/providers.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/user/guide/providers.md -providers.md: 5234f5bb03755c11652eb23f3c5d677fa3cddb40 -providers.zh.md: a54819cab8524a6007c335ad70cecd6516bba25b +providers.md: 5fd11f3753468f9eccd318045b1ac5f6b3a21622 +providers.zh.md: 3e71e3fb2c64fb71fed196433175c860d3f6c817 diff --git a/docs/user/guide/providers.md b/docs/user/guide/providers.md index 5234f5bb03..5fd11f3753 100644 --- a/docs/user/guide/providers.md +++ b/docs/user/guide/providers.md @@ -31,7 +31,7 @@ That holds for providers that authenticate with an API key. The catalog also car **Let the endpoint report its models.** Expand **Model catalog** and choose **Fetch available models**: the interrogation asks the endpoint **the form currently shows** — including a base URL edited but not yet saved and a key typed but not yet stored — and offers what it reports as candidates to pick from. A route the installed catalog describes is answered from that catalog with no network call. Adopting a candidate only writes rows into the draft; nothing is stored until you save. -Keys are write-only: the page only ever holds a redacted descriptor, never the literal secret. A key you enter is stored in `$DSH_HOME/.env`, and the profile records only the variable name that references it. +Keys are write-only: the page only ever holds a redacted descriptor, never the literal secret. A key you enter is stored in `$DSH_HOME/.credentials.yaml`, and the profile records only the variable name that references it. ## settings.yaml for advanced configuration @@ -89,9 +89,9 @@ Model ids are not lifecycle configuration. Requesting a model the route does not ## Credentials -Prefer `apiKeyEnv`: it is a *reference* resolved per request, so no secret enters the configuration file. A literal `apiKey` is the escape hatch. Omitting both is what leaves a route unauthenticated, which for a catalog route means pi-ai's own environment discovery. A reference that resolves to nothing fails the request with `MISSING_CREDENTIAL` rather than falling through to whatever unrelated key the environment happens to hold. +Use `apiKeyEnv`: it is a *reference* resolved per request, so no secret enters the configuration file. Omitting it leaves a route unauthenticated, which for a catalog route means pi-ai's own environment discovery. A reference that resolves to nothing fails the request with `MISSING_CREDENTIAL` rather than falling through to whatever unrelated key the environment happens to hold. -References resolve from `$DSH_HOME/.env` — what the Models page's key fields write — and from the matching environment variable when no credential service is mounted. One credential serves every model on its route. +Under `dsh`, references resolve from the inherited environment, the Models page's `$DSH_HOME/.credentials.yaml` store, the invoking directory's `.env`, then `$DSH_HOME/.env`. Without a credential service, a reference reads only the matching environment variable. One credential serves every model on its route. ## Point an agent at the new provider diff --git a/docs/user/guide/providers.zh.md b/docs/user/guide/providers.zh.md index a54819cab8..3e71e3fb2c 100644 --- a/docs/user/guide/providers.zh.md +++ b/docs/user/guide/providers.zh.md @@ -31,7 +31,7 @@ Harness 出厂自带 DeepSeek,同时预装了一个通用的多提供方适配 **让端点自己报模型。** 展开**模型目录**后点**获取可用模型**,会按你**当前表单里**的地址与密钥去问端点(地址改了但没保存、密钥刚输入还没存下,都算数),把它报告的模型列成候选让你勾选。内置目录里的路由直接由目录作答,不联网。采纳只是把行写进草稿,最终还是你点保存才落盘。 -密钥是只写的:页面拿到的永远是脱敏描述符,不是明文。写入的密钥存进 `$DSH_HOME/.env`,profile 里只记录引用它的变量名。 +密钥是只写的:页面拿到的永远是脱敏描述符,不是明文。写入的密钥存进 `$DSH_HOME/.credentials.yaml`,profile 里只记录引用它的变量名。 ## settings.yaml:进阶配置 @@ -89,9 +89,9 @@ settings 段落**逐个提供方**地盖在 `cordis.yml` 的同名配置之上 ## 凭据 -优先用 `apiKeyEnv`——它是一个**引用**,每次请求时解析,密钥本身不进配置文件;`apiKey` 字面量是应急出口。两者都不给,才表示这个路由不带认证,对内置目录路由意味着交给 pi-ai 自己的环境发现。给了引用却解析不到,请求会以 `MISSING_CREDENTIAL` 失败,而不是退回去用环境里碰巧存在的某个不相干的 key 计费。 +使用 `apiKeyEnv`——它是一个**引用**,每次请求时解析,密钥本身不进配置文件。省略它会让路由不带认证,对内置目录路由意味着交给 pi-ai 自己的环境发现。给了引用却解析不到,请求会以 `MISSING_CREDENTIAL` 失败,而不是退回去用环境里碰巧存在的某个不相干的 key 计费。 -引用解析自 `$DSH_HOME/.env`(模型页的密钥输入框写的就是它),没有挂载凭据服务时则直接读同名环境变量。一份凭据供该路由上的所有模型使用。 +在 `dsh` 下,引用依次从继承环境、模型页的 `$DSH_HOME/.credentials.yaml` 存储、调用目录的 `.env` 和 `$DSH_HOME/.env` 解析。未挂载凭据服务时,引用只读取同名环境变量。一份凭据供该路由上的所有模型使用。 ## 让 agent 用上新提供方 diff --git a/examples/acp-agent/cordis.yml b/examples/acp-agent/cordis.yml index 1af9fa6316..59e7b78278 100644 --- a/examples/acp-agent/cordis.yml +++ b/examples/acp-agent/cordis.yml @@ -9,8 +9,6 @@ - id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' config: - apiKey: !!js process.env.DEEPSEEK_API_KEY - baseURL: !!js process.env.DEEPSEEK_BASE_URL thinking: enabled reasoningEffort: max models: diff --git a/examples/acp-agent/retry.cordis.yml b/examples/acp-agent/retry.cordis.yml index 589120c080..087faa271d 100644 --- a/examples/acp-agent/retry.cordis.yml +++ b/examples/acp-agent/retry.cordis.yml @@ -13,8 +13,6 @@ - id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' config: - apiKey: !!js process.env.DEEPSEEK_API_KEY - baseURL: !!js process.env.DEEPSEEK_BASE_URL thinking: enabled reasoningEffort: max retryPolicy: diff --git a/examples/acp-agent/tests/fs-search.cordis.yml b/examples/acp-agent/tests/fs-search.cordis.yml index c86b34b8aa..9f9ac7cf0c 100644 --- a/examples/acp-agent/tests/fs-search.cordis.yml +++ b/examples/acp-agent/tests/fs-search.cordis.yml @@ -2,8 +2,6 @@ - id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' config: - apiKey: !!js process.env.DEEPSEEK_API_KEY - baseURL: !!js process.env.DEEPSEEK_BASE_URL models: - id: deepseek-v4-pro diff --git a/examples/acp-agent/tests/pwsh.cordis.yml b/examples/acp-agent/tests/pwsh.cordis.yml index 7021ae2116..570d98bf4d 100644 --- a/examples/acp-agent/tests/pwsh.cordis.yml +++ b/examples/acp-agent/tests/pwsh.cordis.yml @@ -2,8 +2,6 @@ - id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' config: - apiKey: !!js process.env.DEEPSEEK_API_KEY - baseURL: !!js process.env.DEEPSEEK_BASE_URL models: - id: deepseek-v4-pro diff --git a/examples/headless-agent/cordis.yml b/examples/headless-agent/cordis.yml index f2d197d662..b229846797 100644 --- a/examples/headless-agent/cordis.yml +++ b/examples/headless-agent/cordis.yml @@ -9,7 +9,7 @@ - id: settings name: '@deepseek-ai/dsh-settings-local' -# Credential store: the live process environment over `$DSH_HOME/.env` +# Credential store: the live process environment over `$DSH_HOME/.credentials.yaml` # (owner-only file, hot-reloaded). The adapter resolves `DEEPSEEK_API_KEY` # through it at each request, so no key is inlined in this file. - id: credentials diff --git a/examples/headless-agent/tests/fixtures/deepseek-defaults.cordis.yml b/examples/headless-agent/tests/fixtures/deepseek-defaults.cordis.yml index cd472f737d..c501901604 100644 --- a/examples/headless-agent/tests/fixtures/deepseek-defaults.cordis.yml +++ b/examples/headless-agent/tests/fixtures/deepseek-defaults.cordis.yml @@ -5,7 +5,6 @@ patches: - id: llm-deepseek config: - apiKey: snapshot-key baseURL: !!js process.env.DSH_SNAPSHOT_BASE_URL thinking: disabled - id: cli-agent diff --git a/examples/headless-agent/tests/headless.snapshot.ts b/examples/headless-agent/tests/headless.snapshot.ts index 9de860d2b5..121bbf75ed 100644 --- a/examples/headless-agent/tests/headless.snapshot.ts +++ b/examples/headless-agent/tests/headless.snapshot.ts @@ -246,16 +246,22 @@ describe('headless stream-json snapshots', () => { prepare: (cwd) => { runCwd = cwd }, }) + // The failure reaches the caller through the stream, not stderr; the + // recorded transcript below pins the guidance text itself, which names + // both places a credential can come from and nothing else. expect(result.stderr).toBe('') const normalized = normalizeHeadlessStream(result.stdout, runCwd) if (refreshing) await writeFile(streamExpected, normalized) expect(normalized).toBe(await readFile(streamExpected, 'utf8')) // The durable failure leads with the credential store — the path that - // keeps the secret out of configuration files — and offers a literal key last. + // keeps the secret out of configuration files — then names the launching + // environment, and stops there: configuration carries the reference, so + // there is no literal-key escape hatch left to offer. expect(normalized).toContain( 'store DEEPSEEK_API_KEY through the credentials service (the web Models page writes it),', ) - expect(normalized).toContain('as a last resort') + expect(normalized).toContain('or export DEEPSEEK_API_KEY in the launching environment') + expect(normalized).not.toContain('as a last resort') }, LOADER_SMOKE_TEST_TIMEOUT_MS) it('logs actionable invalid-credential guidance through the one-shot app', async () => { @@ -351,6 +357,9 @@ describe('headless stream-json snapshots', () => { ], tsconfigPath, env: { + // Configuration carries only the reference; the key rides the + // launching environment, which is the whole credential plane here. + DEEPSEEK_API_KEY: 'snapshot-key', DSH_SNAPSHOT_BASE_URL: server.url, NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '), }, diff --git a/examples/headless-agent/tests/snapshots/missing-credential/stream-json.expected.jsonl b/examples/headless-agent/tests/snapshots/missing-credential/stream-json.expected.jsonl index 16a690dd88..0870c833f1 100644 --- a/examples/headless-agent/tests/snapshots/missing-credential/stream-json.expected.jsonl +++ b/examples/headless-agent/tests/snapshots/missing-credential/stream-json.expected.jsonl @@ -6,7 +6,7 @@ {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":5,"time":0,"data":{"title":"say pong","messageSeqs":[4],"source":{"kind":"fallback"}}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":6,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/context","seq":7,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":1000000}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"error","failure":{"message":"llm-deepseek: no API key for provider route \"deepseek-official\"; store DEEPSEEK_API_KEY through the credentials service (the web Models page writes it), export DEEPSEEK_API_KEY in the launching environment, or — as a last resort — set a literal \"apiKey\" in the llm-deepseek settings section","code":"MISSING_CREDENTIAL"}}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"error","failure":{"message":"llm-deepseek: no API key for provider route \"deepseek-official\"; store DEEPSEEK_API_KEY through the credentials service (the web Models page writes it), or export DEEPSEEK_API_KEY in the launching environment","code":"MISSING_CREDENTIAL"}}}}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":9,"time":0,"data":{"turn":1,"step":1}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":10,"time":0,"data":{"turn":1,"reason":{"kind":"error","error":{"message":"llm-deepseek: no API key for provider route \"deepseek-official\"; store DEEPSEEK_API_KEY through the credentials service (the web Models page writes it), export DEEPSEEK_API_KEY in the launching environment, or — as a last resort — set a literal \"apiKey\" in the llm-deepseek settings section","code":"MISSING_CREDENTIAL"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":10,"time":0,"data":{"turn":1,"reason":{"kind":"error","error":{"message":"llm-deepseek: no API key for provider route \"deepseek-official\"; store DEEPSEEK_API_KEY through the credentials service (the web Models page writes it), or export DEEPSEEK_API_KEY in the launching environment","code":"MISSING_CREDENTIAL"}}}}} {"type":"result","sessionId":"{{sessionId}}","output":""} diff --git a/examples/jsonrpc-agent/cordis.yml b/examples/jsonrpc-agent/cordis.yml index 2c0890a825..39f228b1bc 100644 --- a/examples/jsonrpc-agent/cordis.yml +++ b/examples/jsonrpc-agent/cordis.yml @@ -12,8 +12,6 @@ - id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' config: - apiKey: !!js process.env.DEEPSEEK_API_KEY - baseURL: !!js process.env.DEEPSEEK_BASE_URL thinking: enabled reasoningEffort: max diff --git a/examples/jsonrpc-agent/persistent-tools.cordis.yml b/examples/jsonrpc-agent/persistent-tools.cordis.yml index b5ae81b100..6f42441ca7 100644 --- a/examples/jsonrpc-agent/persistent-tools.cordis.yml +++ b/examples/jsonrpc-agent/persistent-tools.cordis.yml @@ -8,8 +8,6 @@ - id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' config: - apiKey: !!js process.env.DEEPSEEK_API_KEY - baseURL: !!js process.env.DEEPSEEK_BASE_URL - id: sandbox name: '@deepseek-ai/dsh-sandbox-local' diff --git a/knip.json b/knip.json index e6ed5506e3..f90e6c8f8d 100644 --- a/knip.json +++ b/knip.json @@ -116,6 +116,15 @@ "tests/**/*.ts" ] }, + "packages/api/remotes": { + "entry": [ + "tests/**/*.e2e.ts" + ], + "project": [ + "src/**/*.ts", + "tests/**/*.ts" + ] + }, "packages/client/ui-primitives": { "entry": [ "tests/**/*.spec.tsx" @@ -201,7 +210,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..b1ad6853fc 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", @@ -58,6 +62,7 @@ "verify-md-links": "tsx scripts/verify-md-links.ts", "verify-doc-refs": "tsx scripts/verify-doc-refs.ts", "verify-package-paths": "tsx scripts/verify-package-paths.ts", + "verify-config-source-ownership": "tsx scripts/verify-config-source-ownership.ts", "verify-package-invariants": "tsx scripts/verify-package-invariants.ts", "verify-built-package-invariants": "node scripts/verify-built-package-invariants.mjs", "verify-package-readme-model-experience": "tsx scripts/verify-package-readme-model-experience.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..197d68e831 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: @@ -68,12 +77,10 @@ - id: settings name: '@deepseek-ai/dsh-settings-local' - # Credential store: the live process environment over `$DSH_HOME/.env` - # (owner-only file, hot-reloaded). Adapters resolve their key references - # through it at each request, so no key is inlined in this file. The web - # Models page's key inputs write it through `credentials.set`; nothing hoists - # the document into the process environment, which would make every stored key - # read as an unrotatable ambient override. + # Credential sources: inherited environment over the managed + # `$DSH_HOME/.credentials.yaml`, with project and user `.env` fallbacks. + # Adapters resolve references per request; the Models page writes only the + # managed document, which is never materialized into the process environment. - id: credentials name: '@deepseek-ai/dsh-credentials-local' @@ -373,7 +380,6 @@ name: '@deepseek-ai/dsh-web-search-deepseek' config: apiKeyEnv: DEEPSEEK_API_KEY - baseURL: !!js process.env.DEEPSEEK_SEARCH_BASE_URL - id: tool-web name: '@deepseek-ai/dsh-tool-web' diff --git a/packages/bundle/base/package.json b/packages/bundle/base/package.json index f7ee47e343..9d4276e105 100644 --- a/packages/bundle/base/package.json +++ b/packages/bundle/base/package.json @@ -52,6 +52,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:^", @@ -100,6 +101,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..739f045ce6 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -35,11 +35,6 @@ # once the web UI owns the choice per session. mode: !!js process.env.DSH_TOOLS_MODE -- id: llm-deepseek - config: - apiKey: !!js process.env.DEEPSEEK_API_KEY - baseURL: !!js process.env.DEEPSEEK_BASE_URL - # ── web-only host rows, the transport layer, and the browser roster ───────── # `dshClient` rows are the browser roster the modules node half scans into @@ -124,6 +119,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 b636316b68..711510b705 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 @@ -47,11 +49,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" }, 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 06f88a9131..5a1677df96 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' @@ -26,7 +28,7 @@ export type { ISession, ProjectionsFace, SessionFace } from './contract/session. export type { ISessionHistory, SessionHistoryFace, SessionHistorySnapshot, } from './contract/session-history.ts' -export type { ISessions } from './contract/sessions.ts' +export type { AgentContext, ISessions } from './contract/sessions.ts' export type { IWorkspaces } from './contract/workspaces.ts' export type { SessionBinding, SessionListState, SessionProvideContribution, SessionProvideDescriptor, SessionSummary, @@ -75,6 +77,13 @@ export type { SessionId } from '@deepseek-ai/dsh-client-connection/client' /** Client-side Cordis context after declaration merging. */ export type ClientContext = Context +declare module '@deepseek-ai/dsh-type-meta' { + interface TypeRTContextMap { + /** Client Agent scope identity; the agent and session share one wire id. */ + agent: TypeRTContext + } +} + /** The conversation-snapshot selector hook (ConvViewProps/ToolRowProps take this). */ export type UseConversationSession = SnapshotSelectorHook @@ -170,8 +179,8 @@ declare module 'cordis' { } } -/** Required services: the wire handle mounted by the connection plugin. */ -export const inject = ['connection'] +/** Required services: the 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. @@ -180,6 +189,9 @@ export function apply(ctx: Context): void { ctx.plugin(SlotsService) const connection = ctx.get('connection') as ConnectionHandle const sessions = new SessionsService(ctx, connection.api) + ctx.typert.contexts.registerClient('agent', { + identity: candidate => sessions.scopeOf(candidate), + }) const sessionHistory = new SessionHistoryService(ctx, connection.api) const workspaces = new WorkspacesService(ctx, connection.api, sessions) ctx.effect( diff --git a/packages/client/runtime/src/client/sessions/service.ts b/packages/client/runtime/src/client/sessions/service.ts index b1b271e702..621760df02 100644 --- a/packages/client/runtime/src/client/sessions/service.ts +++ b/packages/client/runtime/src/client/sessions/service.ts @@ -29,7 +29,7 @@ import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/t import type { SnapshotStore } from '../contract/store.ts' import { createSnapshotStore } from '../contract/store.ts' import type { SessionFace } from '../contract/session.ts' -import type { ISessions } from '../contract/sessions.ts' +import type { AgentContext, ISessions } from '../contract/sessions.ts' import { createScope, scopeOf as scopeTagOf } from '../agents/scope.ts' import { SessionManager } from './manager.ts' import type { SessionListPhase, SessionSearchResultItem, SubagentCatalogSnapshot } from './manager.ts' @@ -127,7 +127,7 @@ export interface SessionBinding { readonly sessionId: SessionId /** The outward session face only — feature code never sees the concrete class. */ readonly session: SessionFace - readonly ctx: Context + readonly ctx: AgentContext } // Scope primitives live in ../agents/scope.ts (the client mirror of host @@ -182,7 +182,7 @@ function increasedForkTitle(title: string): string { interface ScopeRecord { fiber: Fiber - ctx: Context + ctx: AgentContext binding: SessionBinding /** The concrete Session for runtime-internal entry points (staging open()); the binding carries only the outward face. */ session: Session @@ -483,7 +483,7 @@ export class SessionsService implements ISessions { * @param id - session id (the agent identity — 1:1 same axis). * @returns scoped ctx, or undefined for a session neither listed nor already scoped. */ - scope(id: SessionId): Context | undefined { + scope(id: SessionId): AgentContext | undefined { return this.resolve(id)?.ctx } diff --git a/packages/client/runtime/tests/client-apply.spec.ts b/packages/client/runtime/tests/client-apply.spec.ts index 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 f1512c7059..efbf7c26d7 100644 --- a/packages/client/runtime/tsconfig.json +++ b/packages/client/runtime/tsconfig.json @@ -20,6 +20,9 @@ { "path": "../connection" }, + { + "path": "../../api/remotes" + }, { "path": "../../host/apiproxy" }, @@ -43,6 +46,12 @@ }, { "path": "../../support/invariants" + }, + { + "path": "../../typert/type-meta" + }, + { + "path": "../../typert/registry" } ], "exclude": [ diff --git a/packages/client/test-runtime/src/sessions.ts b/packages/client/test-runtime/src/sessions.ts index e313b63fd2..4747929572 100644 --- a/packages/client/test-runtime/src/sessions.ts +++ b/packages/client/test-runtime/src/sessions.ts @@ -3,7 +3,7 @@ import type { Context } from 'cordis' import { createScope, scopeOf, SessionProvideChannel } from '@deepseek-ai/dsh-client-runtime/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import type { - ConversationSnapshot, ISessions, ObservableSnapshot, ProjectionsFace, SessionFace, SessionId, + AgentContext, ConversationSnapshot, ISessions, ObservableSnapshot, ProjectionsFace, SessionFace, SessionId, SessionListState, SessionProvideDescriptor, SessionSearchResultItem, SessionSummary, SnapshotStore, SubagentAddress, } from '@deepseek-ai/dsh-client-runtime/client' @@ -134,7 +134,7 @@ interface SessionRecord { summary: SessionSummary snapshot: SnapshotStore session: FixtureSession - scope: Context | undefined + scope: AgentContext | undefined scopeFiber: { dispose(): Promise } | undefined /** Materialized standard-props bundle (identity-stable per session; invalidated on roster change). */ provideInfo: SessionProvideInfo | undefined @@ -144,7 +144,7 @@ interface SessionRecord { export interface TestSessionBinding { readonly sessionId: SessionId readonly session: FixtureSession - readonly ctx: Context + readonly ctx: AgentContext } /** @@ -345,7 +345,7 @@ export class TestSessions implements ISessions { * @param id - session id. * @returns the scoped context, or undefined for unknown sessions. */ - scope(id: string): Context | undefined { + scope(id: string): AgentContext | undefined { const record = this.records.get(id as SessionId) if (record === undefined) return undefined if (record.scope === undefined) { diff --git a/packages/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 7794833aa0..b6bf25d410 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md -README.md: 2d956f31a737d345393232aec9ce55b429e5b4d8 -README.zh.md: 087babe2ff878c69c668ad8fdf22b345f38ac204 +README.md: 0cf50146cc44ef0d6cc060a4c97b3d1ff454f013 +README.zh.md: 8bfb96bb9326d8fcadc3c357b6abaad88c92bd17 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 2d956f31a7..0cf50146cc 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -16,7 +16,7 @@ Approvals take over the composer through the chain this package declares: `Appro The session header declares and renders the session-scoped `'conversation.session.header.actions'` list beside the title, allowing feature plugins to contribute controls without entering the skeleton. The composer chain currency includes the current conversation `session`; ui-subagent selects one-shot or parent-unavailable addressed sessions for reason-specific read-only copy, while the ordinary InputBar keeps every addressed child Send-only because the continuation service exposes no public per-Activation cancellation operation and `session.cancel` would bypass its ownership. -Logged non-user messages render as a default-collapsed disclosure whose header names the role the runtime projected for the message — `上下文注入` for an injection, `跨会话召回` for a recalled session — followed by the producer name that projection read out of the durable source, so a reader distinguishes a skill catalog from a workspace instruction file or a recalled session without expanding. A source that names no producer shows the role alone. The header shares the Tool calls geometry and interaction with `ToolRow` through the package-internal `DisclosureRow`, while retaining context semantics: the expanded body follows its content height up to a 141px scrolling cap and synthesizes no tool state, summary, or keyed toolview dispatch ([disclosure decision](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md), [provenance decision](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md)). That body follows the form the producer declared on its durable source: `instructions` names the reconciled files above their text, `catalog` lists the entries the source recorded instead of the model-facing prose, and every other value — absent, unknown to this version, or carrying no usable fields — renders the opaque body, which shows the model-facing text with its real line breaks and the remaining provenance as fields. The opaque body is the documented default, not a leftover: a resumed, forked, or foreign log must render whether or not its producer is mounted here. A durable or pending steering bubble carries an `插话` / `Interjection` caption above it, the only thing distinguishing a mid-turn interjection from the turn-opening prompt that shares its bubble. +Logged non-user messages render as a default-collapsed disclosure whose header names the role the runtime projected for the message — `上下文注入` for an injection, `跨会话召回` for a recalled session — followed by the producer name that projection read out of the durable source, so a reader distinguishes a skill catalog from a workspace instruction file or a recalled session without expanding. A source that names no producer shows the role alone. The header shares the Tool calls geometry and interaction with `ToolRow` through the package-internal `DisclosureRow`, while retaining context semantics: the expanded body follows its content height up to a 141px scrolling cap and synthesizes no tool state, summary, or keyed toolview dispatch ([historical disclosure decision](../../../.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.md), [provenance decision](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md)). That body follows the form the producer declared on its durable source: `instructions` names the reconciled files above their text, `catalog` lists the entries the source recorded instead of the model-facing prose, and every other value — absent, unknown to this version, or carrying no usable fields — renders the opaque body, which shows the model-facing text with its real line breaks and the remaining provenance as fields. The opaque body is the documented default, not a leftover: a resumed, forked, or foreign log must render whether or not its producer is mounted here. A durable or pending steering bubble carries an `插话` / `Interjection` caption above it, the only thing distinguishing a mid-turn interjection from the turn-opening prompt that shares its bubble. A Think row stays collapsed by default and exposes live reasoning throughput without expanding the chain of thought: while its reasoning block is the streaming tail, the summary switches from the settled first line to the latest non-blank line and its one-line scrollport follows each delta to the inline end. Expanding the row removes the moving summary and leaves the full reasoning in ordinary page flow, so page reading never fights an internal follower; settlement restores the stable first-line summary at the left edge ([decision](../../../.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md)). diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 087babe2ff..8bfb96bb93 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -14,7 +14,7 @@ 会话页头会在标题旁声明并渲染 Session scope 的 `'conversation.session.header.actions'` 列表,使功能插件无需进入骨架即可贡献控件。编辑器链的 currency 包含当前对话 `session`;ui-subagent 会选取 one-shot 或 parent 不可用的已寻址会话,并按原因显示只读文案,而普通 InputBar 会让所有已寻址 child 仅保留 Send,因为继续执行服务不公开逐 Activation 取消操作,`session.cancel` 也会绕过其所有权。 -已记录的非用户消息渲染为默认折叠的展开项,标题栏先给出运行时为该消息投影出的角色——注入为 `上下文注入`,召回为 `跨会话召回`——其后是该投影从持久来源读出的生产者名称,因此读者无需展开即可区分 skill(技能)目录、工作区指令文件与被召回的会话。来源未提供生产者名称时只显示角色。标题栏通过包内部的 `DisclosureRow` 与 `ToolRow` 共享 Tool calls 的几何与交互,同时保留上下文语义:展开内容区的高度会随内容自适应,最大为 141px,超出后滚动,且不会合成工具状态、摘要或键控 toolview 分发([展开项决策](../../../.agents/notes/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/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 903591163c..af85c1c5ae 100644 --- a/packages/client/ui-layout/tests/apply.spec.ts +++ b/packages/client/ui-layout/tests/apply.spec.ts @@ -7,7 +7,7 @@ // coverage gate still requires exercised. import { Context } from 'cordis' -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' import { apply as themeApply, inject as themeInject, ThemeService } from '@deepseek-ai/dsh-client-ui-theme/client' @@ -15,6 +15,10 @@ import { apply, inject, LayoutService } from '@deepseek-ai/dsh-client-ui-layout/ import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-layout' import * as invariant from '@deepseek-ai/dsh-client-ui-layout/invariant' +beforeEach(() => { + document.head.querySelectorAll('meta[name="theme-color"]').forEach((node) => { node.remove() }) +}) + async function bench() { const ctx = new Context() const slotsFiber = ctx.plugin(SlotsService) @@ -65,13 +69,17 @@ describe('ui-layout client apply', () => { // Initial getter application: jsdom has no matchMedia, system resolves light. expect(document.documentElement.style.colorScheme).toBe('light') expect(document.body.hasAttribute('data-ds-dark-theme')).toBe(false) + const themeColorMeta = document.head.querySelector('meta[name="theme-color"]') + expect(themeColorMeta).not.toBeNull() const theme = ctx.get('theme') as ThemeService theme.setTheme('dark') expect(document.documentElement.style.colorScheme).toBe('dark') expect(document.body.hasAttribute('data-ds-dark-theme')).toBe(true) + expect(document.head.querySelector('meta[name="theme-color"]')).toBe(themeColorMeta) await fiber.dispose() expect(document.documentElement.style.colorScheme).toBe('') expect(document.body.hasAttribute('data-ds-dark-theme')).toBe(false) + expect(themeColorMeta?.isConnected).toBe(false) // Listener is off: further theme changes no longer reach the document. theme.setTheme('light') theme.setTheme('dark') diff --git a/packages/client/ui-layout/tests/theme-presenter.spec.ts b/packages/client/ui-layout/tests/theme-presenter.spec.ts index a14d781e5f..36a4975fc9 100644 --- a/packages/client/ui-layout/tests/theme-presenter.spec.ts +++ b/packages/client/ui-layout/tests/theme-presenter.spec.ts @@ -1,40 +1,68 @@ // @vitest-environment jsdom // ThemePresenter behavior account: root color-scheme and the palette attribute // follow active.colorScheme only, token variables replace the previous apply's -// set, and dispose retracts everything the presenter wrote. +// set, theme-color metadata follows the rendered body background, and dispose +// retracts everything the presenter wrote. -import { beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' import type { ThemeSnapshot } from '@deepseek-ai/dsh-client-ui-theme/client' import { DARK_ATTRIBUTE, ThemePresenter } from '@deepseek-ai/dsh-client-ui-layout/src/client/theme-presenter.ts' +const LIGHT_THEME_COLOR = 'rgb(255, 255, 255)' +const DARK_THEME_COLOR = 'rgb(21, 21, 23)' + function snapshot(colorScheme: 'light' | 'dark', tokens: Record = {}): ThemeSnapshot { // The presenter must key off colorScheme, not the id — keep them distinct. const active = { id: `${colorScheme}-test`, colorScheme, tokens } return { preference: colorScheme, active, themes: [active], revision: 1 } } +function clearThemePresentation(): void { + document.head.querySelectorAll('meta[name="theme-color"], style[data-theme-presenter-test]').forEach((node) => { node.remove() }) +} + +function themeColorMeta(): HTMLMetaElement | null { + return document.head.querySelector('meta[name="theme-color"]') +} + beforeEach(() => { + clearThemePresentation() document.documentElement.style.removeProperty('color-scheme') document.body.removeAttribute(DARK_ATTRIBUTE) document.body.removeAttribute('style') + const style = document.createElement('style') + style.dataset.themePresenterTest = '' + style.textContent = ` + body { background-color: ${LIGHT_THEME_COLOR}; } + body[${DARK_ATTRIBUTE}] { background-color: ${DARK_THEME_COLOR}; } + ` + document.head.append(style) }) +afterEach(clearThemePresentation) + describe('ThemePresenter', () => { it('light scheme sets root color-scheme and leaves the dark attribute absent', () => { const presenter = new ThemePresenter() presenter.apply(snapshot('light')) expect(document.documentElement.style.colorScheme).toBe('light') expect(document.body.hasAttribute(DARK_ATTRIBUTE)).toBe(false) + expect(themeColorMeta()?.content).toBe(LIGHT_THEME_COLOR) }) - it('dark scheme sets root color-scheme and the attribute; switching to light clears both', () => { + it('dark scheme sets root color-scheme, the attribute, and metadata; switching to light updates one node', () => { const presenter = new ThemePresenter() presenter.apply(snapshot('dark')) + const meta = themeColorMeta() expect(document.documentElement.style.colorScheme).toBe('dark') expect(document.body.hasAttribute(DARK_ATTRIBUTE)).toBe(true) + expect(meta?.content).toBe(DARK_THEME_COLOR) presenter.apply(snapshot('light')) expect(document.documentElement.style.colorScheme).toBe('light') expect(document.body.hasAttribute(DARK_ATTRIBUTE)).toBe(false) + expect(themeColorMeta()).toBe(meta) + expect(meta?.content).toBe(LIGHT_THEME_COLOR) + expect(document.head.querySelectorAll('meta[name="theme-color"]')).toHaveLength(1) }) it('applies tokens as inline variables and clears the previous set on theme change', () => { @@ -52,10 +80,12 @@ describe('ThemePresenter', () => { document.body.style.setProperty('--foreign', 'kept') const presenter = new ThemePresenter() presenter.apply(snapshot('dark', { '--dsw-alias-bg': '#111' })) + const meta = themeColorMeta() presenter.dispose() expect(document.documentElement.style.colorScheme).toBe('') expect(document.body.hasAttribute(DARK_ATTRIBUTE)).toBe(false) expect(document.body.style.getPropertyValue('--dsw-alias-bg')).toBe('') expect(document.body.style.getPropertyValue('--foreign')).toBe('kept') + expect(meta?.isConnected).toBe(false) }) }) diff --git a/packages/client/ui-models/README.i18n.yaml b/packages/client/ui-models/README.i18n.yaml index 5d579d3b51..5e7b29d244 100644 --- a/packages/client/ui-models/README.i18n.yaml +++ b/packages/client/ui-models/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-models/README.md -README.md: cf4e50630339c4055e9ae2df37246b814af06966 -README.zh.md: 2b9158fa4419bf07f496fce47c938744f2a4233f +README.md: 66f23d3f77adae23fcebd3adbb6c5c47ce16ae2f +README.zh.md: dcb35c624aca73b558532210750f684e5cc54e85 diff --git a/packages/client/ui-models/README.md b/packages/client/ui-models/README.md index cf4e506303..66f23d3f77 100644 --- a/packages/client/ui-models/README.md +++ b/packages/client/ui-models/README.md @@ -4,11 +4,11 @@ English | [中文](README.zh.md) Models settings plugin: the provider configuration page and official-DeepSeek conditional onboarding step. It joins three wire domains into one shared snapshot — `llm.providers` (the configurable-provider directory with each route's live/dormant state), `settings.describe` (serialized schemas, layered redacted values, secret slots), and `credentials.describe` (value-free configured/source/writable badges) — and renders provider rows with one editor card at a time, without presenting route liveness as provider status. -Rows are the *configured* providers (their profile resolves in the owning namespace); a whole-section provider whose key is not configured anywhere (the first-run DeepSeek posture) renders as its open setup card instead of a row, and the add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. The pi-ai card additionally edits that route's **model list** and can ask the provider what it serves. A row labels API-key state with a green solid dot only when a literal key or referenced credential is confirmed configured, and with a red solid dot only when a named reference is confirmed missing; reference-free provider-native authentication and unavailable credential enrichment remain unmarked. The editor is a hand-written card per adapter family: the primary field is a single **API key** input — the page never asks for an environment-variable name; a typed key stores **write-only** through `credentials.set` under the profile's reference, deriving `_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `settings.yaml` never carries a key value. Leaving a new pi-ai provider's key blank saves a reference-free profile and therefore preserves provider-native authentication such as the Bedrock credential chain or Vertex ADC. A successful Apply emits a local accessible status message without echoing secret material. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint) and each adapter's model catalog. Reasoning effort is deliberately NOT among them: it is a per-model capability and the models under one provider disagree about which levels they accept, so a provider-scoped control could only be set to a value some of them reject — which took the whole provider out of the model picker. The composer's model picker offers each model its own levels, and a switch there records provider, model, and effort together as the default for the next session. The profile field stays in `settings.yaml` for a deployment that knows its route. Each DeepSeek row edits `id`, optional display `name`, and optional `contextWindow`; existing fields outside that curated set survive edits, while every other profile field stays owned by `settings.yaml`. A row is deletable only when the user layer alone carries it (removal restores the composition base), and its localized confirmation dialog names the provider in the title, description, and final action. A row is tagged **Custom** when the directory entry says the owning adapter ships nothing under that key. The tag follows that answer alone: having a stored profile does not make a route custom — narrowing a shipped provider's models stores one too — and an adapter that reports nothing leaves its rows untagged rather than being read as shipped. +Rows are the *configured* providers (their profile resolves in the owning namespace); a whole-section provider whose key is not configured anywhere (the first-run DeepSeek posture) renders as its open setup card instead of a row, and the add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. The pi-ai card additionally edits that route's **model list** and can ask the provider what it serves. A row labels API-key state with a green solid dot only when a referenced credential is confirmed configured, and with a red solid dot only when a named reference is confirmed missing; reference-free provider-native authentication and unavailable credential enrichment remain unmarked. The editor is a hand-written card per adapter family: the primary field is a single **API key** input — the page never asks for an environment-variable name; a typed key stores **write-only** through `credentials.set` under the profile's reference, deriving `_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `settings.yaml` never carries a key value. Leaving a new pi-ai provider's key blank saves a reference-free profile and therefore preserves provider-native authentication such as the Bedrock credential chain or Vertex ADC. A successful Apply emits a local accessible status message without echoing secret material. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint) and each adapter's model catalog. Reasoning effort is deliberately NOT among them: it is a per-model capability and the models under one provider disagree about which levels they accept, so a provider-scoped control could only be set to a value some of them reject — which took the whole provider out of the model picker. The composer's model picker offers each model its own levels, and a switch there records provider, model, and effort together as the default for the next session. The profile field stays in `settings.yaml` for a deployment that knows its route. Each DeepSeek row edits `id`, optional display `name`, and optional `contextWindow`; existing fields outside that curated set survive edits, while every other profile field stays owned by `settings.yaml`. A row is deletable only when the user layer alone carries it (removal restores the composition base), and its localized confirmation dialog names the provider in the title, description, and final action. A row is tagged **Custom** when the directory entry says the owning adapter ships nothing under that key. The tag follows that answer alone: having a stored profile does not make a route custom — narrowing a shipped provider's models stores one too — and an adapter that reports nothing leaves its rows untagged rather than being read as shipped. -The DeepSeek step projects `deepseek-official` readiness from that same joined snapshot after earlier onboarding pages complete. It recognizes the official adapter through its `llm-deepseek` configurable-provider declaration, so an undeclared live route with the same provider id is not treated as repairable configuration. A configured literal `apiKey` secret sidecar or configured credential reference completes the step without rendering, including a read-only launch-environment credential. Only a mounted, active adapter with a missing writable reference shows the page that opens Settings on Models, whose existing setup card exclusively owns key input and `credentials.set`; the step never holds a secret. An absent adapter, inactive route, failed join, read-only deployment, or unusable settings or credential capability completes the step without rendering so onboarding cannot block the product; Models remains the diagnostic surface. +The DeepSeek step projects `deepseek-official` readiness from that same joined snapshot after earlier onboarding pages complete. It recognizes the official adapter through its `llm-deepseek` configurable-provider declaration, so an undeclared live route with the same provider id is not treated as repairable configuration. A configured credential reference completes the step without rendering, including a read-only launch-environment credential. Only a mounted, active adapter with a missing writable reference shows the page that opens Settings on Models, whose existing setup card exclusively owns key input and `credentials.set`; the step never holds a secret. An absent adapter, inactive route, failed join, read-only deployment, or unusable settings or credential capability completes the step without rendering so onboarding cannot block the product; Models remains the diagnostic surface. -Every edit lands as `settings.mutate` path ops against the stored section — a set per changed field, an unset per cleared one, and a single unset for a deleted provider row. The page only ever holds the REDACTED descriptor, so it names the fields it can see rather than rebuilding a section: a stored literal secret it never received is mentioned by no op and survives. DeepSeek's `models` is one replace-by-value array: the editor shows inherited effective rows until the first model edit materializes the complete array in the user layer, while reset unsets that override. A row carries the model id and display name; its context window and output cap sit behind the row's own disclosure, the same shape the pi-ai provider form uses. Either capacity is typed as a count with an optional decimal `K` or `M` suffix (`256K`, `1M`; `1M` is 1000K) and stored as the plain count, spelled back in the shortest form that round-trips. Empty ids, duplicate ids, empty explicit names, and unreadable, non-positive, or fractional capacities fail before any write. A typed API key is judged on its own field the same way: after trimming, it must be non-empty and every character must be printable ASCII (`[\x21-\x7E]`), which is exactly what an HTTP header value can carry — the twin of `normalizeApiKey` in `@deepseek-ai/dsh-llm`, mirrored here because the source-plane split forbids importing it. A value shaped like a pasted `NAME=value` environment line or wrapped in matching quotes is refused as the same format failure; that paste-shape heuristic runs only in the browser, since a false positive in a resolver would leave the environment refusing the key as well. A field holding only whitespace fails rather than being silently dropped, while an empty field is not a failure at all: it means keep the stored key on an editor card, and authenticate some other way on a create card. A refused key blocks both the write and the endpoint interrogation, so the page never spends a round trip to be told what the field already says. Each settings write carries the card's current `revision`, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings-conflict`; after settings commit, the card adopts the returned redacted user subtree and revision before storing the credential, which makes a failed credential stage retry only that stage. Deletion removes a configured, writable credential only when the profile names the page's derived `_API_KEY` target, then unsets the profile; both operations are idempotent, and a partial failure remains in the identified confirmation dialog for retry. Environment credentials, custom references, and credentials whose target cannot be identified remain untouched. The page refetches on the pushed invalidations (`settings/changed`, `credentials/changed`, `models/changed`, and `connection/reset`) once it has loaded, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling. +Every edit lands as `settings.mutate` path ops against the stored section — a set per changed field, an unset per cleared one, and a single unset for a deleted provider row. The page only ever holds the REDACTED descriptor, so it mutates the fields it can see rather than rebuilding a section. DeepSeek's `models` is one replace-by-value array: the editor shows inherited effective rows until the first model edit materializes the complete array in the user layer, while reset unsets that override. A row carries the model id and display name; its context window and output cap sit behind the row's own disclosure, the same shape the pi-ai provider form uses. Either capacity is typed as a count with an optional decimal `K` or `M` suffix (`256K`, `1M`; `1M` is 1000K) and stored as the plain count, spelled back in the shortest form that round-trips. Empty ids, duplicate ids, empty explicit names, and unreadable, non-positive, or fractional capacities fail before any write. A typed API key is judged on its own field the same way: after trimming, it must be non-empty and every character must be printable ASCII (`[\x21-\x7E]`), which is exactly what an HTTP header value can carry — the twin of `normalizeApiKey` in `@deepseek-ai/dsh-llm`, mirrored here because the source-plane split forbids importing it. A value shaped like a pasted `NAME=value` environment line or wrapped in matching quotes is refused as the same format failure; that paste-shape heuristic runs only in the browser, since a false positive in a resolver would leave the environment refusing the key as well. A field holding only whitespace fails rather than being silently dropped, while an empty field is not a failure at all: it means keep the stored key on an editor card, and authenticate some other way on a create card. A refused key blocks both the write and the endpoint interrogation, so the page never spends a round trip to be told what the field already says. Each settings write carries the card's current `revision`, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings-conflict`; after settings commit, the card adopts the returned redacted user subtree and revision before storing the credential, which makes a failed credential stage retry only that stage. Deletion removes a configured, writable credential only when the profile names the page's derived `_API_KEY` target, then unsets the profile; both operations are idempotent, and a partial failure remains in the identified confirmation dialog for retry. Environment credentials, custom references, and credentials whose target cannot be identified remain untouched. The page refetches on the pushed invalidations (`settings/changed`, `credentials/changed`, `models/changed`, and `connection/reset`) once it has loaded, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling. ## Model list and endpoint interrogation diff --git a/packages/client/ui-models/README.zh.md b/packages/client/ui-models/README.zh.md index 2b9158fa44..dcb35c624a 100644 --- a/packages/client/ui-models/README.zh.md +++ b/packages/client/ui-models/README.zh.md @@ -4,11 +4,11 @@ 模型设置插件:提供方配置页和按条件显示的 DeepSeek 官方首次使用引导步骤。它把三个协议领域汇聚为一个共享快照:`llm.providers`(可配置提供方目录,含每条路由的存活/休眠状态)、`settings.describe`(序列化 schema、分层脱敏值、secret 槽位)与 `credentials.describe`(不含值的 configured/source/writable 徽标);页面据此渲染提供方行,一次只展开一张编辑卡片,且不把路由存活状态呈现为提供方状态。 -行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出);密钥未在任何地方配置的整分节提供方(DeepSeek 的首次运行姿态)会渲染为其展开的设置卡片而非一行,「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。pi-ai 卡片还会编辑该路由的**模型列表**,并可以询问提供方它服务什么。只有确认字面密钥或引用的凭据已配置时,行才会以绿色实心点标示 API 密钥状态;只有确认具名引用缺失时,才会以红色实心点标示。无引用的提供方原生认证以及无法取得凭据补充信息时都不显示状态点。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。为新的 pi-ai 提供方留空密钥会保存一个不带引用的 profile,因此能保留提供方原生认证,例如 Bedrock 凭据链或 Vertex ADC。「应用」成功后会发出本地无障碍状态消息,且绝不回显任何机密内容。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`(deepseek 的占位符显示公共端点),以及各适配器自己的模型目录。推理等级刻意**不在**其中:它是按模型的能力,而同一提供方下各模型接受的档位并不一致,因此提供方级的控件只可能被设成其中一些模型会拒绝的值——那会让整个提供方从模型选择器里消失。输入框的模型选择器为每个模型提供它自己的档位,在那里切换会把提供方、模型、推理等级一并记为下一个会话的默认值。profile 字段仍留在 `settings.yaml`,供清楚自己路由的部署使用。每条 DeepSeek 模型行可编辑 `id`、可选的显示名称 `name` 与可选的 `contextWindow`;精选集合以外的现有字段会在编辑后保留,其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base),其本地化确认对话框会在标题、说明和最终操作中点名该提供方。当目录条目表明拥有该路由的适配器在这个键下什么都没有时,该行会带上 **自定义** 标签。标签只跟随这个答案:存了 profile 并不使一条路由成为自定义——收窄一个内置提供方的模型同样会存下 profile——而什么都不回答的适配器,其路由保持无标签,不会被当成内置。 +行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出);密钥未在任何地方配置的整分节提供方(DeepSeek 的首次运行姿态)会渲染为其展开的设置卡片而非一行,「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。pi-ai 卡片还会编辑该路由的**模型列表**,并可以询问提供方它服务什么。只有确认引用的凭据已配置时,行才会以绿色实心点标示 API 密钥状态;只有确认具名引用缺失时,才会以红色实心点标示。无引用的提供方原生认证以及无法取得凭据补充信息时都不显示状态点。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。为新的 pi-ai 提供方留空密钥会保存一个不带引用的 profile,因此能保留提供方原生认证,例如 Bedrock 凭据链或 Vertex ADC。「应用」成功后会发出本地无障碍状态消息,且绝不回显任何机密内容。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`(deepseek 的占位符显示公共端点),以及各适配器自己的模型目录。推理等级刻意**不在**其中:它是按模型的能力,而同一提供方下各模型接受的档位并不一致,因此提供方级的控件只可能被设成其中一些模型会拒绝的值——那会让整个提供方从模型选择器里消失。输入框的模型选择器为每个模型提供它自己的档位,在那里切换会把提供方、模型、推理等级一并记为下一个会话的默认值。profile 字段仍留在 `settings.yaml`,供清楚自己路由的部署使用。每条 DeepSeek 模型行可编辑 `id`、可选的显示名称 `name` 与可选的 `contextWindow`;精选集合以外的现有字段会在编辑后保留,其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base),其本地化确认对话框会在标题、说明和最终操作中点名该提供方。当目录条目表明拥有该路由的适配器在这个键下什么都没有时,该行会带上 **自定义** 标签。标签只跟随这个答案:存了 profile 并不使一条路由成为自定义——收窄一个内置提供方的模型同样会存下 profile——而什么都不回答的适配器,其路由保持无标签,不会被当成内置。 -前序首次使用引导页面完成后,DeepSeek 步骤会从同一个联接快照得出 `deepseek-official` 的就绪状态。它通过 `llm-deepseek` 的可配置提供方声明识别官方适配器,因此同 id 但未声明的存活路由不属于可修复配置。若 `apiKey` 字面量对应的 secret 槽位标记为已设置,或凭据引用已配置,该步骤会直接完成而不渲染,其中包括来自启动环境且只读的凭据。只有已挂载且活跃、引用可写但尚未配置的适配器才会显示前往「设置」Models 分区的页面;密钥输入和 `credentials.set` 仅由该分区已有的设置卡片负责,该步骤绝不持有 secret。适配器缺失、路由不活跃、联接失败、部署只读或设置/凭据能力不可用时,该步骤均不渲染并直接完成,以免首次使用引导阻塞产品;Models 页仍是诊断界面。 +前序首次使用引导页面完成后,DeepSeek 步骤会从同一个联接快照得出 `deepseek-official` 的就绪状态。它通过 `llm-deepseek` 的可配置提供方声明识别官方适配器,因此同 id 但未声明的存活路由不属于可修复配置。凭据引用已配置时,该步骤会直接完成而不渲染,其中包括来自启动环境且只读的凭据。只有已挂载且活跃、引用可写但尚未配置的适配器才会显示前往「设置」Models 分区的页面;密钥输入和 `credentials.set` 仅由该分区已有的设置卡片负责,该步骤绝不持有 secret。适配器缺失、路由不活跃、联接失败、部署只读或设置/凭据能力不可用时,该步骤均不渲染并直接完成,以免首次使用引导阻塞产品;Models 页仍是诊断界面。 -每一次编辑都以 `settings.mutate` 的路径 op 落到已存分节上——每个变更字段一条 set、每个清空字段一条 unset、删除提供方行则是单独一条 unset。页面自始至终只持有**脱敏后**的 descriptor,因此它点名自己看得见的字段,而不是重建分节:一个它从未收到过的已存字面机密不会被任何 op 提及,也就得以留存。DeepSeek 的 `models` 是一个按值整体替换的数组:编辑器会显示继承而来的生效模型行,直到第一次模型编辑将完整数组具化到用户层;重置则会取消该覆盖。每个模型行承载模型 ID 与显示名称,其上下文窗口与最大输出 token 数则收在该行自己的折叠区里,与 pi-ai 提供方表单采用的形态相同。两项容量都按数值键入,可带十进制的 `K` 或 `M` 后缀(`256K`、`1M`;`1M` 即 1000K),存储为纯数值,回显时写成能够往返的最短形式。空 ID、重复 ID、显式填写的空名称,以及无法读取、非正数或非整数的容量都会在写入前失败。键入的 API 密钥同样在它自己的字段上被判定:trim 之后必须非空,且每个字符都是可打印 ASCII(`[\x21-\x7E]`)——这正是 HTTP 标头值所能承载的范围,是 `@deepseek-ai/dsh-llm` 中 `normalizeApiKey` 的孪生体,因源码平面分割禁止直接引入而在此镜像。形如整行粘贴的 `NAME=value` 环境变量或首尾成对引号包裹的值,会以同一条格式失败被拒绝;该粘贴形状启发式只在浏览器中运行,因为 resolver 中的一次误判会连带让环境变量这条路也拒绝该密钥。只含空白的输入框会失败而不是被静默丢弃;留空则完全不是失败:在编辑卡片上意味着保持已存储的密钥,在新建卡片上则意味着以其他方式鉴权。被拒绝的密钥会同时拦截写入与端点探测,因此页面不会白花一次往返去换取字段上已经写明的答案。每次 settings 写入都携带卡片当前的 `revision`,因此来自另一个标签页或对 `settings.yaml` 的外部编辑所产生的并发写入会以 `settings-conflict` 被拒绝;settings 提交成功后,卡片会在存储凭据前采用响应返回的脱敏用户子树与 revision,因此凭据阶段失败时,重试只会重复该阶段。删除操作只会在 profile 指向页面派生的 `_API_KEY` 目标时清除已配置且可写的凭据,随后取消设置 profile;两项操作都具备幂等性,部分失败会停留在点名目标的确认对话框中供重试。环境凭据、自定义引用和无法识别目标的凭据保持不变。页面加载完成后会在推送的失效事件(`settings/changed`、`credentials/changed`、`models/changed` 与 `connection/reset`)上重拉,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。 +每一次编辑都以 `settings.mutate` 的路径 op 落到已存分节上——每个变更字段一条 set、每个清空字段一条 unset、删除提供方行则是单独一条 unset。页面自始至终只持有**脱敏后**的 descriptor,因此它只修改自己看得见的字段,而不重建分节。DeepSeek 的 `models` 是一个按值整体替换的数组:编辑器会显示继承而来的生效模型行,直到第一次模型编辑将完整数组具化到用户层;重置则会取消该覆盖。每个模型行承载模型 ID 与显示名称,其上下文窗口与最大输出 token 数则收在该行自己的折叠区里,与 pi-ai 提供方表单采用的形态相同。两项容量都按数值键入,可带十进制的 `K` 或 `M` 后缀(`256K`、`1M`;`1M` 即 1000K),存储为纯数值,回显时写成能够往返的最短形式。空 ID、重复 ID、显式填写的空名称,以及无法读取、非正数或非整数的容量都会在写入前失败。键入的 API 密钥同样在它自己的字段上被判定:trim 之后必须非空,且每个字符都是可打印 ASCII(`[\x21-\x7E]`)——这正是 HTTP 标头值所能承载的范围,是 `@deepseek-ai/dsh-llm` 中 `normalizeApiKey` 的孪生体,因源码平面分割禁止直接引入而在此镜像。形如整行粘贴的 `NAME=value` 环境变量或首尾成对引号包裹的值,会以同一条格式失败被拒绝;该粘贴形状启发式只在浏览器中运行,因为 resolver 中的一次误判会连带让环境变量这条路也拒绝该密钥。只含空白的输入框会失败而不是被静默丢弃;留空则完全不是失败:在编辑卡片上意味着保持已存储的密钥,在新建卡片上则意味着以其他方式鉴权。被拒绝的密钥会同时拦截写入与端点探测,因此页面不会白花一次往返去换取字段上已经写明的答案。每次 settings 写入都携带卡片当前的 `revision`,因此来自另一个标签页或对 `settings.yaml` 的外部编辑所产生的并发写入会以 `settings-conflict` 被拒绝;settings 提交成功后,卡片会在存储凭据前采用响应返回的脱敏用户子树与 revision,因此凭据阶段失败时,重试只会重复该阶段。删除操作只会在 profile 指向页面派生的 `_API_KEY` 目标时清除已配置且可写的凭据,随后取消设置 profile;两项操作都具备幂等性,部分失败会停留在点名目标的确认对话框中供重试。环境凭据、自定义引用和无法识别目标的凭据保持不变。页面加载完成后会在推送的失效事件(`settings/changed`、`credentials/changed`、`models/changed` 与 `connection/reset`)上重拉,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。 ## 模型列表与端点询问 diff --git a/packages/client/ui-models/src/client/ModelsSection.tsx b/packages/client/ui-models/src/client/ModelsSection.tsx index 9cbfba9a5a..5551b847fd 100644 --- a/packages/client/ui-models/src/client/ModelsSection.tsx +++ b/packages/client/ui-models/src/client/ModelsSection.tsx @@ -80,8 +80,8 @@ function renderProviderEditor({ target, ...props }: ProviderEditorRenderProps): * Remove one user-added provider and its page-managed credential. Credential * removal comes first so a second-step failure leaves the provider row visible * and the whole operation safely retryable; both unsets are idempotent. - * The settings removal names the profile rather than rebuilding its redacted - * namespace, which would drop literal secrets stored elsewhere. + * The settings removal names the profile rather than rebuilding its whole + * namespace from a partial view. * @param api - settings and credential wire faces. * @param controller - the page store to refresh. * @param target - the provider's settings address and optional managed credential. @@ -112,16 +112,14 @@ export async function removeProviderProfile( } /** - * Whether a whole-section provider still needs its first key: nothing marks - * the credential configured and no literal `apiKey` is stored, so the page - * opens the setup card instead of showing a row. + * Whether a whole-section provider still needs its first key: an unconfigured + * credential opens the setup card instead of showing a row. * @param row - the joined provider row. * @returns whether to render the setup card. */ export function needsSetup(row: ProviderRow): boolean { if (row.entry.settingsPath.length > 0) return false - if (row.credential?.configured === true) return false - return !row.literalApiKeyConfigured + return row.credential?.configured !== true } function targetOf(row: ProviderRow): EditorTarget { @@ -264,7 +262,7 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode { ) } const open = !adding && editing?.provider === row.entry.provider - const credentialConfigured = row.literalApiKeyConfigured || row.credential?.configured === true + const credentialConfigured = row.credential?.configured === true const credentialMissing = !credentialConfigured && row.apiKeyEnv !== undefined && row.credential?.configured === false diff --git a/packages/client/ui-models/src/client/ProviderEditor.tsx b/packages/client/ui-models/src/client/ProviderEditor.tsx index ff23e35b63..01978273ae 100644 --- a/packages/client/ui-models/src/client/ProviderEditor.tsx +++ b/packages/client/ui-models/src/client/ProviderEditor.tsx @@ -14,9 +14,8 @@ * model picker offers each model its own levels; `settings.yaml` keeps the * profile field for a deployment that knows its route. Everything else stays * owned by `settings.yaml`. Profile edits land as minimal `settings.mutate` - * path ops against the stored section — the card reads the redacted - * descriptor, so it names only the fields it can see and a stored literal - * secret is never collaterally removed. + * path ops against the stored section — the card names only the fields it can + * see instead of rebuilding the whole subtree from a partial descriptor. */ import { useEffect, useMemo, useState } from 'react' @@ -72,10 +71,9 @@ function draftAt(namespace: SettingsNamespaceView, path: readonly string[]): Rec /** * The minimal path ops carrying `after` over `before`, both as the card sees - * them (that is, redacted). Only keys the card observed are named: a stored - * `role('secret')` field appears in neither side, so it produces no op and - * survives the write — the whole reason edits are path-addressed rather than - * a rebuilt section. + * them. Only keys the card observed are named; fields absent from both sides + * produce no op, which is why edits are path-addressed rather than a rebuilt + * section. * @param base - path of the edited subtree inside the user section. * @param before - the subtree as loaded, or undefined when it is new. * @param after - the subtree as edited. @@ -197,9 +195,8 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { /** * The write for this card, or a failure message. Every edit travels as * path ops against the STORED section: the draft comes from the redacted - * descriptor, so a wholesale replace rebuilt from it would delete the - * literal secrets the wire never returned. Ops name only the fields this - * card can see, so a stored secret is untouched by construction. + * descriptor, so a wholesale replace rebuilt from it could delete fields + * outside the card. Ops name only the fields this card can see. */ const applyOnce = async (): Promise => { const ns = namespace.ns diff --git a/packages/client/ui-models/src/client/store.ts b/packages/client/ui-models/src/client/store.ts index 938283b903..95db7e6787 100644 --- a/packages/client/ui-models/src/client/store.ts +++ b/packages/client/ui-models/src/client/store.ts @@ -31,8 +31,6 @@ export interface ProviderRow { apiKeyEnv: string | undefined /** Credential state for {@link apiKeyEnv}, once described. */ credential: CredentialView | undefined - /** Whether the redacted secret sidecar reports an effective literal `apiKey`. */ - literalApiKeyConfigured: boolean } /** Page snapshot. */ @@ -97,19 +95,6 @@ function apiKeyEnvOf(namespace: SettingsNamespaceView | undefined, path: readonl return typeof ref === 'string' && ref.length > 0 ? ref : undefined } -/** Whether one namespace's redacted sidecar reports a set literal API key. */ -function literalApiKeyConfigured( - namespace: SettingsNamespaceView | undefined, - path: readonly string[], -): boolean { - if (namespace === undefined) return false - const secretPath = [...path, 'apiKey'] - return namespace.secrets.some(secret => - secret.set - && secret.path.length === secretPath.length - && secret.path.every((key, index) => key === secretPath[index])) -} - /** The models settings page controller (one per settings surface). */ export class ModelsSettingsStore { /** The snapshot the section renders from (uSES-safe store). */ @@ -170,7 +155,6 @@ export class ModelsSettingsStore { removable, apiKeyEnv: apiKeyEnvOf(namespace, entry.settingsPath), credential: undefined, - literalApiKeyConfigured: literalApiKeyConfigured(namespace, entry.settingsPath), } }) const refs = [...new Set(rows.flatMap(row => row.apiKeyEnv === undefined ? [] : [row.apiKeyEnv]))] @@ -257,7 +241,6 @@ export function deepSeekReadiness(state: ModelsSettingsState): DeepSeekReadiness reason: 'settings-unavailable', } } - if (row.literalApiKeyConfigured) return { kind: 'configured' } if (row.apiKeyEnv === undefined) { return { kind: 'unavailable', diff --git a/packages/client/ui-models/tests/components.spec.tsx b/packages/client/ui-models/tests/components.spec.tsx index 88eab4b998..da4c8972ae 100644 --- a/packages/client/ui-models/tests/components.spec.tsx +++ b/packages/client/ui-models/tests/components.spec.tsx @@ -35,9 +35,7 @@ function capacityInputs(label: string): HTMLInputElement[] { } const PiAiConfig = Schema.object({ - token: Schema.string().role('secret'), providers: Schema.dict(Schema.object({ - apiKey: Schema.string().role('secret'), apiKeyEnv: Schema.string().role('credential-ref'), baseURL: Schema.string(), reasoning: Schema.union(['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']), @@ -46,7 +44,6 @@ const PiAiConfig = Schema.object({ }) const DeepSeekConfig = Schema.object({ - apiKey: Schema.string().role('secret'), apiKeyEnv: Schema.string().role('credential-ref'), baseURL: Schema.string().pattern(/^https:\/\//), reasoningEffort: Schema.union(['off', 'high', 'max']), @@ -99,7 +96,7 @@ function wireNamespaces(): SettingsNamespaceView[] { base: { defaultContextWindow: 1_000_000, maxTokens: 256_000, models: DEFAULT_DEEPSEEK_MODELS }, user: { baseURL: 'https://base' }, applies: 'live', - secrets: [{ path: ['apiKey'], set: false }], + secrets: [], revision: 0, }, { @@ -118,7 +115,7 @@ function wireNamespaces(): SettingsNamespaceView[] { value: { providers: { openai: { apiKeyEnv: 'OPENAI_API_KEY', baseURL: 'https://proxy', headers: { 'X-Team': 'a' } }, zombie: {} } }, user: { providers: { openai: { apiKeyEnv: 'OPENAI_API_KEY', baseURL: 'https://proxy', headers: { 'X-Team': 'a' } }, zombie: {} } }, applies: 'live', - secrets: [{ path: ['token'], set: false }, { path: ['providers', 'openai', 'apiKey'], set: false }], + secrets: [], revision: 0, }, ] @@ -262,22 +259,17 @@ describe('ModelsSection', () => { expect(screen.queryByLabelText(en.keyInput)).toBeNull() }) - it('decides setup need from the joined credential state and literal-key sidecar', () => { + it('decides setup need from the joined credential state', () => { const entry = { provider: 'p', displayName: 'p', settingsNs: 'llm-deepseek', settingsPath: [], active: true } - const row = ( - credential: ProviderRow['credential'], - literalApiKeyConfigured = false, - ): ProviderRow => ({ + const row = (credential: ProviderRow['credential']): ProviderRow => ({ entry, configured: true, removable: false, apiKeyEnv: 'X', credential, - literalApiKeyConfigured, }) expect(needsSetup(row(undefined))).toBe(true) expect(needsSetup(row({ configured: true, writable: true }))).toBe(false) - expect(needsSetup(row(undefined, true))).toBe(false) const nested = { ...row(undefined), entry: { ...entry, settingsPath: ['providers', 'x'] } } expect(needsSetup(nested)).toBe(false) }) @@ -294,9 +286,7 @@ describe('ModelsSection', () => { expect(providerTargetLabel(OPENAI_TARGET)).toBe('openai') }) - it('names only the fields the card can see, so an unseen secret survives', () => { - // `before` is the REDACTED subtree: a stored literal apiKey is in neither - // side, so no op mentions it and the seam leaves it alone. + it('names only changed fields instead of rebuilding the section', () => { expect(pathOps(['providers', 'openai'], { baseURL: 'https://old', reasoning: 'high' }, { reasoning: 'high' })) .toEqual([{ op: 'unset', path: ['providers', 'openai', 'baseURL'] }]) expect(pathOps([], { b: 1 }, { b: 2, d: 3 })) @@ -724,8 +714,7 @@ describe('ModelsSection', () => { }) it('clears an inherited override with an unset op, never a whole-section replace', async () => { - // The data-loss shape: the old path rebuilt the section from the REDACTED - // user layer and replaced it wholesale, deleting any stored literal key. + // The old path rebuilt the whole user section to clear one inherited field. const { replace, update, mutate } = await mountSection() fireEvent.click(screen.getByText(en.customized)) const url = screen.getByLabelText(en.baseUrl) @@ -798,9 +787,7 @@ describe('ModelsSection', () => { fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement) await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) }) // Only the edited field travels: apiKeyEnv and headers were already stored - // with these values, so no op restates them — and the profile's stored - // literal apiKey, absent from the redacted view the card read, is named by - // nothing at all. + // with these values, so no op restates them. expect(mutate.mock.calls[0]?.[0]).toEqual({ ns: 'llm-pi-ai', ops: [{ op: 'set', path: ['providers', 'openai', 'baseURL'], value: 'https://proxy/v2' }], @@ -1132,8 +1119,8 @@ describe('ModelsSection', () => { }) it('removes by unsetting the profile path, never by rebuilding the section', async () => { - // The section rebuild is what dropped stored literal secrets: this page - // only ever holds the redacted descriptor, so the removal names the path. + // The page only needs to name the profile path; rebuilding the section + // would widen the write for no benefit. const { face, mutate, replace, controller } = await mountSection() await removeProviderProfile( face as unknown as Parameters[0], diff --git a/packages/client/ui-models/tests/onboarding-dialog.spec.tsx b/packages/client/ui-models/tests/onboarding-dialog.spec.tsx index 07acc89fe4..772e14aaba 100644 --- a/packages/client/ui-models/tests/onboarding-dialog.spec.tsx +++ b/packages/client/ui-models/tests/onboarding-dialog.spec.tsx @@ -28,7 +28,6 @@ function harness(options: { providerActive?: boolean settingsNamespace?: boolean apiKeyEnv?: string | null - literal?: boolean configured?: () => boolean credential?: { source?: string; writable: boolean } describeFailure?: string @@ -66,7 +65,7 @@ function harness(options: { ? {} : { apiKeyEnv: options.apiKeyEnv ?? 'DEEPSEEK_API_KEY' }, applies: 'live' as const, - secrets: [{ path: ['apiKey'], set: options.literal === true }], + secrets: [], revision: 0, }], })), @@ -153,11 +152,10 @@ describe('DeepSeekOnboardingDialog', () => { } }) - it('skips an absent adapter and already-configured literal or environment credentials', async () => { + it('skips an absent adapter and an already-configured environment credential', async () => { for (const h of [ harness({ provider: false }), harness({ providerSettingsNs: '' }), - harness({ literal: true, describeFailure: 'credential seam absent' }), harness({ configured: () => true, credential: { source: 'env', writable: false } }), ]) { const view = render() diff --git a/packages/client/ui-models/tests/readiness.spec.ts b/packages/client/ui-models/tests/readiness.spec.ts index d03cd130f4..f01ab75930 100644 --- a/packages/client/ui-models/tests/readiness.spec.ts +++ b/packages/client/ui-models/tests/readiness.spec.ts @@ -19,7 +19,6 @@ function row(overrides: Partial = {}): ProviderRow { removable: false, apiKeyEnv: 'DEEPSEEK_API_KEY', credential: missingCredential, - literalApiKeyConfigured: false, ...overrides, } } @@ -64,13 +63,6 @@ describe('deepSeekReadiness', () => { }))).toEqual({ kind: 'configured' }) }) - it('accepts the redacted literal-key sidecar before judging the credential domain', () => { - expect(deepSeekReadiness(state({ - credentialError: 'credentials service absent', - rows: [row({ literalApiKeyConfigured: true, credential: undefined })], - }))).toEqual({ kind: 'configured' }) - }) - it('turns missing capabilities and inconsistent descriptors into diagnostics', () => { expect(deepSeekReadiness(state({ status: 'error', error: 'settings down' }))).toEqual({ kind: 'unavailable', diff --git a/packages/client/ui-models/tests/store.spec.ts b/packages/client/ui-models/tests/store.spec.ts index ee9aa2ddaf..5a1f340a0d 100644 --- a/packages/client/ui-models/tests/store.spec.ts +++ b/packages/client/ui-models/tests/store.spec.ts @@ -25,7 +25,7 @@ const NAMESPACES = [ value: { apiKeyEnv: 'DEEPSEEK_API_KEY', baseURL: 'https://base' }, base: { baseURL: 'https://base' }, applies: 'live' as const, - secrets: [{ path: ['apiKey'], set: false }], + secrets: [], revision: 0, }, { @@ -85,7 +85,6 @@ describe('ModelsSettingsStore', () => { removable: false, apiKeyEnv: 'DEEPSEEK_API_KEY', credential: { configured: false, writable: true }, - literalApiKeyConfigured: false, }) expect(byProvider.get('openai')).toMatchObject({ configured: true, @@ -131,30 +130,6 @@ describe('ModelsSettingsStore', () => { expect(store.store.getSnapshot().credentialError).toBe('credential transport refusal') }) - it('joins a configured literal key from the redacted secret sidecar', async () => { - const { face } = api({ - describeSettings: () => Promise.resolve(ok({ - writable: true, - hasDocument: false, - namespaces: [{ - ...NAMESPACES[0], - secrets: [ - { path: ['apiKey', 'nested'], set: true }, - { path: ['different'], set: true }, - { path: ['apiKey'], set: true }, - ], - }] as never, - })), - providers: () => Promise.resolve(ok({ providers: [DIRECTORY[0]] as never })), - }) - const store = new ModelsSettingsStore(face) - await store.load() - expect(store.store.getSnapshot().rows[0]).toMatchObject({ - literalApiKeyConfigured: true, - apiKeyEnv: 'DEEPSEEK_API_KEY', - }) - }) - it('surfaces a directory failure and keeps the last good rows', async () => { const { face } = api() const store = new ModelsSettingsStore(face) 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/credentials/credentials-local/README.i18n.yaml b/packages/credentials/credentials-local/README.i18n.yaml index 9087d22f51..07a3efd5c3 100644 --- a/packages/credentials/credentials-local/README.i18n.yaml +++ b/packages/credentials/credentials-local/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/credentials/credentials-local/README.md -README.md: 7d541d42efc942310e9a4066a9edadedb608ef0b -README.zh.md: 6a3d697551c8607bbab59a81c84eb725bf6191eb +README.md: 8e95a890a8e38172cf8984653a01c59570f0061a +README.zh.md: 04ad07ae4e703ab0416d1d8f1bb6a6ff90adf337 diff --git a/packages/credentials/credentials-local/README.md b/packages/credentials/credentials-local/README.md index 7d541d42ef..8e95a890a8 100644 --- a/packages/credentials/credentials-local/README.md +++ b/packages/credentials/credentials-local/README.md @@ -2,37 +2,56 @@ English | [中文](README.zh.md) -File-backed [credentials](../credentials/README.md) provider: two layers, one honest precedence. +File-backed [credentials](../credentials/README.md) provider: four layers, one honest precedence. | Layer | Source id | Writable | Wins | |---|---|---|---| -| Live process environment | `env` | no | always | -| `$DSH_HOME/.env` document | `file` | yes (`set`/`unset`) | otherwise | +| Inherited process environment | `env` | no | always | +| `$DSH_HOME/.credentials.yaml` document | `file` | yes (`set`/`unset`) | over both `.env` layers | +| `/.env` | `project-env` | not here | over the user `.env` | +| `$DSH_HOME/.env` | `user-env` | not here | otherwise | -The environment wins because a launch-time override (`DEEPSEEK_API_KEY=… dsh`, CI secrets, a dev shell sourcing the repo `.env`) is operator intent for this run — and because it cannot be edited from inside, it must be *visibly* read-only: `describe()` reports `source: 'env', writable: false`, and `set`/`unset` reject instead of writing a change the reader would never see. Resolution reads `process.env` live and never writes it back. +The launching environment wins because a per-run override (`DEEPSEEK_API_KEY=… dsh`, a CI secret, a container `-e`) is operator intent for this run — and because it cannot be edited from inside, it must be *visibly* read-only: `describe()` reports `source: 'env', writable: false`, and `set`/`unset` reject instead of writing a change the reader would never see. + +Everything below it loses to the managed store, so a key written by the Models page takes effect immediately even when an older key sits in a `.env`. Those two layers still resolve when nothing is stored, and `describe()` names them `project-env` or `user-env` with `writable: true` — storing a key replaces them as the effective source. + +Under the product CLI, resolution reads the launcher's frozen [environment snapshot](../../util/environment/README.md) rather than `process.env`: only the snapshot can say whether a value came from the launching shell or from a file. A composition the product CLI did not boot has the inherited environment as its only layer, which keeps embedders on the semantics they already had. ## Config | Field | Default | Meaning | |---|---|---| -| `path` | `/.env` | Credentials document location. | +| `path` | `/.credentials.yaml` | Credentials document location. | | `dshHome` | `$DSH_HOME` or `~/.dsh` | Harness home used when `path` is omitted. | | `watch` | `true` | Hot-publish external edits. | | `debounceMs` | `100` | Watcher write-settle window. | ## The document -dotenv format, parsed with `dotenv` and edited by a physical-line editor that preserves every byte it does not own: `set` rewrites the first assignment of its key in place with that line's own ending (dropping later duplicates, which dotenv's last-wins reading would otherwise let override the edit), `unset` removes only the owning line, and comments, unrelated lines, CRLF endings, and the continuation lines of another key's quoted multi-line value all survive verbatim. Every write first re-reads the document under the cross-process writer lock of [`dsh-atomic-write`](../../util/atomic-write/README.md) and publishes anything it had not observed, then commits atomically with mode `0600` under an owner-only (`0700`) directory — so a concurrent writer or an external edit inside the watcher's debounce window is folded in rather than overwritten. +A YAML mapping of credential reference to value, and nothing else: -Values are rendered in the narrowest style dotenv reads back verbatim — bare, then single-quoted (fully literal), then double-quoted (only without backslashes, which double-quote reading expands). A value no style can represent, and any entry that already spans multiple physical lines, fails loud instead of being corrupted silently. An empty stored value is absent, per the seam rule. +```yaml +DEEPSEEK_API_KEY: sk-… +OPENAI_API_KEY: sk-… +``` + +The document holds credentials only, so every deviation is a rejection rather than a skipped entry — a silently ignored key would read as "the secret I stored has no effect". A non-mapping root, a key that is not a POSIX identifier, a non-string value, an empty string, a duplicate key, and malformed YAML all fail: loud at boot, and warn-and-keep-the-last-good-snapshot on a live reload. There is no `version` field and no wrapper level; the format is the mapping. + +Writes patch the parsed document rather than rebuilding it, so comments and the formatting of every untouched entry survive. A comment directly above an entry is that entry's annotation and is removed with it. Every write first re-reads the document under the cross-process writer lock of [`dsh-atomic-write`](../../util/atomic-write/README.md) and publishes anything it had not observed, then commits atomically with mode `0600` under an owner-only (`0700`) directory — so a concurrent writer or an external edit inside the watcher's debounce window is folded in rather than overwritten. An on-disk document that no longer parses fails the write instead of overwriting content the provider could not understand. + +Any string value round-trips, multi-line values included, so no entry is unwritable for want of a quoting style. An empty stored value is absent, per the seam rule — which is why an empty string in the document is rejected outright: `unset` removes a key, it does not blank it. + +## Permissions + +The provider creates the directory `0700` and creates or atomically replaces the document `0600`. It holds what it *reads* to that same bound: on POSIX a document carrying any group or other permission bit fails before its contents are parsed — at boot and on every reload — and the error names the `chmod 600` repair. Windows has no mode to inspect, so the check is skipped there rather than faked. ## Hot reload -External edits publish `credentials/updated` per changed reference after the snapshot is replaced **wholesale** — an entry deleted on disk never lingers in memory. The provider's own writes are recognized by content and publish exactly their one commit event. An unreadable document at runtime keeps the last good snapshot and warns; an absent file is an empty store; an unreadable file at boot fails loud. Keys that are not POSIX identifiers are preserved file content the seam cannot address. +External edits publish `credentials/updated` per changed reference after the snapshot is replaced **wholesale** — an entry deleted on disk never lingers in memory. The provider's own writes are recognized by content and publish exactly their one commit event. An unreadable or invalid document at runtime keeps the last good snapshot and warns; an absent file is an empty store; an unreadable or invalid file at boot fails loud. ## Security boundary -The document is `0600` under a `0700` directory, which stops other OS users — **not** the model. Tool processes (bash, the filesystem tools) run as the same user, and the shipped `workspace-write` file policy confines mutations rather than reads, so they can read this file exactly like any other file the user owns; no sandbox mode singles it out. What the harness does hold to is narrower: it never hands the model a resolved path to the document, and never loads it into the process environment (see [app-boot's Harness-home layers](../../ui/app-boot/README.md#profiles)), so reaching the value takes a deliberate read of a path the agent was not given. +The document is `0600` under a `0700` directory, which stops other OS users — **not** the model. Tool processes (bash, the filesystem tools) run as the same user, and the shipped `workspace-write` file policy confines mutations rather than reads, so they can read this file exactly like any other file the user owns; no sandbox mode singles it out. What the harness does hold to is narrower: it never hands the model a resolved path to the document, and never loads it into the process environment — unlike `$DSH_HOME/.env`, which is the user's ordinary environment layer (see [app-boot's Harness-home layers](../../ui/app-boot/README.md#profiles)) — so reaching the value takes a deliberate read of a path the agent was not given. That is discretion, not a boundary. A deployment that must keep provider keys away from its own agent cannot get there with file permissions; an OS-keychain provider — a store the model's processes cannot read at all — is the deferred answer and belongs beside this provider as a sibling package. @@ -46,9 +65,7 @@ No direct invalidation; credentials never enter a request prefix. ## Known Limitations and Deferred Work -- **Multi-line entries refuse `set`/`unset`** — the line editor will not rewrite an entry it would corrupt; `describe` reports them `writable: false` and edits must go to the file directly. - **Same-reference concurrent writes are last-write-wins** — the writer lock and the read-modify-write keep concurrent writers from dropping each other's entries, but two writers editing one reference still resolve to the later write; there is no revision check. - **A same-UID process can read the document** — see [Security boundary](#security-boundary): the file-effect sandbox modes do not deny reads, and an OS-keychain provider is deferred. -- **Unrepresentable values fail loud** — control characters, or a mix of both quote styles with backslashes, cannot round-trip the dotenv line format. -- **Environment changes are invisible** — `process.env` is read live per resolution, but no event can announce a change there. +- **Environment changes are invisible** — the snapshot is frozen at launch, so a variable exported after startup reaches neither resolution nor `describe`; changing an environment-sourced credential takes a restart. - **Atomic, not crash-durable** — inherited from `dsh-atomic-write`; the store re-reads on boot. diff --git a/packages/credentials/credentials-local/README.zh.md b/packages/credentials/credentials-local/README.zh.md index 6a3d697551..04ad07ae4e 100644 --- a/packages/credentials/credentials-local/README.zh.md +++ b/packages/credentials/credentials-local/README.zh.md @@ -2,37 +2,56 @@ [English](README.md) | 中文 -文件型[凭据](../credentials/README.md)提供方:两层来源,一条诚实的优先级。 +文件型[凭据](../credentials/README.md)提供方:四层来源,一条诚实的优先级。 | 层 | 来源 id | 可写 | 优先 | |---|---|---|---| -| 当前进程环境 | `env` | 否 | 始终优先 | -| `$DSH_HOME/.env` 文档 | `file` | 是(`set`/`unset`) | 其余情况 | +| 继承的进程环境 | `env` | 否 | 始终优先 | +| `$DSH_HOME/.credentials.yaml` 文档 | `file` | 是(`set`/`unset`) | 高于两个 `.env` 层 | +| `/.env` | `project-env` | 不在此处 | 高于用户 `.env` | +| `$DSH_HOME/.env` | `user-env` | 不在此处 | 其余情况 | -环境优先,因为启动时覆盖(`DEEPSEEK_API_KEY=… dsh`、CI 机密、加载了仓库 `.env` 的开发 shell)代表本次运行的操作者意图——而它无法从进程内部修改,就必须*可见地*只读:`describe()` 报告 `source: 'env', writable: false`,`set`/`unset` 直接拒绝,而不是写下一个读取方永远看不到的变更。解析实时读取 `process.env`,绝不写回。 +启动环境优先,因为按次覆盖(`DEEPSEEK_API_KEY=… dsh`、CI 机密、容器 `-e`)代表本次运行的操作者意图——而它无法从进程内部修改,就必须*可见地*只读:`describe()` 报告 `source: 'env', writable: false`,`set`/`unset` 直接拒绝,而不是写下一个读取方永远看不到的变更。 + +它之下的一切都输给受管存储,因此 Models 页写入的密钥会立即生效,即使某个 `.env` 里还留着更旧的密钥。没有存储任何东西时这两层仍会解析,`describe()` 会把来源报告为 `project-env` 或 `user-env` 且 `writable: true`——存入一个密钥就会取代它们成为生效来源。 + +在产品 CLI(命令行界面)下,解析读取的是启动器冻结的[环境快照](../../util/environment/README.md)而不是 `process.env`:只有快照才说得清某个值来自启动 shell 还是来自某个文件。并非由产品 CLI 启动的组合只有继承环境这一层,这让嵌入方保持它们原有的语义。 ## 配置 | 字段 | 默认值 | 含义 | |---|---|---| -| `path` | `/.env` | 凭据文档位置。 | +| `path` | `/.credentials.yaml` | 凭据文档位置。 | | `dshHome` | `$DSH_HOME` 或 `~/.dsh` | `path` 缺省时使用的 harness home。 | | `watch` | `true` | 热发布外部编辑。 | | `debounceMs` | `100` | watcher 写入稳定窗口。 | ## 文档本身 -dotenv 格式,用 `dotenv` 解析;写回用物理行级编辑器,保留一切不属于本次编辑的字节:`set` 原位改写该键的第一条赋值行、沿用该行自身的行尾(丢弃后续重复行——dotenv 按最后一条生效,重复行会反过来覆盖这次编辑),`unset` 只删除所属行,注释、无关行、CRLF 行尾,以及另一个键的带引号多行值的续行,都逐字保留。每次写入都先在 [`dsh-atomic-write`](../../util/atomic-write/README.md) 的跨进程写锁下重读文档、把此前未观察到的一切发布出去,再在仅属主可访问(`0700`)的目录下以 `0600` 权限原子提交——因此并发写入者、或落在 watcher 防抖窗口内的外部编辑会被并入,而不是被覆盖。 +一个从凭据引用到值的 YAML mapping,除此之外别无他物: -值按 dotenv 能逐字读回的最窄样式渲染——裸值,其次单引号(完全字面),再次双引号(仅限无反斜杠,双引号读取会展开转义)。任何样式都无法表示的值,以及已经跨越多个物理行的条目,都会明确报错而不是被静默破坏。空的存储值等于不存在(seam 规则)。 +```yaml +DEEPSEEK_API_KEY: sk-… +OPENAI_API_KEY: sk-… +``` + +该文档只存放凭据,因此任何偏离都是拒绝,而不是跳过某个条目——被静默忽略的键读起来就是「我存进去的密钥没有生效」。非 mapping 的根、非 POSIX 标识符的键、非字符串值、空字符串、重复键以及格式错误的 YAML 全部失败:启动时明确报错,运行期热重载则告警并保留最后可用快照。没有 `version` 字段,也没有包装层;格式就是这个 mapping。 + +写入是对已解析文档打补丁而不是重建,因此注释与所有未触及条目的排版都会保留。直接位于某条目上方的注释属于该条目的注解,会随它一起删除。每次写入都先在 [`dsh-atomic-write`](../../util/atomic-write/README.md) 的跨进程写锁下重读文档、把此前未观察到的一切发布出去,再在仅属主可访问(`0700`)的目录下以 `0600` 权限原子提交——因此并发写入者、或落在 watcher 防抖窗口内的外部编辑会被并入,而不是被覆盖。磁盘上已经无法解析的文档会让写入失败,而不是覆盖提供方读不懂的内容。 + +任何字符串值都能往返,包括多行值,因此不会再有条目因为缺少可用引号样式而不可写。空的存储值等于不存在(seam 规则)——这也正是文档中的空字符串被直接拒绝的原因:`unset` 删除键,而不是把它置空。 + +## 权限 + +提供方以 `0700` 创建目录,以 `0600` 创建或原子替换文档。它对*读取*同样守住这条界线:在 POSIX 上,只要文档带有任何 group 或 other 权限位,就会在解析其内容之前失败——启动时与每次 reload 都检查——并在错误里给出 `chmod 600` 的修复命令。Windows 没有可检查的 mode,因此在那里跳过该检查而不是伪造它。 ## 热重载 -外部编辑在快照**整体替换**后按变更引用逐个发布 `credentials/updated`——磁盘上删掉的条目绝不在内存滞留。提供方自己的写入按内容识别,只发布属于该次提交的一个事件。运行期文档不可读时保留最后可用快照并告警;文件不存在即空存储;启动时不可读则明确报错。非 POSIX 标识符的键属于被保留的文件内容,seam 无法寻址。 +外部编辑在快照**整体替换**后按变更引用逐个发布 `credentials/updated`——磁盘上删掉的条目绝不在内存滞留。提供方自己的写入按内容识别,只发布属于该次提交的一个事件。运行期文档不可读或无效时保留最后可用快照并告警;文件不存在即空存储;启动时不可读或无效则明确报错。 ## 安全边界 -文档在 `0700` 目录下以 `0600` 权限存放,这挡得住其他 OS 用户,**挡不住**模型。工具进程(bash、文件系统工具)以同一用户身份运行,而已交付的 `workspace-write` 文件策略限制的是修改而非读取,因此它们读这个文件与读该用户拥有的任何其他文件毫无二致;也没有任何沙箱模式会把它单独挑出来。harness 真正守住的更窄:它绝不把该文档的解析后路径交给模型,也绝不把它载入进程环境(见 [app-boot 的 Harness home 各层](../../ui/app-boot/README.md#profiles)),因此要拿到这个值,需要刻意去读一条并未交给 agent(智能体)的路径。 +文档在 `0700` 目录下以 `0600` 权限存放,这挡得住其他 OS 用户,**挡不住**模型。工具进程(bash、文件系统工具)以同一用户身份运行,而已交付的 `workspace-write` 文件策略限制的是修改而非读取,因此它们读这个文件与读该用户拥有的任何其他文件毫无二致;也没有任何沙箱模式会把它单独挑出来。harness 真正守住的更窄:它绝不把该文档的解析后路径交给模型,也绝不把它载入进程环境——这与用户的普通环境层 `$DSH_HOME/.env` 不同(见 [app-boot 的 Harness home 各层](../../ui/app-boot/README.md#profiles))——因此要拿到这个值,需要刻意去读一条并未交给 agent(智能体)的路径。 这是审慎,不是边界。必须让提供方密钥远离自身 agent 的部署无法靠文件权限做到;OS 钥匙串提供方——一个模型的进程根本读不到的存储——才是延后的答案,它应当作为平级包与本提供方并列。 @@ -46,9 +65,7 @@ dotenv 格式,用 `dotenv` 解析;写回用物理行级编辑器,保留一 ## 已知限制与暂缓事项 -- **多行条目拒绝 `set`/`unset`**——行编辑器不改写会被它破坏的条目;`describe` 把它们报为 `writable: false`,编辑必须直接落到文件上。 - **同一引用的并发写入是后写胜出**——写锁加读-改-写让并发写入者不会丢掉彼此的条目,但两个写入者编辑同一个引用时仍以较后的写入为准;没有修订检查。 - **同 UID 进程可以读取该文档**——见[安全边界](#security-boundary):文件效果沙箱模式不会拒绝读取,OS 钥匙串提供方仍是延后项。 -- **无法表示的值明确报错**——控制字符,或同时混用两种引号又含反斜杠的值,无法在 dotenv 行格式中往返。 -- **环境变化不可见**——每次解析实时读取 `process.env`,但那里的变化不可能发出事件。 +- **环境变化不可见**:快照在启动时冻结,因此启动之后 export 的变量既不会进入解析,也不会进入 `describe`;要更换来自环境的凭据需要重启。 - **原子但不具备崩溃持久性**——继承自 `dsh-atomic-write`;存储在启动时重新读取。 diff --git a/packages/credentials/credentials-local/package.json b/packages/credentials/credentials-local/package.json index 6cfa6fad5f..f4fbb3a9fe 100644 --- a/packages/credentials/credentials-local/package.json +++ b/packages/credentials/credentials-local/package.json @@ -27,18 +27,20 @@ "peerDependencies": { "@deepseek-ai/dsh-atomic-write": "^0.0.1", "@deepseek-ai/dsh-credentials": "^0.0.1", + "@deepseek-ai/dsh-environment": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-paths": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "dependencies": { "chokidar": "^4.0.3", - "dotenv": "^17.2.0", - "schemastery": "^3.18.0" + "schemastery": "^3.18.0", + "yaml": "^2.9.0" }, "devDependencies": { "@deepseek-ai/dsh-atomic-write": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^", + "@deepseek-ai/dsh-environment": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-paths": "workspace:^", "cordis": "^4.0.0-rc.7" diff --git a/packages/credentials/credentials-local/src/index.ts b/packages/credentials/credentials-local/src/index.ts index c11c2db20c..e781c10f3e 100644 --- a/packages/credentials/credentials-local/src/index.ts +++ b/packages/credentials/credentials-local/src/index.ts @@ -1,30 +1,58 @@ /** - * File-backed credentials provider layering the live process environment over - * a `$DSH_HOME/.env` document. The environment is authoritative and read-only - * (a launch-time override must win, and must be visibly read-only rather than - * silently shadow writes); the file is the provider-managed writable source: - * every write re-reads the document under a cross-process writer lock before - * rewriting only its own line — preserving every other byte, physical line - * endings and quoted multi-line values included — external edits hot-publish - * through the seam, and each reload replaces the snapshot wholesale so a - * deleted entry never lingers in memory. + * File-backed credentials provider over `$DSH_HOME/.credentials.yaml`, layered + * against the environment by how much each layer is trusted: + * + * ```text + * inherited process environment (read-only, wins) + * > $DSH_HOME/.credentials.yaml (provider-managed, writable) + * > /.env (read-only fallback) + * > $DSH_HOME/.env (read-only fallback) + * ``` + * + * The inherited environment wins because `DEEPSEEK_API_KEY=… dsh`, a CI + * secret, or a container `-e` is this run's explicit intent; it cannot be + * edited from inside, so it must be *visibly* read-only rather than silently + * shadow writes. Everything below it loses to the managed store, so a key the + * Models page writes takes effect immediately even when an older key sits in + * the user's `.env`. + * + * The invoking project may supply a key, because the product trusts the + * project it is launched in. It ranks below the managed store, so a key stored + * through the Models page is never displaced by one a checkout happens to carry. + * + * The file is the provider-managed writable source: every write re-reads the + * document under a cross-process writer lock before patching only its own key + * — comments and the formatting of every untouched entry survive — external + * edits hot-publish through the seam, and each reload replaces the snapshot + * wholesale so a deleted entry never lingers in memory. + * + * The document holds nothing but credentials, which is why it is a strict + * `CredentialRef`-to-string mapping rather than a dotenv file: a store the + * Harness owns and never materializes into the environment cannot also serve + * as the user's environment layer, and conflating the two is what made a + * non-secret in the old `$DSH_HOME/.env` silently unreachable. * @module @deepseek-ai/dsh-credentials-local */ import { Context, Service } from 'cordis' import z from 'schemastery' import { watch as chokidarWatch } from 'chokidar' -import { mkdir, readFile } from 'node:fs/promises' +import { mkdir, readFile, stat } from 'node:fs/promises' import { dirname, join, resolve } from 'node:path' -import { parse } from 'dotenv' +import { Document, parseDocument, type YAMLError } from 'yaml' import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write' import { resolveDshHome } from '@deepseek-ai/dsh-paths' +import { environmentOf } from '@deepseek-ai/dsh-environment' import { Credentials, credentialRef } from '@deepseek-ai/dsh-credentials' import type { CredentialInfo, CredentialRef, ResolvedCredential } from '@deepseek-ai/dsh-credentials' +import type { EnvironmentEntry } from '@deepseek-ai/dsh-environment' + +/** Basename of the credentials document inside the harness home. */ +export const CREDENTIALS_FILENAME = '.credentials.yaml' /** Plugin config: file location and hot-reload behavior. */ export interface Config { - /** Credentials document path; defaults to `.env` under the harness home. */ + /** Credentials document path; defaults to `.credentials.yaml` under the harness home. */ path?: string /** Harness home used when `path` is omitted; defaults to `$DSH_HOME` or `~/.dsh`. */ dshHome?: string @@ -43,146 +71,135 @@ interface ResolvedSpec { /** * Resolve the runtime spec from plugin config: an explicit `path` wins, - * otherwise the document lives at `/.env`. + * otherwise the document lives at `/.credentials.yaml`. * @param config - raw plugin config. * @returns the resolved file location and watch behavior. */ export function resolveSpec(config: Config): ResolvedSpec { return { - filename: resolve(config.path ?? join(resolveDshHome(config.dshHome), '.env')), + filename: resolve(config.path ?? join(resolveDshHome(config.dshHome), CREDENTIALS_FILENAME)), watch: config.watch ?? true, debounceMs: config.debounceMs ?? 100, } } +/** Permission bits outside the owner; a credentials document must have none of them. */ +const GROUP_OTHER_BITS = 0o077 + +/** + * Reject a credentials document other OS users can read, before its contents + * are read at all. The provider creates and replaces the file at `0600`, but a + * hand-written or externally generated one carries whatever umask produced it, + * and silently serving secrets out of a world-readable file would make the + * mode the provider promises meaningless. + * + * POSIX only: Windows has no mode to inspect — its ACLs are not expressible + * here — so the check is skipped rather than faked, and the file's protection + * there is whatever the create and replace APIs express. + * @param filename - absolute path of the document. + * @throws when the file exists with group or other permission bits set. + */ +async function assertOwnerOnly(filename: string): Promise { + /* v8 ignore next -- native Windows coverage exercises the skip; POSIX covers the check */ + if (process.platform === 'win32') return + let mode: number + try { + mode = (await stat(filename)).mode + } catch (error) { + if (!isENOENT(error)) throw error + return + } + const offending = mode & GROUP_OTHER_BITS + if (offending === 0) return + throw new Error( + `credentials-local: ${filename} is readable beyond its owner (mode ${(mode & 0o777).toString(8)});` + + ` run "chmod 600 ${filename}" before starting again`, + ) +} + /** Whether a filesystem error means absence; every non-ENOENT failure must surface. */ function isENOENT(error: unknown): boolean { return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT' } -/** Values that survive a dotenv round-trip without quoting. */ -const BARE_VALUE = /^[A-Za-z0-9_@%+:,./-]+$/ - -/** Whether a value contains C0 control characters (newlines included) no dotenv style reads back. */ -function hasControlCharacters(value: string): boolean { - for (const char of value) { - if (char.charCodeAt(0) < 0x20) return true - } - return false +/** + * Describe one YAML parse failure without quoting the source. The parser's own + * message embeds the offending line, which here holds a secret. + * @param error - the parser's error. + * @returns the error code with its line and column. + */ +function describeYamlError(error: YAMLError): string { + const at = error.linePos?.[0] + /* v8 ignore next -- `prettyErrors` populates linePos on every error; the guard answers its optional type */ + const where = at === undefined ? '' : ` at line ${String(at.line)}, column ${String(at.col)}` + return `${error.code}${where}` } /** - * Render one `KEY=value` line in the narrowest style dotenv reads back - * verbatim: bare, then single quotes (fully literal), then double quotes - * (safe only without backslashes, which double-quote reading expands). - * A value no style can represent fails loud instead of corrupting silently. + * Parse one credentials document into its entries. The document is a strict + * mapping of {@link CredentialRef} to non-empty string: a non-mapping root, a + * key that is not a POSIX identifier, a non-string value, and an empty string + * are all rejected rather than skipped, because this file holds nothing but + * credentials and a silently ignored entry reads as "the key I stored has no + * effect". Duplicate keys surface as parser errors. An empty document is an + * empty store. + * @param text - the document's text. + * @param filename - absolute path, quoted in errors. + * @returns the parsed entries, keyed by reference. */ -function renderLine(ref: CredentialRef, value: string): string { - if (BARE_VALUE.test(value)) return `${ref}=${value}` - if (hasControlCharacters(value)) { - throw new Error(`credentials-local: the value for "${ref}" contains control characters the .env line format cannot represent`) +export function parseCredentialsDocument(text: string, filename: string): Map { + // `prettyErrors` is on only for `linePos`; `error.message` is never used, + // because the parser quotes the offending source line and in this document + // that line is a secret. Only the code and position leave this function, and + // the same rule governs every other diagnostic here — a key name is safe to + // print, a value is not. + const document = parseDocument(text, { prettyErrors: true, uniqueKeys: true }) + if (document.errors.length > 0) { + throw new Error(`credentials-local: invalid document at ${filename}: ${ + document.errors.map(describeYamlError).join('; ')}`) } - if (!value.includes('\'')) return `${ref}='${value}'` - if (!value.includes('"') && !value.includes('\\')) return `${ref}="${value}"` - throw new Error(`credentials-local: the value for "${ref}" mixes quoting no .env style can represent; edit the file directly`) -} - -/** Split text into physical lines with their terminators attached. */ -function physicalLines(text: string): string[] { - return text.length === 0 ? [] : text.split(/(?<=\n)/) -} - -/** One physical line's content without its terminator. */ -function lineContent(line: string): string { - if (line.endsWith('\r\n')) return line.slice(0, -2) - if (line.endsWith('\n')) return line.slice(0, -1) - return line -} - -/** One physical line's terminator (empty on a final unterminated line). */ -function lineTerminator(line: string): string { - return line.slice(lineContent(line).length) -} - -/** An assignment line: optional export, a POSIX identifier, `=`, the value part. */ -const ASSIGNMENT = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$/ - -/** Quote characters dotenv reads across physical lines. */ -const MULTILINE_QUOTES = ['\'', '"', '`'] - -/** - * The quote character an assignment's value part opens without closing on its - * own line — the following physical lines are that value's continuation, not - * assignments — or `undefined` for a single-line value. - */ -function opensMultiline(valuePart: string): string | undefined { - const trimmed = valuePart.trimStart() - const quote = trimmed[0] - if (quote === undefined || !MULTILINE_QUOTES.includes(quote)) return undefined - const rest = trimmed.slice(1) - const body = quote === '"' ? rest.replaceAll('\\"', '') : rest - return body.includes(quote) ? undefined : quote -} - -/** Whether a continuation line closes the given quote. */ -function closesQuote(content: string, quote: string): boolean { - const body = quote === '"' ? content.replaceAll('\\"', '') : content - return body.includes(quote) + const root: unknown = document.toJS() ?? {} + if (typeof root !== 'object' || root === null || Array.isArray(root)) { + throw new TypeError(`credentials-local: ${filename} must be a mapping of credential reference to value`) + } + const entries = new Map() + for (const [key, value] of Object.entries(root as Record)) { + // credentialRef throws on anything that is not a POSIX identifier, which + // is exactly the constraint a stored reference must satisfy to be + // addressable through the seam. + credentialRef(key) + // The key name is quoted, never the value: a wrong-typed entry is still a + // secret the user meant to store. + if (typeof value !== 'string') { + throw new TypeError(`credentials-local: the value for "${key}" in ${filename} must be a string`) + } + if (value.length === 0) { + throw new Error(`credentials-local: the value for "${key}" in ${filename} is empty; remove the key instead`) + } + entries.set(key, value) + } + return entries } /** - * Replace, insert, or delete one reference's assignment while preserving - * every other byte: untouched lines keep their exact content and terminators - * (CRLF included), and the physical lines inside another key's quoted - * multi-line value are never mistaken for assignments. The first matching - * assignment is rewritten in place with its own line ending; later duplicates - * drop (dotenv reads the last one, so a surviving duplicate would override - * the edit); an insert appends in the document's dominant ending style. + * Render the next document text with one reference set or deleted. Editing + * the parsed document rather than rebuilding it keeps comments and the + * formatting of every untouched entry; an absent document starts a fresh one. + * @param text - the current document text, `undefined` while the file is absent. + * @param ref - the reference to write. + * @param value - the new value, or `undefined` to delete the key. + * @returns the text to persist. */ -function upsertLine(text: string | undefined, ref: CredentialRef, rendered: string | undefined): string { - const lines = physicalLines(text ?? '') - const dominant = lines.some(line => line.endsWith('\r\n')) ? '\r\n' : '\n' - const out: string[] = [] - let placed = false - let pendingQuote: string | undefined - for (const line of lines) { - const content = lineContent(line) - if (pendingQuote !== undefined) { - // Inside a quoted multi-line value: never an assignment, always kept. - if (closesQuote(content, pendingQuote)) pendingQuote = undefined - out.push(line) - continue - } - const match = ASSIGNMENT.exec(content) - if (match === null) { - out.push(line) - continue - } - const [, key, valuePart] = match - if (key !== ref) { - /* v8 ignore next -- the value group is `(.*)`, which always participates; the fallback only satisfies noUncheckedIndexedAccess */ - pendingQuote = opensMultiline(valuePart ?? '') - out.push(line) - continue - } - // The write path refuses multi-line targets before rendering, so the - // matched assignment is single-line and drops or rewrites wholesale. - if (rendered !== undefined && !placed) { - out.push(`${rendered}${lineTerminator(line) === '' ? dominant : lineTerminator(line)}`) - placed = true - } - } - if (rendered !== undefined && !placed) { - const last = out[out.length - 1] - if (last !== undefined && lineTerminator(last) === '') { - out[out.length - 1] = `${last}${dominant}` - } - out.push(`${rendered}${dominant}`) - } - return out.join('') +function renderDocument(text: string | undefined, ref: CredentialRef, value: string | undefined): string { + // `text` only ever caches content that parsed successfully, so this re-parse + // for the mutable comment-preserving tree cannot fail. + const document = text === undefined ? new Document({}) : parseDocument(text) + if (value === undefined) document.deleteIn([ref]) + else document.setIn([ref], value) + return document.toString() } -/** File-backed credentials provider (`$DSH_HOME/.env`). */ +/** File-backed credentials provider (`$DSH_HOME/.credentials.yaml`). */ export class CredentialsLocal extends Credentials { /* jscpd:ignore-start -- deliberate config-surface and lifecycle symmetry with settings-local (prefer symmetry for parallel values); extracting the shared @@ -225,6 +242,22 @@ export class CredentialsLocal extends Credentials { this.spec = resolveSpec(config) } + /** The inherited-environment value for a reference, or `undefined` when empty or unset. */ + private inherited(ref: CredentialRef): string | undefined { + const entry = environmentOf(this.ctx).getFrom(ref, ['process']) + return entry !== undefined && entry.value.length > 0 ? entry.value : undefined + } + + /** + * The `.env` fallback for a reference — below the managed store, never above + * it. The invoking project ranks over the user's home file, matching the + * environment layering: the more specific location wins. + */ + private dotenvFallback(ref: CredentialRef): EnvironmentEntry | undefined { + const entry = environmentOf(this.ctx).getFrom(ref, ['project-env', 'user-env']) + return entry !== undefined && entry.value.length > 0 ? entry : undefined + } + async* [Service.init](): AsyncGenerator<() => Promise | void, void, void> { yield async () => { // Drain: refuse new operations, then settle the queued ones so disposal @@ -270,24 +303,26 @@ export class CredentialsLocal extends Credentials { } override resolve(ref: CredentialRef): Promise { - const env = process.env[ref] - if (env !== undefined && env.length > 0) return Promise.resolve({ value: env, source: 'env' }) + const inherited = this.inherited(ref) + if (inherited !== undefined) return Promise.resolve({ value: inherited, source: 'env' }) const stored = this.values.get(ref) - if (stored !== undefined && stored.length > 0) return Promise.resolve({ value: stored, source: 'file' }) + if (stored !== undefined) return Promise.resolve({ value: stored, source: 'file' }) + const fallback = this.dotenvFallback(ref) + if (fallback !== undefined) return Promise.resolve({ value: fallback.value, source: fallback.source }) return Promise.resolve(undefined) } override describe(ref: CredentialRef): Promise { - const env = process.env[ref] - if (env !== undefined && env.length > 0) { + // Only the inherited environment is unwritable: it is the one layer this + // process cannot edit. A user `.env` value is writable in the sense that + // matters — storing a key replaces it as the effective one. + if (this.inherited(ref) !== undefined) { return Promise.resolve({ configured: true, source: 'env', writable: false }) } const stored = this.values.get(ref) - if (stored !== undefined && stored.length > 0) { - // A quoted multi-line value resolves fine but the line editor refuses to - // rewrite it, so writability must say what set() would actually do. - return Promise.resolve({ configured: true, source: 'file', writable: !stored.includes('\n') }) - } + if (stored !== undefined) return Promise.resolve({ configured: true, source: 'file', writable: true }) + const fallback = this.dotenvFallback(ref) + if (fallback !== undefined) return Promise.resolve({ configured: true, source: fallback.source, writable: true }) return Promise.resolve({ configured: false, writable: true }) } @@ -350,12 +385,7 @@ export class CredentialsLocal extends Credentials { await this.reconcileFromDisk() const existing = this.values.get(ref) if (value === undefined && existing === undefined) return - if (existing !== undefined && existing.includes('\n')) { - throw new Error( - `credentials-local: "${ref}" is a multi-line entry this line editor would corrupt; edit ${this.spec.filename} directly`, - ) - } - const nextText = upsertLine(this.text, ref, value === undefined ? undefined : renderLine(ref, value)) + const nextText = renderDocument(this.text, ref, value) // 0600: a document holding secrets is never world-readable. await writeFileAtomic(this.spec.filename, nextText, { mode: 0o600, dirMode: 0o700 }) this.text = nextText @@ -368,19 +398,27 @@ export class CredentialsLocal extends Credentials { }) } - /** Reject a write the live environment would shadow into apparent no-effect. */ + /** + * Reject a write the inherited environment would shadow into apparent + * no-effect. Only that layer can shadow a write: everything else this + * provider resolves ranks below the document being written. + */ private assertUnshadowed(ref: CredentialRef, verb: 'set' | 'unset'): void { - const env = process.env[ref] - if (env !== undefined && env.length > 0) { + if (this.inherited(ref) !== undefined) { throw new Error( - `credentials-local: "${ref}" is supplied read-only by the process environment, so ${verb} would be` - + ' shadowed; change the launching environment instead', + `credentials-local: "${ref}" is supplied read-only by the launching environment, so ${verb} would be` + + ' shadowed; unset it in the shell you start dsh from instead', ) } } - /** Boot read: an absent file is an empty store; any other failure is loud. */ + /** + * Boot read: an absent file is an empty store; an invalid one fails the + * plugin's activation, because a credentials document that exists but + * cannot be trusted must never be treated as "no credentials stored". + */ private async loadInitial(): Promise { + await assertOwnerOnly(this.spec.filename) let text: string try { text = await readFile(this.spec.filename, 'utf8') @@ -388,8 +426,8 @@ export class CredentialsLocal extends Credentials { if (!isENOENT(error)) throw error return } + this.values = parseCredentialsDocument(text, this.spec.filename) this.text = text - this.values = new Map(Object.entries(parse(text))) } /* jscpd:ignore-start -- same deliberate mirror of settings-local's reload and @@ -415,12 +453,15 @@ export class CredentialsLocal extends Credentials { /** * Compare the on-disk text against the cache and publish any difference - * into the seam. Absence publishes the empty store; an unreadable file - * throws, so each caller picks its policy — a reload warns and keeps the - * last good snapshot, a write fails loud. dotenv parsing is lenient by - * design and cannot fail. + * into the seam. Absence publishes the empty store; an unreadable or + * invalid document throws, so each caller picks its policy — a reload warns + * and keeps the last good snapshot, a write fails loud rather than + * overwriting a document it could not understand. */ private async reconcileFromDisk(): Promise { + // Re-checked on every reload and before every write: an external editor or + // a restored backup can loosen the mode after boot. + await assertOwnerOnly(this.spec.filename) let text: string | undefined try { text = await readFile(this.spec.filename, 'utf8') @@ -429,7 +470,7 @@ export class CredentialsLocal extends Credentials { text = undefined } if (text === this.text || this.isClosed()) return - const next = text === undefined ? new Map() : new Map(Object.entries(parse(text))) + const next = text === undefined ? new Map() : parseCredentialsDocument(text, this.spec.filename) const changed = this.changedRefs(this.values, next) this.text = text this.values = next @@ -437,21 +478,12 @@ export class CredentialsLocal extends Credentials { } /* jscpd:ignore-end */ - /** Seam-addressable entries whose effective (non-empty) value changed. */ + /** Entries whose stored value changed; the parser has already proven every key addressable. */ private changedRefs(prev: Map, next: Map): CredentialRef[] { const changed: CredentialRef[] = [] for (const key of new Set([...prev.keys(), ...next.keys()])) { - const before = prev.get(key) - const after = next.get(key) - const effectiveBefore = before !== undefined && before.length > 0 ? before : undefined - const effectiveAfter = after !== undefined && after.length > 0 ? after : undefined - if (effectiveBefore === effectiveAfter) continue - try { - changed.push(credentialRef(key)) - } catch (_unaddressableKey) { - // A key that is not a POSIX identifier is preserved file content the - // seam cannot address, so no observer could ever see it change. - } + if (prev.get(key) === next.get(key)) continue + changed.push(credentialRef(key)) } return changed } diff --git a/packages/credentials/credentials-local/tests/drain.spec.ts b/packages/credentials/credentials-local/tests/drain.spec.ts index baefbd52c5..9cf4e600fb 100644 --- a/packages/credentials/credentials-local/tests/drain.spec.ts +++ b/packages/credentials/credentials-local/tests/drain.spec.ts @@ -42,7 +42,7 @@ describe('write-drain teardown', () => { const dir = await mkdtemp(join(tmpdir(), 'dsh-credentials-drain-')) cleanups.push(() => rm(dir, { recursive: true, force: true })) const ctx = new Context() - const fiber = ctx.plugin(CredentialsLocal, { path: join(dir, '.env'), watch: false }) + const fiber = ctx.plugin(CredentialsLocal, { path: join(dir, '.credentials.yaml'), watch: false }) await fiber const service = ctx.credentials diff --git a/packages/credentials/credentials-local/tests/local.spec.ts b/packages/credentials/credentials-local/tests/local.spec.ts index 4ebaed1a0c..43e42cd53f 100644 --- a/packages/credentials/credentials-local/tests/local.spec.ts +++ b/packages/credentials/credentials-local/tests/local.spec.ts @@ -4,9 +4,15 @@ import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { credentialRef } from '@deepseek-ai/dsh-credentials' +import { createEnvironmentSnapshot, DSH_ENVIRONMENT_KEY } from '@deepseek-ai/dsh-environment' import type { CredentialRef } from '@deepseek-ai/dsh-credentials' import { CredentialsLocal, resolveSpec } from '../src/index.ts' +/** Credential documents are seeded owner-only, exactly as the provider creates them. */ +function writeCredentials(file: string, text: string): Promise { + return writeFile(file, text, { mode: 0o600 }) +} + const KEY = credentialRef('DSH_CRED_TEST') const OTHER = credentialRef('DSH_CRED_OTHER') @@ -42,29 +48,29 @@ function updates(ctx: Context): CredentialRef[] { } describe('resolveSpec', () => { - it('defaults to .env under the harness home with watching on', () => { + it('defaults to .credentials.yaml under the harness home with watching on', () => { const spec = resolveSpec({ dshHome: '/custom/home' }) - expect(spec).toEqual({ filename: resolve('/custom/home/.env'), watch: true, debounceMs: 100 }) + expect(spec).toEqual({ filename: resolve('/custom/home/.credentials.yaml'), watch: true, debounceMs: 100 }) }) it('lets an explicit path win over the home', () => { - const spec = resolveSpec({ path: '/etc/dsh/creds.env', dshHome: '/ignored', watch: false, debounceMs: 5 }) - expect(spec).toEqual({ filename: resolve('/etc/dsh/creds.env'), watch: false, debounceMs: 5 }) + const spec = resolveSpec({ path: '/etc/dsh/creds.yaml', dshHome: '/ignored', watch: false, debounceMs: 5 }) + expect(spec).toEqual({ filename: resolve('/etc/dsh/creds.yaml'), watch: false, debounceMs: 5 }) }) }) describe('layering and reads', () => { it('treats an absent file as an empty writable store', async () => { const dir = await tempDir() - const ctx = await boot({ path: join(dir, '.env'), watch: false }) + const ctx = await boot({ path: join(dir, '.credentials.yaml'), watch: false }) expect(await ctx.credentials.resolve(KEY)).toBeUndefined() expect(await ctx.credentials.describe(KEY)).toEqual({ configured: false, writable: true }) }) - it('serves file entries, including export-prefixed and quoted values', async () => { + it('serves file entries alongside comments and quoted values', async () => { const dir = await tempDir() - const path = join(dir, '.env') - await writeFile(path, '# notes\nexport DSH_CRED_TEST=plain\nDSH_CRED_OTHER="with space"\n') + const path = join(dir, '.credentials.yaml') + await writeCredentials(path, '# notes\nDSH_CRED_TEST: plain\nDSH_CRED_OTHER: "with space"\n') const ctx = await boot({ path, watch: false }) expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'plain', source: 'file' }) expect(await ctx.credentials.resolve(OTHER)).toEqual({ value: 'with space', source: 'file' }) @@ -73,22 +79,22 @@ describe('layering and reads', () => { it('lets a non-empty process environment win read-only over the file', async () => { const dir = await tempDir() - const path = join(dir, '.env') - await writeFile(path, 'DSH_CRED_TEST=from-file\n') + const path = join(dir, '.credentials.yaml') + await writeCredentials(path, 'DSH_CRED_TEST: from-file\n') const ctx = await boot({ path, watch: false }) vi.stubEnv('DSH_CRED_TEST', 'from-env') expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'from-env', source: 'env' }) expect(await ctx.credentials.describe(KEY)).toEqual({ configured: true, source: 'env', writable: false }) }) - it('treats empty values as absent in both layers', async () => { + it('treats an empty environment value as absent, falling through to the file', async () => { const dir = await tempDir() - const path = join(dir, '.env') - await writeFile(path, 'DSH_CRED_TEST=\n') + const path = join(dir, '.credentials.yaml') + await writeCredentials(path, 'DSH_CRED_TEST: stored\n') const ctx = await boot({ path, watch: false }) vi.stubEnv('DSH_CRED_TEST', '') - expect(await ctx.credentials.resolve(KEY)).toBeUndefined() - expect(await ctx.credentials.describe(KEY)).toEqual({ configured: false, writable: true }) + expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'stored', source: 'file' }) + expect(await ctx.credentials.describe(KEY)).toEqual({ configured: true, source: 'file', writable: true }) }) it('fails boot loud when the document exists but cannot be read', async () => { @@ -100,110 +106,280 @@ describe('layering and reads', () => { }) }) -describe('line-editing writes', () => { - it('appends a missing key to a fresh 0600 document and emits the commit', async () => { +describe('layer ladder', () => { + // inherited process env > .credentials.yaml > $DSH_HOME/.env, and the + // invoking directory's .env supplies no credential at all. + async function bootLayered( + path: string, + layers: Parameters[0], + ): Promise { + const ctx = new Context() + ctx.provide(DSH_ENVIRONMENT_KEY, createEnvironmentSnapshot(layers)) + const fiber = ctx.plugin(CredentialsLocal, { path, watch: false }) + cleanups.push(async () => { await fiber.dispose() }) + await fiber + return ctx + } + + it('lets the stored value beat the user .env, so a UI write takes effect immediately', async () => { const dir = await tempDir() - const path = join(dir, '.env') + const path = join(dir, '.credentials.yaml') + await writeCredentials(path, 'DSH_CRED_TEST: stored\n') + const ctx = await bootLayered(path, [ + { source: 'process', values: {} }, + { source: 'user-env', path: '/home/.dsh/.env', values: { DSH_CRED_TEST: 'older-user-env' } }, + ]) + expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'stored', source: 'file' }) + // The old dead end is gone: a key sitting in the user's .env no longer + // makes the stored one unwritable. + expect(await ctx.credentials.describe(KEY)).toEqual({ configured: true, source: 'file', writable: true }) + await expect(ctx.credentials.set(KEY, 'rotated')).resolves.toBeUndefined() + expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'rotated', source: 'file' }) + }) + + it('serves the user .env only when nothing is stored', async () => { + const dir = await tempDir() + const ctx = await bootLayered(join(dir, '.credentials.yaml'), [ + { source: 'process', values: {} }, + { source: 'user-env', path: '/home/.dsh/.env', values: { DSH_CRED_TEST: 'from-user-env' } }, + ]) + expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'from-user-env', source: 'user-env' }) + // Writable: storing a key replaces it as the effective one. + expect(await ctx.credentials.describe(KEY)).toEqual({ configured: true, source: 'user-env', writable: true }) + }) + + it('serves the invoking project .env over the user one, but never over the store', async () => { + const dir = await tempDir() + const path = join(dir, '.credentials.yaml') + // The product trusts the project it is launched in, so a checkout may + // carry its own key — ranked above the user's home file (more specific + // wins) and below the managed store, which a stored key must never lose to. + const layers = [ + { source: 'process' as const, values: {} }, + { source: 'project-env' as const, path: '/work/.env', values: { DSH_CRED_TEST: 'from-project' } }, + { source: 'user-env' as const, path: '/home/.dsh/.env', values: { DSH_CRED_TEST: 'from-user' } }, + ] + const bare = await bootLayered(path, layers) + expect(await bare.credentials.resolve(KEY)).toEqual({ value: 'from-project', source: 'project-env' }) + expect(await bare.credentials.describe(KEY)).toEqual({ configured: true, source: 'project-env', writable: true }) + + await writeCredentials(path, 'DSH_CRED_TEST: stored\n') + const stored = await bootLayered(path, layers) + expect(await stored.credentials.resolve(KEY)).toEqual({ value: 'stored', source: 'file' }) + }) + + it('refuses a document other OS users can read', async () => { + const dir = await tempDir() + const path = join(dir, '.credentials.yaml') + await writeFile(path, 'DSH_CRED_TEST: leaked\n', { mode: 0o644 }) + const ctx = new Context() + // Before the contents are read at all: serving secrets out of a + // world-readable file would make the 0600 the provider writes meaningless. + await expect(ctx.plugin(CredentialsLocal, { path, watch: false })) + .rejects.toThrow(/readable beyond its owner \(mode 644\)/) + }) + + it('propagates a permission check that fails for a reason other than absence', async () => { + const dir = await tempDir() + const notADirectory = join(dir, 'occupied') + await writeFile(notADirectory, 'a regular file\n') + // An absent document is an empty store, but a path that cannot be + // reached at all is a misconfiguration: the parent is a file, so the + // check fails with ENOTDIR rather than concluding "no credentials yet". + const ctx = new Context() + await expect(ctx.plugin(CredentialsLocal, { path: join(notADirectory, '.credentials.yaml'), watch: false })) + .rejects.toThrow(/ENOTDIR/) + }) + + it('propagates a read that fails for a reason other than absence', async () => { + const dir = await tempDir() + const path = join(dir, '.credentials.yaml') + // Owner-only, so the permission check passes, and unreadable as a file: + // the store is present but cannot be parsed, which must fail the launch + // rather than silently serve nothing. + await mkdir(path, { mode: 0o700 }) + const ctx = new Context() + await expect(ctx.plugin(CredentialsLocal, { path, watch: false })).rejects.toThrow(/EISDIR/) + }) + + it('lets only the inherited environment shadow the store, read-only', async () => { + const dir = await tempDir() + const path = join(dir, '.credentials.yaml') + await writeCredentials(path, 'DSH_CRED_TEST: stored\n') + const ctx = await bootLayered(path, [ + { source: 'process', values: { DSH_CRED_TEST: 'from-shell' } }, + { source: 'user-env', path: '/home/.dsh/.env', values: { DSH_CRED_TEST: 'from-user-env' } }, + ]) + expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'from-shell', source: 'env' }) + expect(await ctx.credentials.describe(KEY)).toEqual({ configured: true, source: 'env', writable: false }) + await expect(ctx.credentials.set(KEY, 'next')).rejects.toThrow(/launching environment/) + }) +}) + +describe('document validation', () => { + // Every rejection below is a boot failure rather than a skipped entry: this + // document holds nothing but credentials, so an ignored key would read as + // "the secret I stored has no effect". + it.each([ + ['a non-mapping root', 'just a string\n', /must be a mapping/], + ['a sequence root', '- DSH_CRED_TEST\n', /must be a mapping/], + ['a key that is not a POSIX identifier', 'not-a-ref: value\n', /credential ref/], + ['a non-string value', 'DSH_CRED_TEST: 123\n', /must be a string/], + ['an empty value', 'DSH_CRED_TEST: ""\n', /is empty/], + ['duplicate keys', 'DSH_CRED_TEST: one\nDSH_CRED_TEST: two\n', /invalid document/], + ['malformed yaml', 'DSH_CRED_TEST: "unterminated\n', /invalid document/], + ])('fails boot on %s', async (_case, text, message) => { + const dir = await tempDir() + const path = join(dir, '.credentials.yaml') + await writeCredentials(path, text) + const ctx = new Context() + await expect(ctx.plugin(CredentialsLocal, { path, watch: false })).rejects.toThrow(message) + }) + + it('never puts a credential value in a diagnostic', async () => { + const dir = await tempDir() + const path = join(dir, '.credentials.yaml') + const secret = 'sk-live-DO-NOT-LOG-abcdef123456' + // The yaml parser's own message quotes the offending source line, which in + // this document is the secret itself. Boot stderr and the watcher's logger + // both receive whatever this throws. + await writeCredentials(path, `DSH_CRED_TEST: "${secret}\n`) + let failure: unknown + try { + await new Context().plugin(CredentialsLocal, { path, watch: false }) + } catch (error) { + failure = error + } + expect(String(failure)).toMatch(/invalid document/) + // The position survives; the line's contents do not. + expect(String(failure)).toMatch(/line 2, column 1/) + expect(String(failure)).not.toContain(secret) + expect((failure as Error).stack ?? '').not.toContain(secret) + }) + + it('reads an empty document as an empty store', async () => { + const dir = await tempDir() + const path = join(dir, '.credentials.yaml') + await writeCredentials(path, '# nothing stored yet\n') + const ctx = await boot({ path, watch: false }) + expect(await ctx.credentials.resolve(KEY)).toBeUndefined() + }) +}) + +describe('document writes', () => { + it('adds a missing key to a fresh 0600 document and emits the commit', async () => { + const dir = await tempDir() + const path = join(dir, '.credentials.yaml') const ctx = await boot({ path, watch: false }) const seen = updates(ctx) await ctx.credentials.set(KEY, 'sk-fresh') - expect(await readFile(path, 'utf8')).toBe('DSH_CRED_TEST=sk-fresh\n') + expect(await readFile(path, 'utf8')).toBe('DSH_CRED_TEST: sk-fresh\n') expect((await stat(path)).mode & 0o777).toBe(0o600) expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'sk-fresh', source: 'file' }) expect(seen).toEqual([KEY]) }) - it('rewrites one line in place, preserving every other byte and dropping duplicates', async () => { + it('patches one entry, preserving comments and every untouched entry', async () => { const dir = await tempDir() - const path = join(dir, '.env') - await writeFile(path, '# deployment notes\nFIRST=one\n\nDSH_CRED_TEST=old\nTRAILING=x\nDSH_CRED_TEST=older') + const path = join(dir, '.credentials.yaml') + await writeCredentials(path, '# deployment notes\nDSH_CRED_OTHER: keep\n\n# the one under edit\nDSH_CRED_TEST: old\n') const ctx = await boot({ path, watch: false }) await ctx.credentials.set(KEY, 'new value!') - expect(await readFile(path, 'utf8')).toBe('# deployment notes\nFIRST=one\n\nDSH_CRED_TEST=\'new value!\'\nTRAILING=x\n') + expect(await readFile(path, 'utf8')).toBe( + '# deployment notes\nDSH_CRED_OTHER: keep\n\n# the one under edit\nDSH_CRED_TEST: new value!\n', + ) }) - it('quotes hostile values so they round-trip through a fresh provider', async () => { + it('round-trips values no dotenv line could represent', async () => { const dir = await tempDir() - const path = join(dir, '.env') + const path = join(dir, '.credentials.yaml') const ctx = await boot({ path, watch: false }) - const singleQuoted = 'with "quote", back\\slash and space' - const doubleQuoted = "it's got an apostrophe" - await ctx.credentials.set(KEY, singleQuoted) - await ctx.credentials.set(OTHER, doubleQuoted) + const multiLine = 'line one\nline two' + const mixedQuotes = 'both \' and "' + await ctx.credentials.set(KEY, multiLine) + await ctx.credentials.set(OTHER, mixedQuotes) const reread = await boot({ path, watch: false }) - expect(await reread.credentials.resolve(KEY)).toEqual({ value: singleQuoted, source: 'file' }) - expect(await reread.credentials.resolve(OTHER)).toEqual({ value: doubleQuoted, source: 'file' }) + expect(await reread.credentials.resolve(KEY)).toEqual({ value: multiLine, source: 'file' }) + expect(await reread.credentials.resolve(OTHER)).toEqual({ value: mixedQuotes, source: 'file' }) + expect(await reread.credentials.describe(KEY)).toEqual({ configured: true, source: 'file', writable: true }) }) - it('fails loud on values no .env quoting style reads back verbatim', async () => { + it('unsets only the owning entry, with its own annotation, and keeps an absent unset silent', async () => { const dir = await tempDir() - const ctx = await boot({ path: join(dir, '.env'), watch: false }) - await expect(ctx.credentials.set(KEY, 'line one\nline two')).rejects.toThrow(/control characters/) - await expect(ctx.credentials.set(KEY, 'both \' and "')).rejects.toThrow(/mixes quoting/) - }) - - it('unsets only the owning line and keeps an absent unset silent', async () => { - const dir = await tempDir() - const path = join(dir, '.env') - await writeFile(path, '# keep\nDSH_CRED_TEST=gone\nDSH_CRED_OTHER=stays\n') + const path = join(dir, '.credentials.yaml') + // Comments above an entry are that entry's annotation and go with it when + // it is removed — including anything above the document's first entry. + // Every other entry keeps its own comments. + await writeCredentials(path, '# about the doomed one\nDSH_CRED_TEST: gone\n# about the survivor\nDSH_CRED_OTHER: stays\n') const ctx = await boot({ path, watch: false }) const seen = updates(ctx) await ctx.credentials.unset(KEY) - expect(await readFile(path, 'utf8')).toBe('# keep\nDSH_CRED_OTHER=stays\n') + expect(await readFile(path, 'utf8')).toBe('# about the survivor\nDSH_CRED_OTHER: stays\n') await ctx.credentials.unset(KEY) expect(seen).toEqual([KEY]) }) - it('rejects empty values, shadowed writes, and multi-line entries', async () => { + it('rejects empty values and writes the environment would shadow', async () => { const dir = await tempDir() - const path = join(dir, '.env') - await writeFile(path, 'DSH_CRED_TEST="line one\nline two"\n') + const path = join(dir, '.credentials.yaml') + await writeCredentials(path, 'DSH_CRED_TEST: stored\n') const ctx = await boot({ path, watch: false }) await expect(ctx.credentials.set(KEY, '')).rejects.toThrow(/empty value/) - await expect(ctx.credentials.set(KEY, 'next')).rejects.toThrow(/multi-line/) - await expect(ctx.credentials.unset(KEY)).rejects.toThrow(/multi-line/) vi.stubEnv('DSH_CRED_TEST', 'shadowing') await expect(ctx.credentials.set(KEY, 'next')).rejects.toThrow(/shadowed/) await expect(ctx.credentials.unset(KEY)).rejects.toThrow(/shadowed/) }) - it('leaves an empty document after unsetting the only entry', async () => { + it('leaves an empty mapping after unsetting the only entry', async () => { const dir = await tempDir() - const path = join(dir, '.env') - await writeFile(path, 'DSH_CRED_TEST=only\n') + const path = join(dir, '.credentials.yaml') + await writeCredentials(path, 'DSH_CRED_TEST: only\n') const ctx = await boot({ path, watch: false }) await ctx.credentials.unset(KEY) - expect(await readFile(path, 'utf8')).toBe('') + expect(await readFile(path, 'utf8')).toBe('{}\n') + // The emptied document still reloads as an empty store, not a parse error. + const reread = await boot({ path, watch: false }) + expect(await reread.credentials.resolve(KEY)).toBeUndefined() + }) + + it('fails a write loud when the on-disk document became invalid', async () => { + const dir = await tempDir() + const path = join(dir, '.credentials.yaml') + const ctx = await boot({ path, watch: false }) + // An external editor left the document unparsable: the read-modify-write + // must refuse rather than overwrite content it cannot understand. + await writeCredentials(path, 'DSH_CRED_TEST: "unterminated\n') + await expect(ctx.credentials.set(OTHER, 'lands')).rejects.toThrow(/invalid document/) }) it('chains past a rejected write so one bad value cannot poison the queue', async () => { const dir = await tempDir() - const path = join(dir, '.env') + const path = join(dir, '.credentials.yaml') const ctx = await boot({ path, watch: false }) - const bad = expect(ctx.credentials.set(KEY, 'both \' and "')).rejects.toThrow(/mixes quoting/) + const bad = expect(ctx.credentials.set(KEY, '')).rejects.toThrow(/empty value/) const good = ctx.credentials.set(OTHER, 'lands') await bad await good - expect(await readFile(path, 'utf8')).toBe('DSH_CRED_OTHER=lands\n') + expect(await readFile(path, 'utf8')).toBe('DSH_CRED_OTHER: lands\n') }) it('serializes concurrent writes so both land in the one document', async () => { const dir = await tempDir() - const path = join(dir, '.env') + const path = join(dir, '.credentials.yaml') const ctx = await boot({ path, watch: false }) await Promise.all([ ctx.credentials.set(KEY, 'one'), ctx.credentials.set(OTHER, 'two'), ]) - expect(await readFile(path, 'utf8')).toBe('DSH_CRED_TEST=one\nDSH_CRED_OTHER=two\n') + expect(await readFile(path, 'utf8')).toBe('DSH_CRED_TEST: one\nDSH_CRED_OTHER: two\n') }) it('refuses writes after disposal', async () => { const dir = await tempDir() const ctx = new Context() - const fiber = ctx.plugin(CredentialsLocal, { path: join(dir, '.env'), watch: false }) + const fiber = ctx.plugin(CredentialsLocal, { path: join(dir, '.credentials.yaml'), watch: false }) await fiber // Capture the handle first: disposal also removes the ctx.credentials service. const service = ctx.credentials @@ -215,20 +391,20 @@ describe('line-editing writes', () => { describe('real hot reload', () => { it('publishes external edits, replaces the snapshot wholesale, and suppresses self-writes', async () => { const dir = await tempDir() - const path = join(dir, '.env') + const path = join(dir, '.credentials.yaml') // Watching starts on an existing document: creation racing watcher setup // is a chokidar readiness gap, not the reload contract under test. - await writeFile(path, 'DSH_CRED_TEST=boot\n') + await writeCredentials(path, 'DSH_CRED_TEST: boot\n') const ctx = await boot({ path, debounceMs: 10 }) const seen = updates(ctx) - await writeFile(path, 'DSH_CRED_TEST=live\nDSH_CRED_OTHER=extra\n') + await writeCredentials(path, 'DSH_CRED_TEST: live\nDSH_CRED_OTHER: extra\n') await vi.waitFor(async () => { expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'live', source: 'file' }) }) // Wholesale replacement: an entry deleted on disk never lingers in memory. - await writeFile(path, 'DSH_CRED_TEST=live\n') + await writeCredentials(path, 'DSH_CRED_TEST: live\n') await vi.waitFor(async () => { expect(await ctx.credentials.resolve(OTHER)).toBeUndefined() }) diff --git a/packages/credentials/credentials-local/tests/review-fixes.spec.ts b/packages/credentials/credentials-local/tests/review-fixes.spec.ts index 78e51c90e1..fcec7fceb9 100644 --- a/packages/credentials/credentials-local/tests/review-fixes.spec.ts +++ b/packages/credentials/credentials-local/tests/review-fixes.spec.ts @@ -1,7 +1,7 @@ // Third-review behaviors: read-modify-write under the writer lock (external // edits survive an API write), the contained credentials/updated fan-out (a -// broken observer never fails a committed write), and the physical-line -// editor's multi-line and CRLF discipline. +// broken observer never fails a committed write), and the YAML document +// editor's isolation between entries. import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' @@ -10,6 +10,11 @@ import { join } from 'node:path' import { credentialRef } from '@deepseek-ai/dsh-credentials' import { CredentialsLocal } from '../src/index.ts' +/** Credential documents are seeded owner-only, exactly as the provider creates them. */ +function writeCredentials(file: string, text: string): Promise { + return writeFile(file, text, { mode: 0o600 }) +} + const ALPHA = credentialRef('DSH_REVIEW_ALPHA') const BETA = credentialRef('DSH_REVIEW_BETA') const INNER = credentialRef('DSH_REVIEW_INNER') @@ -37,18 +42,18 @@ async function boot(config: ConstructorParameters[1]): describe('read-modify-write', () => { it('folds an unobserved external edit into a write instead of overwriting it', async () => { const dir = await tempDir() - const path = join(dir, '.env') + const path = join(dir, '.credentials.yaml') const ctx = await boot({ path, watch: false }) const seen: string[] = [] ctx.on('credentials/updated', (ref) => { seen.push(ref) }) await ctx.credentials.set(ALPHA, 'one') // The external edit has landed on disk but no watcher reported it (watch // is off — the same blind spot as a debounce window or a missed event). - await writeFile(path, `${ALPHA}=one\n${BETA}=external\n`) + await writeCredentials(path, `${ALPHA}: one\n${BETA}: external\n`) await ctx.credentials.set(ALPHA, 'two') const text = await readFile(path, 'utf8') - expect(text).toContain(`${BETA}=external`) - expect(text).toContain(`${ALPHA}=two`) + expect(text).toContain(`${BETA}: external`) + expect(text).toContain(`${ALPHA}: two`) // The fold published the unobserved entry before the write's own commit. expect(seen).toEqual([ALPHA, BETA, ALPHA]) expect(await ctx.credentials.resolve(BETA)).toEqual({ value: 'external', source: 'file' }) @@ -56,7 +61,7 @@ describe('read-modify-write', () => { it('keeps both refs when two providers write the same document concurrently', async () => { const dir = await tempDir() - const path = join(dir, '.env') + const path = join(dir, '.credentials.yaml') const first = await boot({ path, watch: false }) const second = await boot({ path, watch: false }) await Promise.all([ @@ -71,7 +76,7 @@ describe('read-modify-write', () => { it('creates the credentials directory owner-only', async () => { const dir = await tempDir() const home = join(dir, 'home') - const ctx = await boot({ path: join(home, '.env'), watch: false }) + const ctx = await boot({ path: join(home, '.credentials.yaml'), watch: false }) await ctx.credentials.set(ALPHA, 'one') expect((await stat(home)).mode & 0o777).toBe(0o700) }) @@ -80,7 +85,7 @@ describe('read-modify-write', () => { describe('contained update fan-out', () => { it('does not fail a committed set when a listener throws, and later listeners still run', async () => { const dir = await tempDir() - const ctx = await boot({ path: join(dir, '.env'), watch: false }) + const ctx = await boot({ path: join(dir, '.credentials.yaml'), watch: false }) ctx.on('credentials/updated', () => { throw new Error('observer boom') }) @@ -93,7 +98,7 @@ describe('contained update fan-out', () => { it('contains an async listener rejection', async () => { const dir = await tempDir() - const ctx = await boot({ path: join(dir, '.env'), watch: false }) + const ctx = await boot({ path: join(dir, '.credentials.yaml'), watch: false }) // An unknown-returning function keeps the typed surface legal while the // runtime value is still the rejected promise the containment must handle. const boom = (): unknown => Promise.reject(new Error('async observer boom')) @@ -104,7 +109,7 @@ describe('contained update fan-out', () => { it('rethrows an invariant-coded failure after the commit and the remaining listeners', async () => { const dir = await tempDir() - const path = join(dir, '.env') + const path = join(dir, '.credentials.yaml') const ctx = await boot({ path, watch: false }) ctx.on('credentials/updated', () => { throw Object.assign(new Error('forged relation'), { code: 'INVARIANT' }) @@ -114,78 +119,33 @@ describe('contained update fan-out', () => { await expect(ctx.credentials.set(ALPHA, 'one')).rejects.toThrow(/forged relation/) // Harness-fatal by design — but the write itself committed first. expect(second).toHaveBeenCalledWith(ALPHA) - expect(await readFile(path, 'utf8')).toContain(`${ALPHA}=one`) + expect(await readFile(path, 'utf8')).toContain(`${ALPHA}: one`) expect(await ctx.credentials.resolve(ALPHA)).toEqual({ value: 'one', source: 'file' }) }) }) -describe('physical-line editor', () => { - it('never mistakes a quoted multi-line continuation for an assignment', async () => { +describe('document editor', () => { + it('leaves a sibling multi-line value untouched while patching one entry', async () => { const dir = await tempDir() - const path = join(dir, '.env') - const wrapped = `DSH_REVIEW_WRAPPED="line1\n${INNER}=looks-like-one\nline3"\n${ALPHA}=a\n` - await writeFile(path, wrapped) + const path = join(dir, '.credentials.yaml') + const wrapped = `DSH_REVIEW_WRAPPED: |-\n line1\n line2\n${ALPHA}: a\n` + await writeCredentials(path, wrapped) const ctx = await boot({ path, watch: false }) await ctx.credentials.set(ALPHA, 'b') - // The wrapped value survives byte-for-byte; only ALPHA's line changed. - const afterAlpha = await readFile(path, 'utf8') - expect(afterAlpha).toBe(`DSH_REVIEW_WRAPPED="line1\n${INNER}=looks-like-one\nline3"\n${ALPHA}=b\n`) - // Setting the inner-looking ref appends a real assignment; the - // continuation line inside the quoted value stays untouched. - await ctx.credentials.set(INNER, 'real') - const afterInner = await readFile(path, 'utf8') - expect(afterInner).toBe(`DSH_REVIEW_WRAPPED="line1\n${INNER}=looks-like-one\nline3"\n${ALPHA}=b\n${INNER}=real\n`) - expect(await ctx.credentials.resolve(INNER)).toEqual({ value: 'real', source: 'file' }) + expect(await readFile(path, 'utf8')).toBe(`DSH_REVIEW_WRAPPED: |-\n line1\n line2\n${ALPHA}: b\n`) + expect(await ctx.credentials.resolve(credentialRef('DSH_REVIEW_WRAPPED'))) + .toEqual({ value: 'line1\nline2', source: 'file' }) }) - it('preserves CRLF line endings on untouched and edited lines', async () => { + it('stores a value that looks like another entry without creating one', async () => { const dir = await tempDir() - const path = join(dir, '.env') - await writeFile(path, `# note\r\n${ALPHA}=a\r\n${BETA}=keep\r\n`) + const path = join(dir, '.credentials.yaml') const ctx = await boot({ path, watch: false }) - await ctx.credentials.set(ALPHA, 'b') - expect(await readFile(path, 'utf8')).toBe(`# note\r\n${ALPHA}=b\r\n${BETA}=keep\r\n`) - await ctx.credentials.set(INNER, 'new') - expect(await readFile(path, 'utf8')).toBe(`# note\r\n${ALPHA}=b\r\n${BETA}=keep\r\n${INNER}=new\r\n`) - }) - - it('terminates a final unterminated line before appending', async () => { - const dir = await tempDir() - const path = join(dir, '.env') - await writeFile(path, `${ALPHA}=a`) - const ctx = await boot({ path, watch: false }) - await ctx.credentials.set(BETA, 'b') - expect(await readFile(path, 'utf8')).toBe(`${ALPHA}=a\n${BETA}=b\n`) - }) - - it('rewrites a final unterminated assignment in the dominant ending style', async () => { - const dir = await tempDir() - const path = join(dir, '.env') - await writeFile(path, `${ALPHA}=a`) - const ctx = await boot({ path, watch: false }) - await ctx.credentials.set(ALPHA, 'b') - expect(await readFile(path, 'utf8')).toBe(`${ALPHA}=b\n`) - }) - - it('tracks a single-quoted multi-line value through its continuation', async () => { - const dir = await tempDir() - const path = join(dir, '.env') - await writeFile(path, `DSH_REVIEW_SQ='line1\n${INNER}=shadow\nline3'\n`) - const ctx = await boot({ path, watch: false }) - await ctx.credentials.set(ALPHA, 'x') - expect(await readFile(path, 'utf8')) - .toBe(`DSH_REVIEW_SQ='line1\n${INNER}=shadow\nline3'\n${ALPHA}=x\n`) - }) - - it('reports a multi-line entry as unwritable and refuses to edit it', async () => { - const dir = await tempDir() - const path = join(dir, '.env') - await writeFile(path, `${ALPHA}="line1\nline2"\n`) - const ctx = await boot({ path, watch: false }) - expect(await ctx.credentials.describe(ALPHA)).toEqual({ configured: true, source: 'file', writable: false }) - await expect(ctx.credentials.set(ALPHA, 'flat')).rejects.toThrow(/multi-line entry/) - await expect(ctx.credentials.unset(ALPHA)).rejects.toThrow(/multi-line entry/) - // Resolution still serves the multi-line value. - expect(await ctx.credentials.resolve(ALPHA)).toEqual({ value: 'line1\nline2', source: 'file' }) + // The stored text must stay a value: a quoted-scalar write that leaked its + // own structure would silently mint a credential nobody stored. + await ctx.credentials.set(ALPHA, `${INNER}: injected`) + const reread = await boot({ path, watch: false }) + expect(await reread.credentials.resolve(ALPHA)).toEqual({ value: `${INNER}: injected`, source: 'file' }) + expect(await reread.credentials.resolve(INNER)).toBeUndefined() }) }) diff --git a/packages/credentials/credentials-local/tests/watcher.spec.ts b/packages/credentials/credentials-local/tests/watcher.spec.ts index 6ff53252cf..8c216e6976 100644 --- a/packages/credentials/credentials-local/tests/watcher.spec.ts +++ b/packages/credentials/credentials-local/tests/watcher.spec.ts @@ -6,6 +6,11 @@ import { join } from 'node:path' import { credentialRef } from '@deepseek-ai/dsh-credentials' import { CredentialsLocal } from '../src/index.ts' +/** Credential documents are seeded owner-only, exactly as the provider creates them. */ +function writeCredentials(file: string, text: string): Promise { + return writeFile(file, text, { mode: 0o600 }) +} + // chokidar is the nondeterministic OS boundary: faking it lets these tests // drive the event pipeline (error events, races with unreadable files) // deterministically. Real end-to-end watching stays covered by local.spec.ts. @@ -66,21 +71,21 @@ async function boot(config: ConstructorParameters[1]): describe('watcher pipeline', () => { it('clamps the write-settle poll interval for a zero debounce', async () => { const dir = await tempDir() - await boot({ path: join(dir, '.env'), debounceMs: 0 }) + await boot({ path: join(dir, '.credentials.yaml'), debounceMs: 0 }) const [instance] = await fakeInstances() expect(instance!.options.awaitWriteFinish).toEqual({ stabilityThreshold: 0, pollInterval: 1 }) }) it('survives a watcher error and keeps publishing later edits', async () => { const dir = await tempDir() - const path = join(dir, '.env') + const path = join(dir, '.credentials.yaml') const ctx = await boot({ path, debounceMs: 5 }) const [instance] = await fakeInstances() instance!.watcher.emit('error', new Error('watch backend failure')) expect(await ctx.credentials.resolve(KEY)).toBeUndefined() - await writeFile(path, 'DSH_CRED_PIPE=arrived\n') + await writeCredentials(path, 'DSH_CRED_PIPE: arrived\n') instance!.watcher.emit('all', 'change', path) await vi.waitFor(async () => { expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'arrived', source: 'file' }) @@ -89,8 +94,8 @@ describe('watcher pipeline', () => { it('keeps the last good snapshot when the file turns unreadable at runtime', async () => { const dir = await tempDir() - const path = join(dir, '.env') - await writeFile(path, 'DSH_CRED_PIPE=good\n') + const path = join(dir, '.credentials.yaml') + await writeCredentials(path, 'DSH_CRED_PIPE: good\n') const ctx = await boot({ path, debounceMs: 5 }) await chmod(path, 0o000) @@ -104,7 +109,7 @@ describe('watcher pipeline', () => { it('keeps the reload queue alive after an invariant violation escapes the fan-out', async () => { const dir = await tempDir() - const path = join(dir, '.env') + const path = join(dir, '.credentials.yaml') const ctx = await boot({ path, debounceMs: 5 }) let arm = true ctx.on('credentials/updated', () => { @@ -113,7 +118,7 @@ describe('watcher pipeline', () => { }) const [instance] = await fakeInstances() - await writeFile(path, 'DSH_CRED_PIPE=first\n') + await writeCredentials(path, 'DSH_CRED_PIPE: first\n') instance!.watcher.emit('all', 'change', path) // The snapshot commits before the fan-out, so the value lands even though // the listener threw out of the refresh. @@ -122,7 +127,7 @@ describe('watcher pipeline', () => { }) arm = false - await writeFile(path, 'DSH_CRED_PIPE=second\n') + await writeCredentials(path, 'DSH_CRED_PIPE: second\n') instance!.watcher.emit('all', 'change', path) await vi.waitFor(async () => { expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'second', source: 'file' }) @@ -131,8 +136,8 @@ describe('watcher pipeline', () => { it('quiesces the refresh pipeline before dispose completes', async () => { const dir = await tempDir() - const path = join(dir, '.env') - await writeFile(path, 'DSH_CRED_PIPE=initial\n') + const path = join(dir, '.credentials.yaml') + await writeCredentials(path, 'DSH_CRED_PIPE: initial\n') const ctx = new Context() const fiber = ctx.plugin(CredentialsLocal, { path, debounceMs: 5 }) await fiber @@ -142,7 +147,7 @@ describe('watcher pipeline', () => { if (disposed) postDisposeCommits += 1 }) - await writeFile(path, 'DSH_CRED_PIPE=changed\n') + await writeCredentials(path, 'DSH_CRED_PIPE: changed\n') const [instance] = await fakeInstances() // Two queued refreshes: dispose interrupts one mid-flight and the other // before it starts, so both closed guards must hold. @@ -158,8 +163,8 @@ describe('watcher pipeline', () => { it('empties the snapshot when the document is deleted and emits the removals', async () => { const dir = await tempDir() - const path = join(dir, '.env') - await writeFile(path, 'DSH_CRED_PIPE=doomed\n') + const path = join(dir, '.credentials.yaml') + await writeCredentials(path, 'DSH_CRED_PIPE: doomed\n') const ctx = await boot({ path, debounceMs: 5 }) const seen: string[] = [] ctx.on('credentials/updated', (ref) => { @@ -175,30 +180,39 @@ describe('watcher pipeline', () => { expect(seen).toEqual([KEY]) }) - it('publishes only seam-addressable keys and preserves the rest untouched', async () => { + it('keeps the last good snapshot when an external edit makes the document invalid', async () => { const dir = await tempDir() - const path = join(dir, '.env') - await writeFile(path, 'BAD-KEY=1\nDSH_CRED_PIPE=a\n') + const path = join(dir, '.credentials.yaml') + await writeCredentials(path, 'DSH_CRED_PIPE: a\n') const ctx = await boot({ path, debounceMs: 5 }) const seen: string[] = [] ctx.on('credentials/updated', (ref) => { seen.push(ref) }) - await writeFile(path, 'BAD-KEY=2\nDSH_CRED_PIPE=b\n') + // A key the seam cannot address is a rejection, not preserved content: + // this document holds nothing but credentials. A live reload must warn + // and keep serving the last good snapshot rather than take the process + // down or silently drop the entry it could not validate. + await writeCredentials(path, 'BAD-KEY: 2\nDSH_CRED_PIPE: b\n') const [instance] = await fakeInstances() instance!.watcher.emit('all', 'change', path) + await new Promise(resolve => setTimeout(resolve, 50)) + expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'a', source: 'file' }) + expect(seen).toEqual([]) + + // Repairing the document resumes publishing. + await writeCredentials(path, 'DSH_CRED_PIPE: b\n') + instance!.watcher.emit('all', 'change', path) await vi.waitFor(async () => { expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'b', source: 'file' }) }) - // The dash-named key is preserved file content the seam cannot address: - // its change publishes nothing and breaks nothing. expect(seen).toEqual([KEY]) }) it('treats an event for a still-absent file as a no-op', async () => { const dir = await tempDir() - const path = join(dir, '.env') + const path = join(dir, '.credentials.yaml') const ctx = await boot({ path, debounceMs: 5 }) const [instance] = await fakeInstances() instance!.watcher.emit('all', 'add', path) @@ -208,12 +222,12 @@ describe('watcher pipeline', () => { it('reconciles at watcher ready so a change during setup is not missed', async () => { const dir = await tempDir() - const path = join(dir, '.env') - await writeFile(path, `${KEY}=a\n`) + const path = join(dir, '.credentials.yaml') + await writeCredentials(path, `${KEY}: a\n`) const ctx = await boot({ path, debounceMs: 5 }) // Written after the initial load but before the watcher became active: // no 'all' event will ever fire for it. - await writeFile(path, `${KEY}=written-before-ready\n`) + await writeCredentials(path, `${KEY}: written-before-ready\n`) const [instance] = await fakeInstances() instance!.watcher.emit('ready') await vi.waitFor(async () => { diff --git a/packages/credentials/credentials-local/tsconfig.json b/packages/credentials/credentials-local/tsconfig.json index 3acfbdeffe..75e6b0aeb0 100644 --- a/packages/credentials/credentials-local/tsconfig.json +++ b/packages/credentials/credentials-local/tsconfig.json @@ -20,6 +20,9 @@ { "path": "../../util/atomic-write" }, + { + "path": "../../util/environment" + }, { "path": "../../util/paths" }, diff --git a/packages/credentials/credentials/README.i18n.yaml b/packages/credentials/credentials/README.i18n.yaml index 10fe5f0ffe..beeeef0ffd 100644 --- a/packages/credentials/credentials/README.i18n.yaml +++ b/packages/credentials/credentials/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/credentials/credentials/README.md -README.md: 1c18c4762360ad081227b7097cd82ddab4fcdefc -README.zh.md: 751fb7c1e8326cef91b925c5f8b9f40d92e1bba6 +README.md: 95ef76d145727340d8135bf1d48babd6d8adb882 +README.zh.md: b3404858025d4ec53a76548c78c1808d2c858844 diff --git a/packages/credentials/credentials/README.md b/packages/credentials/credentials/README.md index 1c18c47623..95ef76d145 100644 --- a/packages/credentials/credentials/README.md +++ b/packages/credentials/credentials/README.md @@ -31,7 +31,7 @@ The shadowing rule on `set`/`unset` is deliberate fail-loud: when a read-only so ## Providers -[`dsh-credentials-local`](../credentials-local/README.md) layers the live process environment over a `$DSH_HOME/.env` file. The seam shape leaves room for keyring-, helper-command-, and KMS-backed providers; a remote settings provider never needs to carry secrets. +[`dsh-credentials-local`](../credentials-local/README.md) layers the inherited process environment over its managed `$DSH_HOME/.credentials.yaml` document, with the launcher's project and user `.env` layers as fallbacks. The seam shape leaves room for keyring-, helper-command-, and KMS-backed providers; a remote settings provider never needs to carry secrets. ## Model Experience diff --git a/packages/credentials/credentials/README.zh.md b/packages/credentials/credentials/README.zh.md index 751fb7c1e8..b340485802 100644 --- a/packages/credentials/credentials/README.zh.md +++ b/packages/credentials/credentials/README.zh.md @@ -31,7 +31,7 @@ await ctx.credentials.unset(ref) // no-op when absent; s ## Providers -[`dsh-credentials-local`](../credentials-local/README.md) 把活跃进程环境叠加在 `$DSH_HOME/.env` 文件之上。seam 形状为 keyring、辅助命令、KMS 后端的 provider 留好了位置;远端 settings provider 永远不必携带机密。 +[`dsh-credentials-local`](../credentials-local/README.md) 把继承的进程环境叠加在其受管 `$DSH_HOME/.credentials.yaml` 文档之上,并以启动器的项目和用户 `.env` 层作为后备。seam 形状为 keyring、辅助命令、KMS 后端的 provider 留好了位置;远端 settings provider 永远不必携带机密。 ## Model Experience diff --git a/packages/credentials/credentials/src/index.ts b/packages/credentials/credentials/src/index.ts index b640b42881..c6470c1628 100644 --- a/packages/credentials/credentials/src/index.ts +++ b/packages/credentials/credentials/src/index.ts @@ -32,7 +32,7 @@ export function credentialRef(value: string): CredentialRef { export interface ResolvedCredential { /** The non-empty secret value. */ value: string - /** Provider-defined source layer id (the local provider uses `env` and `file`). */ + /** Provider-defined source layer id (the local provider uses `env`, `file`, `project-env`, and `user-env`). */ source: string } diff --git a/packages/examples/acp-demo/tests/load-path.e2e.ts b/packages/examples/acp-demo/tests/load-path.e2e.ts index 37b41cedd0..4719c161fe 100644 --- a/packages/examples/acp-demo/tests/load-path.e2e.ts +++ b/packages/examples/acp-demo/tests/load-path.e2e.ts @@ -33,8 +33,6 @@ const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.m const CORDIS_YML = ` - id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' - config: - apiKey: !!js process.env.DEEPSEEK_API_KEY - id: subprocess name: '@deepseek-ai/dsh-subprocess-local' - id: bash 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 2a54e2c113..e4b715a0c4 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/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index b07e13cb7d..470dc19e92 100644 --- a/packages/llm/llm-deepseek/README.i18n.yaml +++ b/packages/llm/llm-deepseek/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/llm/llm-deepseek/README.md -README.md: c6435d0bdfbb9758b6f86ef38e94159a9ccdc36d -README.zh.md: f37286ade023ceecf6bfc87eb08fd4d80a5a3912 +README.md: 6ad674ebdf8da4fd927a9499e80e06462d3c0dfb +README.zh.md: 02a192e38aa19b24a34e83b3ddb77d786bbace0b diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index c6435d0bdf..6ad674ebdf 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -15,7 +15,6 @@ The package root exposes the Cordis plugin contract and `DeepSeekAdapter`; wire name: '@deepseek-ai/dsh-llm-deepseek' config: apiKeyEnv: DEEPSEEK_API_KEY # default; resolved per request via ctx.credentials, then the environment - # apiKey: … # literal escape hatch; prefer the reference so no secret enters this file baseURL: https://api.deepseek.com # optional; $DEEPSEEK_BASE_URL then the public API when omitted thinking: enabled # optional; provider default is enabled reasoningEffort: high # optional; off | high | max — omitted ⇒ high @@ -53,7 +52,7 @@ The same exact-model result exposes ordered `off`, `high`, and `max` efforts und Connection facts are not frozen at load. `resolveAdapterOptions` is the one explicit resolve step from raw config to validated facts, and the adapter re-reads them through a thunk **once per operation**: base URL, catalog, request defaults, and idle budget all take effect on the next request, while an in-flight stream keeps the facts it started with. Two optional seams feed that thunk: - **`ctx.settings`** — the plugin registers the `llm-deepseek` namespace with this same `Config` schema and its `cordis.yml` entry as the composition `base`, so a `llm-deepseek:` section in the user settings document overrides any field without a restart. Without a mounted settings service the entry config alone drives the adapter, unchanged. A live settings snapshot that passes the schema but fails a beyond-schema bound (a duplicate catalog id, a broken thinking/effort pair) keeps the last good facts and logs the failure; the entry config itself still fails plugin load. -- **`ctx.credentials`** — the API key resolves per stream call, from the *same* resolved snapshot that supplies the endpoint: a trimmed, non-empty literal `apiKey` wins, then `apiKeyEnv` through the credential seam (`$DSH_HOME/.env` under the live environment), then — only without a mounted seam — the raw environment variable. Whitespace-only literals are absent rather than Authorization values. Because credential facts travel with the connection facts, a settings snapshot the resolver rejects contributes neither its endpoint nor its key: the whole previous generation keeps serving. Every key is format-checked before use — a literal at connection-facts resolution (plugin load, or the next settings snapshot), a stored or ambient value at request time — so a value no HTTP header can carry is refused there instead of surfacing as an opaque `fetch` `TypeError`; the request-time check throws `LlmError('INVALID_CREDENTIAL')` naming the failing entry point but never any part of the key. A request with no key anywhere fails with `MISSING_CREDENTIAL` naming every configuration entry point, while the route stays registered and the catalog stays browsable — first-run onboarding is "browse models, store the key, prompt again", with no restart between. +- **`ctx.credentials`** — the API key resolves per stream call, from the *same* resolved snapshot that supplies the endpoint. Configuration carries only `apiKeyEnv`, never a literal key: the reference resolves through the credential seam, and without a mounted seam through the trusted environment layers. Because credential facts travel with the connection facts, a settings snapshot the resolver rejects contributes neither its endpoint nor its key: the whole previous generation keeps serving. Every resolved key is format-checked before use, so a value no HTTP header can carry is refused with `LlmError('INVALID_CREDENTIAL')` naming the failing entry point — never any part of the key — instead of surfacing as an opaque `fetch` `TypeError`. A request with no key anywhere fails with `MISSING_CREDENTIAL` naming every configuration entry point, while the route stays registered and the catalog stays browsable — first-run onboarding is "browse models, store the key, prompt again", with no restart between. The one registration-captured fact is the retry policy: when its resolved value changes, the plugin re-registers the route in place (same adapter instance, one synchronous section), so `ctx.llm.providerRetryPolicy('deepseek-official')` always reports the current policy. @@ -108,7 +107,6 @@ Loop-retained response blocks append to the next request and preserve its earlie ## Known Limitations and Deferred Work - **A settings `models` list replaces the composition list wholesale** — settings-layer merging is per-field, and arrays are one field; per-entry catalog merging would need a keyed shape. -- **`Config.apiKey` is redacted on the wire but still a stored literal** — `describe({ redactSecrets: true })` strips it and reports the slot, so a configuration UI never receives the value; the key is nonetheless stored in the settings document rather than the credential store, so prefer `apiKeyEnv`. - **`tool_choice` is not mapped** — not part of the core vocabulary (MVP cut, shared with the pi-ai twin). - **Requests use raw `fetch`, not `@cordisjs/plugin-http`** — no shared proxy/interception configuration; adoption is deferred until a second adapter wants it (`TODO(http)`). - **Serialization flattens user and tool-result content to text blocks** — plugin-added block types are skipped, and empty tool output crosses the wire as the literal `(no output)`. diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md index f37286ade0..02a192e38a 100644 --- a/packages/llm/llm-deepseek/README.zh.md +++ b/packages/llm/llm-deepseek/README.zh.md @@ -15,7 +15,6 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器: name: '@deepseek-ai/dsh-llm-deepseek' config: apiKeyEnv: DEEPSEEK_API_KEY # default; resolved per request via ctx.credentials, then the environment - # apiKey: … # literal escape hatch; prefer the reference so no secret enters this file baseURL: https://api.deepseek.com # optional; $DEEPSEEK_BASE_URL then the public API when omitted thinking: enabled # optional; provider default is enabled reasoningEffort: high # optional; off | high | max — omitted ⇒ high @@ -53,7 +52,7 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器: 连接事实不在加载时冻结。`resolveAdapterOptions` 是从原始配置到已校验事实的唯一显式 resolve 步骤,适配器经由一个 thunk **每操作重读一次**:base URL、catalog、请求默认值与 idle 预算都在下一次请求生效,进行中的流则保持其起始事实。两个可选 seam 供给该 thunk: - **`ctx.settings`**——插件用同一份 `Config` schema 注册 `llm-deepseek` namespace,并以其 `cordis.yml` 条目为组合 `base`,因此用户设置文档中的 `llm-deepseek:` 分节可以免重启覆盖任何字段。未挂载 settings 服务时,仅由 entry 配置驱动适配器,行为不变。存活 settings 快照若通过 schema 却违反 schema 之外的约束(重复的 catalog id、无法成立的 thinking/推理强度组合),则保留最后可用事实并记录失败;entry 配置本身仍会使插件加载失败。 -- **`ctx.credentials`**——API 密钥按每次 stream 调用解析,取自与端点*同一*份解析后的快照:去除首尾空白后非空的字面 `apiKey` 优先,其次经凭据 seam 解析 `apiKeyEnv`(活跃环境之下的 `$DSH_HOME/.env`),最后——仅在未挂载 seam 时——读取原始环境变量。纯空白字面值会被视为缺失,而不会成为 Authorization 值。由于凭据事实与连接事实同行,被 resolver 拒绝的 settings 快照既不贡献自己的端点,也不贡献自己的密钥:整个先前世代继续服务。每个密钥在使用前都会被校验格式——字面量在连接事实解析时(插件加载或下一次 settings 快照)校验,已存储的值或环境变量值则在请求时校验——因此 HTTP 标头无法承载的值会在这一步被拒绝,而不是以语义不明的 `fetch` `TypeError` 形式浮现;请求时校验会抛出 `LlmError('INVALID_CREDENTIAL')`,点名失败的入口,但绝不透露密钥的任何部分。任何地方都没有密钥的请求以 `MISSING_CREDENTIAL` 失败,并点名每个配置入口,同时路由保持注册、catalog 保持可浏览——首次运行的上手流程就是「浏览模型、存入密钥、再次发起提示」,中间无需任何重启。 +- **`ctx.credentials`**——API 密钥按每次 stream 调用解析,取自与端点*同一*份解析后的快照。配置只携带 `apiKeyEnv`,从不携带字面密钥:该引用经凭据 seam 解析,未挂载 seam 时则经受信环境层解析。由于凭据事实与连接事实同行,被 resolver 拒绝的 settings 快照既不贡献自己的端点,也不贡献自己的密钥:整个先前世代继续服务。每个解析出的密钥在使用前都会被校验格式,因此 HTTP 标头无法承载的值会以 `LlmError('INVALID_CREDENTIAL')` 被拒绝,点名失败的入口,但绝不透露密钥的任何部分,而不是以语义不明的 `fetch` `TypeError` 形式浮现。任何地方都没有密钥的请求以 `MISSING_CREDENTIAL` 失败,并点名每个配置入口,同时路由保持注册、catalog 保持可浏览——首次运行的上手流程就是「浏览模型、存入密钥、再次发起提示」,中间无需任何重启。 唯一在注册期捕获的事实是重试策略:其解析值变化时,插件原地重新注册该路由(同一适配器实例、一个同步区段),因此 `ctx.llm.providerRetryPolicy('deepseek-official')` 始终报告当前策略。 @@ -108,7 +107,6 @@ loop 保留的响应块会追加到下一个请求,并保留其较早可复用 ## 已知限制与暂缓事项 - **settings 的 `models` 列表会整体替换组合列表**:settings 层按字段合并,而数组是单个字段;按条目合并 catalog 需要带键的形状。 -- **`Config.apiKey` 在协议上已脱敏,但仍是一个已存的字面值**:`describe({ redactSecrets: true })` 会把它剥离并报告该槽位,配置 UI 因此永远收不到该值;但这个密钥仍存放在 settings 文档而非凭据存储中,所以请优先使用 `apiKeyEnv`。 - **未映射 `tool_choice`**:它不属于核心词汇(MVP 取舍,与 pi-ai twin 共享)。 - **请求使用原始 `fetch`,而非 `@cordisjs/plugin-http`**:没有共享 proxy/拦截配置;采用暂缓到第二个适配器需要该功能时(`TODO(http)`)。 - **序列化会将 user 与工具结果内容展平为文本块**:会跳过插件添加的块类型,空工具输出会以字面 `(no output)` 通过协议发送。 diff --git a/packages/llm/llm-deepseek/package.json b/packages/llm/llm-deepseek/package.json index 9f9f305b83..e745fa1169 100644 --- a/packages/llm/llm-deepseek/package.json +++ b/packages/llm/llm-deepseek/package.json @@ -26,6 +26,7 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-credentials": "^0.0.1", + "@deepseek-ai/dsh-environment": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-settings": "^0.0.1", @@ -38,6 +39,7 @@ }, "devDependencies": { "@deepseek-ai/dsh-credentials": "workspace:^", + "@deepseek-ai/dsh-environment": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index c0d74d3f75..1c3306472e 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -49,12 +49,11 @@ export interface DeepSeekConnectionOptions { /** Endpoint base; `/chat/completions` is appended. */ baseURL: string /** - * Literal API key of this same resolution, when the configuration carried - * one. Travelling with the endpoint is the point: a request can never pair - * one generation's URL with another generation's secret. + * Credential reference of this same resolution, resolved per request. + * Travelling with the endpoint is the point: a request can never pair one + * generation's URL with another generation's secret. Configuration carries + * only this name — a literal key is not a configuration value. */ - apiKey?: string - /** Credential reference of this same resolution, resolved per request when no literal key exists. */ apiKeyEnv: CredentialRef /** Request defaults applied to every call (thinking mode, effort). */ defaults: RequestDefaults diff --git a/packages/llm/llm-deepseek/src/index.ts b/packages/llm/llm-deepseek/src/index.ts index 63706de49c..6d052edc45 100644 --- a/packages/llm/llm-deepseek/src/index.ts +++ b/packages/llm/llm-deepseek/src/index.ts @@ -13,9 +13,10 @@ import type { Context } from 'cordis' import z from 'schemastery' -import { assertUsableApiKey, LlmError, normalizeApiKey, resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/dsh-llm' +import { assertUsableApiKey, LlmError, resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/dsh-llm' import type { RetryPolicyConfig } from '@deepseek-ai/dsh-llm' import { credentialRef } from '@deepseek-ai/dsh-credentials' +import { environmentOf, type EnvironmentSnapshot } from '@deepseek-ai/dsh-environment' import { deepEqualJson, installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { @@ -58,17 +59,9 @@ const DEFAULT_MODELS: DeepSeekCatalogModel[] = [ * reasoning effort resolves to `high`. */ export interface Config { - /** - * Trimmed literal API key; whitespace-only is absent, so it resolves through - * {@link apiKeyEnv} like an omitted one. Prefer {@link apiKeyEnv} to keep - * secrets out of configuration files. {@link resolveAdapterOptions} also - * format-checks what remains: a value no HTTP header can carry fails there - * rather than inside `fetch`. - */ - apiKey?: string /** Credential reference (environment-variable name) resolved per request; defaults to `DEEPSEEK_API_KEY`. */ apiKeyEnv?: string - /** Endpoint base; falls back to $DEEPSEEK_BASE_URL, then the public API. */ + /** Endpoint base; falls back to $DEEPSEEK_BASE_URL from a trusted environment layer, then the public API. */ baseURL?: string /** Deployment thinking policy; `disabled` limits every conversation request to `off`. */ thinking?: 'enabled' | 'disabled' @@ -95,7 +88,6 @@ const catalogModel: z = z.object({ }) export const Config: z = z.object({ - apiKey: z.string().role('secret'), apiKeyEnv: z.string().role('credential-ref').default(DEFAULT_API_KEY_ENV), baseURL: z.string(), thinking: z.union(['enabled', 'disabled']), @@ -110,6 +102,9 @@ export const Config: z = z.object({ /** Public API default; the internal endpoint comes from $DEEPSEEK_BASE_URL. */ export const PUBLIC_BASE_URL = 'https://api.deepseek.com' +/** Environment variable naming this provider's endpoint, honored only from trusted layers. */ +const BASE_URL_ENV = 'DEEPSEEK_BASE_URL' + /** * One resolution's complete request facts. Connection and credential facts * are one value on purpose: a snapshot the resolver rejects keeps the whole @@ -156,9 +151,13 @@ function resolveModels(models: readonly DeepSeekCatalogModel[] | undefined): Dee * every default and bound is re-judged here — for the composition entry at * load (fail loud) and for each settings snapshot at its first use. * @param config - raw plugin config or resolved settings snapshot. + * @param environment - this run's environment layers, or `undefined` outside + * the product CLI. Every layer may supply an endpoint: the product trusts the + * project it is launched in, so a checkout can point its own agent at the + * gateway that checkout is meant to use. * @returns validated connection facts plus the credential reference. */ -export function resolveAdapterOptions(config: Config): ResolvedDeepSeekOptions { +export function resolveAdapterOptions(config: Config, environment?: EnvironmentSnapshot): ResolvedDeepSeekOptions { if (config.thinking === 'disabled' && config.reasoningEffort !== undefined && config.reasoningEffort !== 'off') { @@ -180,27 +179,11 @@ export function resolveAdapterOptions(config: Config): ResolvedDeepSeekOptions { `llm-deepseek: streamIdleTimeoutMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`, ) } - // An absent apiKey is not a failure: it falls through to apiKeyEnv below. - // A supplied one must be usable, so a malformed literal fails here beside - // the other beyond-schema bounds instead of inside `fetch`. - // Absence is not a failure, and a blank literal is absence: both resolve - // through apiKeyEnv below, which is this adapter's defined fallback. (The - // pi-ai adapter refuses a blank one instead, because there absence selects a - // different authentication mode rather than a different source for the same - // key.) What a literal cannot be is unusable: a value no HTTP header can - // carry fails here beside the other beyond-schema bounds, not inside `fetch`. - let apiKey: string | undefined - if (config.apiKey !== undefined) { - const checked = normalizeApiKey(config.apiKey) - if (!checked.ok && checked.reason === 'illegalCharacters') { - throw new Error('llm-deepseek: apiKey contains characters no HTTP header can carry; paste the raw key only') - } - apiKey = checked.ok ? checked.value : undefined - } return { - ...apiKey === undefined ? {} : { apiKey }, apiKeyEnv: credentialRef(config.apiKeyEnv ?? DEFAULT_API_KEY_ENV), - baseURL: config.baseURL ?? process.env.DEEPSEEK_BASE_URL ?? PUBLIC_BASE_URL, + baseURL: config.baseURL + ?? environment?.get(BASE_URL_ENV)?.value + ?? PUBLIC_BASE_URL, defaults: { thinking: config.thinking, reasoningEffort: config.reasoningEffort, @@ -221,7 +204,7 @@ export function apply(ctx: Context, config: Config): void { const raw = current() if (raw === lastRaw && lastGood !== undefined) return lastGood try { - const next = resolveAdapterOptions(raw) + const next = resolveAdapterOptions(raw, environmentOf(ctx)) lastRaw = raw lastGood = next return next @@ -241,22 +224,22 @@ export function apply(ctx: Context, config: Config): void { const resolveApiKey = async (connection: ResolvedDeepSeekOptions): Promise => { // Every credential fact comes from the caller's snapshot, so a rejected // settings generation cannot leak its key onto the previous endpoint. - if (connection.apiKey !== undefined) return connection.apiKey const ref = connection.apiKeyEnv const credentials = ctx.get('credentials') if (credentials !== undefined) { const hit = await credentials.resolve(ref) if (hit !== undefined) return assertUsableApiKey(hit.value, 'llm-deepseek', ref) } else { - // Without the seam, keep the historical ambient fallback so a plain - // cordis.yml composition works from the environment alone. - const ambient = process.env[ref] - if (ambient !== undefined && ambient.length > 0) return assertUsableApiKey(ambient, 'llm-deepseek', ref) + // Without the seam there is no managed store to rank against, so the + // environment is the whole credential plane. + const ambient = environmentOf(ctx).get(ref) + if (ambient !== undefined && ambient.value.length > 0) { + return assertUsableApiKey(ambient.value, 'llm-deepseek', ref) + } } throw new LlmError( `llm-deepseek: no API key for provider route "${PROVIDER}"; store ${ref} through the credentials` - + ` service (the web Models page writes it), export ${ref} in the launching environment, or — as a` - + ' last resort — set a literal "apiKey" in the llm-deepseek settings section', + + ` service (the web Models page writes it), or export ${ref} in the launching environment`, 'MISSING_CREDENTIAL', ) } diff --git a/packages/llm/llm-deepseek/tests/adapter.e2e.ts b/packages/llm/llm-deepseek/tests/adapter.e2e.ts index 5468cd8d9f..97ae629001 100644 --- a/packages/llm/llm-deepseek/tests/adapter.e2e.ts +++ b/packages/llm/llm-deepseek/tests/adapter.e2e.ts @@ -62,14 +62,16 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-deepseek e2e (real API)', () if (key === undefined) throw new Error('e2e ran without DEEPSEEK_API_KEY') const dir = await mkdtemp(join(tmpdir(), 'dsh-e2e-credentials-')) try { - await writeFile(join(dir, '.env'), `DEEPSEEK_API_KEY=${key}\n`, { mode: 0o600 }) + // JSON.stringify quotes the value: YAML is a JSON superset, so a real + // key survives whatever characters it happens to carry. + await writeFile(join(dir, '.credentials.yaml'), `DEEPSEEK_API_KEY: ${JSON.stringify(key)}\n`, { mode: 0o600 }) // Scrub the ambient variable so only the credential seam can supply the // key: this request proves the per-request resolution path end to end. vi.stubEnv('DEEPSEEK_API_KEY', '') const ctx = new Context() contexts.push(ctx) await ctx.plugin(LlmService) - await ctx.plugin(CredentialsLocal, { path: join(dir, '.env'), watch: false }) + await ctx.plugin(CredentialsLocal, { path: join(dir, '.credentials.yaml'), watch: false }) await ctx.plugin(LlmDeepSeek, {}) const result = await assemble(ctx, { diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index 70c775080e..dbf0eb83b4 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' +import { createEnvironmentSnapshot } from '@deepseek-ai/dsh-environment' import LlmService, { createUserMessage, CONTEXT_WINDOW_EXCEEDED_CODE, ProviderRequestId, @@ -23,9 +24,12 @@ afterEach(async () => { }) async function harness(baseURL: string, config: object = {}) { + // Configuration carries only the reference; the key comes from the + // environment, which is the whole credential plane without a mounted seam. + vi.stubEnv('DEEPSEEK_API_KEY', 'test-key') const ctx = new Context() await ctx.plugin(LlmService) - await ctx.plugin(LlmDeepSeek, { apiKey: 'test-key', baseURL, ...config }) + await ctx.plugin(LlmDeepSeek, { baseURL, ...config }) return ctx } @@ -565,7 +569,6 @@ describe('plugin registration and config', () => { const ctx = new Context() await ctx.plugin(LlmService) const fiber = await ctx.plugin(LlmDeepSeek, { - apiKey: 'k', baseURL: server.url, }) expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }]) @@ -584,7 +587,6 @@ describe('plugin registration and config', () => { const ctx = new Context() await ctx.plugin(LlmService) await ctx.plugin(LlmDeepSeek, { - apiKey: 'k', baseURL: 'http://127.0.0.1:1', retryPolicy: { mode: 'always', @@ -603,7 +605,7 @@ describe('plugin registration and config', () => { it('owns the deepseek provider and advertises the default models', async () => { const ctx = new Context() await ctx.plugin(LlmService) - await ctx.plugin(LlmDeepSeek, { apiKey: 'k', baseURL: 'http://127.0.0.1:1' }) + await ctx.plugin(LlmDeepSeek, { baseURL: 'http://127.0.0.1:1' }) expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }]) await expect(ctx.llm.listModels('deepseek-official')).resolves.toEqual([ { provider: 'deepseek-official', id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash' }, @@ -631,7 +633,6 @@ describe('plugin registration and config', () => { const ctx = new Context() await ctx.plugin(LlmService) await ctx.plugin(LlmDeepSeek, { - apiKey: 'k', baseURL: 'http://127.0.0.1:1', reasoningEffort: effort, }) @@ -652,7 +653,6 @@ describe('plugin registration and config', () => { const ctx = new Context() await ctx.plugin(LlmService) await ctx.plugin(LlmDeepSeek, { - apiKey: 'k', baseURL: 'http://127.0.0.1:1', thinking: 'disabled', reasoningEffort: 'off', @@ -672,7 +672,6 @@ describe('plugin registration and config', () => { const ctx = new Context() await ctx.plugin(LlmService) await expect(ctx.plugin(LlmDeepSeek, { - apiKey: 'k', baseURL: 'http://127.0.0.1:1', thinking: 'disabled', reasoningEffort, @@ -699,17 +698,10 @@ describe('plugin registration and config', () => { }) }) - it('normalizes a literal API key and treats whitespace as absent', () => { - expect(resolveAdapterOptions({ apiKey: ' key ' }).apiKey).toBe('key') - const whitespace = resolveAdapterOptions({ apiKey: ' \t ', apiKeyEnv: 'CUSTOM_API_KEY' }) - expect(whitespace.apiKey).toBeUndefined() - expect(whitespace.apiKeyEnv).toBe('CUSTOM_API_KEY') - }) - it('uses the default model catalog when apply is called directly', async () => { const ctx = new Context() await ctx.plugin(LlmService) - LlmDeepSeek.apply(ctx, { apiKey: 'k', baseURL: 'http://127.0.0.1:1' }) + LlmDeepSeek.apply(ctx, { baseURL: 'http://127.0.0.1:1' }) await expect(ctx.llm.listModels('deepseek-official')).resolves.toEqual([ { provider: 'deepseek-official', id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash' }, { provider: 'deepseek-official', id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro' }, @@ -720,7 +712,6 @@ describe('plugin registration and config', () => { const ctx = new Context() await ctx.plugin(LlmService) await ctx.plugin(LlmDeepSeek, { - apiKey: 'k', baseURL: 'http://127.0.0.1:1', models: [ { id: 'private-fast', contextWindow: 32_000 }, @@ -754,7 +745,6 @@ describe('plugin registration and config', () => { const ctx = new Context() await ctx.plugin(LlmService) await ctx.plugin(LlmDeepSeek, { - apiKey: 'k', baseURL: 'http://127.0.0.1:1', defaultContextWindow: 256_000, models: [ @@ -775,7 +765,6 @@ describe('plugin registration and config', () => { const ctx = new Context() await ctx.plugin(LlmService) await ctx.plugin(LlmDeepSeek, { - apiKey: 'k', baseURL: 'http://127.0.0.1:1', models: [], }) @@ -792,7 +781,6 @@ describe('plugin registration and config', () => { const ctx = new Context() await ctx.plugin(LlmService) await expect(ctx.plugin(LlmDeepSeek, { - apiKey: 'k', baseURL: 'http://127.0.0.1:1', models: [...models], })).rejects.toThrow(message) @@ -824,7 +812,6 @@ describe('plugin registration and config', () => { await ctx.plugin(LlmService) expect(() => { LlmDeepSeek.apply(ctx, { - apiKey: 'k', baseURL: 'http://127.0.0.1:1', models: [{ id: 'invalid-context', contextWindow: 0 }], }) @@ -841,7 +828,6 @@ describe('plugin registration and config', () => { const ctx = new Context() await ctx.plugin(LlmService) await expect(ctx.plugin(LlmDeepSeek, { - apiKey: 'k', baseURL: 'http://127.0.0.1:1', defaultContextWindow, })).rejects.toThrow(/defaultContextWindow/) @@ -858,7 +844,6 @@ describe('plugin registration and config', () => { const ctx = new Context() await ctx.plugin(LlmService) await expect(ctx.plugin(LlmDeepSeek, { - apiKey: 'k', baseURL: 'http://127.0.0.1:1', maxTokens, })).rejects.toThrow(/maxTokens/) @@ -886,13 +871,14 @@ describe('plugin registration and config', () => { await expect(ctx.llm.listModels('deepseek-official')).resolves.toHaveLength(2) const first = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) expect(first.finish).toMatchObject({ kind: 'error', failure: { code: 'MISSING_CREDENTIAL' } }) - // The guidance leads with the credential store — the path that keeps the - // secret out of configuration files — and mentions a literal key last. + // The guidance leads with the managed credential store. const second = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) expect(second.finish.kind).toBe('error') if (second.finish.kind !== 'error') throw new Error('expected an error finish') + // The guidance names both places a credential can come from, and nothing + // else: configuration carries the reference, never a literal key. expect(second.finish.failure.message) - .toMatch(/store DEEPSEEK_API_KEY through the credentials service.*as a last resort.*"apiKey"/s) + .toMatch(/store DEEPSEEK_API_KEY through the credentials service.*export DEEPSEEK_API_KEY/s) }) it('reads the ambient variable when no credentials seam is mounted', async () => { @@ -928,13 +914,33 @@ describe('plugin registration and config', () => { it('uses DEEPSEEK_BASE_URL when config omits baseURL', async () => { const server = await mockServer([{ kind: 'sse', events: textEvents }]) vi.stubEnv('DEEPSEEK_BASE_URL', server.url) + vi.stubEnv('DEEPSEEK_API_KEY', 'test-key') const ctx = new Context() await ctx.plugin(LlmService) - await ctx.plugin(LlmDeepSeek, { apiKey: 'k' }) + await ctx.plugin(LlmDeepSeek, {}) await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }) expect(server.requests).toHaveLength(1) }) + + it('takes DEEPSEEK_BASE_URL from any environment layer, with explicit config still on top', () => { + const trusted = createEnvironmentSnapshot([ + { source: 'user-env', path: '/home/.dsh/.env', values: { DEEPSEEK_BASE_URL: 'https://user.example' } }, + ]) + expect(resolveAdapterOptions({}, trusted).baseURL).toBe('https://user.example') + // The product trusts the project it is launched in, so a checkout can + // point its own agent at the gateway that checkout is meant to use. + const project = createEnvironmentSnapshot([ + { source: 'project-env', path: '/work/.env', values: { DEEPSEEK_BASE_URL: 'https://project.example' } }, + ]) + expect(resolveAdapterOptions({}, project).baseURL).toBe('https://project.example') + // An explicitly configured endpoint outranks every environment layer, so a + // stale shell value cannot rewrite a deployment's own gateway. + const shell = createEnvironmentSnapshot([ + { source: 'process', values: { DEEPSEEK_BASE_URL: 'https://stale.example' } }, + ]) + expect(resolveAdapterOptions({ baseURL: 'https://gateway.internal' }, shell).baseURL).toBe('https://gateway.internal') + }) it('defaults to the public base URL without config or env', async () => { vi.stubEnv('DEEPSEEK_API_KEY', 'k') vi.stubEnv('DEEPSEEK_BASE_URL', undefined) @@ -975,12 +981,10 @@ describe('plugin registration and config', () => { const ctx = new Context() await ctx.plugin(LlmService) await expect(ctx.plugin(LlmDeepSeek, { - apiKey: 'k', baseURL: 'http://127.0.0.1:1', streamIdleTimeoutMs: 0, })).rejects.toThrow(/streamIdleTimeoutMs/) await expect(ctx.plugin(LlmDeepSeek, { - apiKey: 'k', baseURL: 'http://127.0.0.1:1', streamIdleTimeoutMs: MAX_TIMER_DELAY_MS + 1, })).rejects.toThrow(/streamIdleTimeoutMs/) @@ -991,45 +995,9 @@ describe('plugin registration and config', () => { await ctx.plugin(LlmService) await expect(ctx.plugin(LlmDeepSeek, { - apiKey: 'k', baseURL: 'http://127.0.0.1:1', retryPolicy: { mode: 'normal', maxRetries: -1 }, })).rejects.toThrow(/retryPolicy/) expect(ctx.llm.listProviders()).toEqual([]) }) }) - -describe('API key format', () => { - it('trims a padded literal apiKey', () => { - expect(resolveAdapterOptions({ apiKey: ' sk-abc ' }).apiKey).toBe('sk-abc') - }) - - it('leaves an omitted apiKey absent so apiKeyEnv still resolves it', () => { - expect(resolveAdapterOptions({}).apiKey).toBeUndefined() - }) - - it('treats a whitespace-only literal apiKey as absent, not as a failure', () => { - // This adapter's absence has a defined fallback, so a blank literal - // resolves through apiKeyEnv like an omitted one. (llm-pi-ai refuses a - // blank one instead: there, absence selects provider-native or OAuth - // authentication rather than a different source for the same key.) - const resolved = resolveAdapterOptions({ apiKey: ' ', apiKeyEnv: 'CUSTOM_API_KEY' }) - expect(resolved.apiKey).toBeUndefined() - expect(resolved.apiKeyEnv).toBe('CUSTOM_API_KEY') - }) - - it('rejects a literal apiKey no header can carry', () => { - expect(() => resolveAdapterOptions({ apiKey: 'sk-\u{1F600}' })) - .toThrow(/no HTTP header can carry/) - }) - - it('never echoes the key in the rejection', () => { - const secret = 'sk-\u{1F600}supersecret' - expect(() => resolveAdapterOptions({ apiKey: secret })).toThrow() - try { - resolveAdapterOptions({ apiKey: secret }) - } catch (error) { - expect((error as Error).message).not.toContain('supersecret') - } - }) -}) diff --git a/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts b/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts index e593e3a61d..99e57d10c4 100644 --- a/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts +++ b/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts @@ -48,7 +48,7 @@ async function boot(dir: string, config: object): Promise { await ctx.plugin(LlmService) const settingsFiber = ctx.plugin(SettingsLocal, { path: join(dir, 'settings.yaml'), watch: false }) await settingsFiber - await ctx.plugin(CredentialsLocal, { path: join(dir, '.env'), watch: false }) + await ctx.plugin(CredentialsLocal, { path: join(dir, '.credentials.yaml'), watch: false }) await ctx.plugin(LlmDeepSeek, config) return { ctx, settingsFiber } } @@ -61,7 +61,7 @@ describe('request-level dynamic configuration', () => { it('routes the next request with the freshly resolved base URL and credential', async () => { vi.stubEnv('DEEPSEEK_API_KEY', '') const dir = await home() - await writeFile(join(dir, '.env'), 'DEEPSEEK_API_KEY=first-key\n') + await writeFile(join(dir, '.credentials.yaml'), 'DEEPSEEK_API_KEY: first-key\n', { mode: 0o600 }) const serverA = await mockServer([{ kind: 'sse', events: textEvents }]) const serverB = await mockServer([{ kind: 'sse', events: textEvents }]) const { ctx } = await boot(dir, { baseURL: serverA.url }) @@ -78,18 +78,6 @@ describe('request-level dynamic configuration', () => { expect(serverB.headers[0]?.authorization).toBe('Bearer second-key') }) - it('prefers a literal settings apiKey over the credential layers', async () => { - vi.stubEnv('DEEPSEEK_API_KEY', '') - const dir = await home() - await writeFile(join(dir, '.env'), 'DEEPSEEK_API_KEY=file-key\n') - const server = await mockServer([{ kind: 'sse', events: textEvents }]) - const { ctx } = await boot(dir, { baseURL: server.url }) - - await ctx.settings.update(NS, { apiKey: 'literal-key' }) - await prompt(ctx) - expect(server.headers[0]?.authorization).toBe('Bearer literal-key') - }) - it('starts keyless and serves the next request once the key arrives', async () => { vi.stubEnv('DEEPSEEK_API_KEY', '') const dir = await home() @@ -124,7 +112,7 @@ describe('request-level dynamic configuration', () => { it('advertises a live settings catalog without re-registration', async () => { const dir = await home() - const { ctx } = await boot(dir, { apiKey: 'k', baseURL: 'http://127.0.0.1:1' }) + const { ctx } = await boot(dir, { baseURL: 'http://127.0.0.1:1' }) await expect(ctx.llm.listModels('deepseek-official')).resolves.toHaveLength(2) await ctx.settings.update(NS, { models: [{ id: 'settings-model', name: 'From Settings' }] }) @@ -135,7 +123,7 @@ describe('request-level dynamic configuration', () => { it('re-registers the route in place when the captured retry policy changes, without an empty-registry window', async () => { const dir = await home() - const { ctx } = await boot(dir, { apiKey: 'k', baseURL: 'http://127.0.0.1:1' }) + const { ctx } = await boot(dir, { baseURL: 'http://127.0.0.1:1' }) // Observing the topology event, not just the end state: disposing and // re-registering also lands on the right final registry, but publishes an @@ -160,7 +148,7 @@ describe('request-level dynamic configuration', () => { it('keeps the last good options when a settings snapshot fails beyond-schema validation', async () => { const dir = await home() - const { ctx } = await boot(dir, { apiKey: 'k', baseURL: 'http://127.0.0.1:1' }) + const { ctx } = await boot(dir, { baseURL: 'http://127.0.0.1:1' }) // Schema-valid but resolver-invalid: duplicate catalog ids pass the array // schema and fail the explicit resolve step. @@ -172,17 +160,16 @@ describe('request-level dynamic configuration', () => { ]) }) - it('sends the whole last-good snapshot when a rejected one changed both the key and the URL', async () => { - vi.stubEnv('DEEPSEEK_API_KEY', '') + it('keeps the whole last-good snapshot when a rejected one changed the URL', async () => { const dir = await home() const good = await mockServer([{ kind: 'sse', events: textEvents }]) const rejected = await mockServer([{ kind: 'sse', events: textEvents }]) - const { ctx } = await boot(dir, { apiKey: 'good-key', baseURL: good.url }) + vi.stubEnv('DEEPSEEK_API_KEY', 'good-key') + const { ctx } = await boot(dir, { baseURL: good.url }) - // One snapshot moves the endpoint AND the literal key, and fails the - // resolve step beyond the schema (duplicate catalog ids). + // One snapshot moves the endpoint and fails the resolve step beyond the + // schema (duplicate catalog ids). await ctx.settings.update(NS, { - apiKey: 'rejected-key', baseURL: rejected.url, models: [{ id: 'dup' }, { id: 'dup' }], }) @@ -198,7 +185,7 @@ describe('request-level dynamic configuration', () => { it('falls back to the composition entry when settings detach', async () => { vi.stubEnv('DEEPSEEK_API_KEY', '') const dir = await home() - await writeFile(join(dir, '.env'), 'DEEPSEEK_API_KEY=steady-key\n') + await writeFile(join(dir, '.credentials.yaml'), 'DEEPSEEK_API_KEY: steady-key\n', { mode: 0o600 }) const serverA = await mockServer([{ kind: 'sse', events: textEvents }]) const serverB = await mockServer([{ kind: 'sse', events: textEvents }]) const { ctx, settingsFiber } = await boot(dir, { baseURL: serverA.url }) diff --git a/packages/llm/llm-deepseek/tests/loader-composition.spec.ts b/packages/llm/llm-deepseek/tests/loader-composition.spec.ts index 402f94441d..a7e1f433e5 100644 --- a/packages/llm/llm-deepseek/tests/loader-composition.spec.ts +++ b/packages/llm/llm-deepseek/tests/loader-composition.spec.ts @@ -2,7 +2,7 @@ * Real-composition guard for the dynamic-configuration chain: LlmService, * settings-local, credentials-local, and llm-deepseek boot from a test-only * cordis.yml through the actual Loader + Include path, external edits of - * settings.yaml and .env hot-publish through their providers, and the very + * settings.yaml and the credentials document hot-publish through their providers, and the very * next request carries the fresh base URL and credential. The same adapter * composition without settings or credentials entries keeps entry-config * behavior — the documented optional-inject fallback. @@ -42,16 +42,16 @@ afterEach(async () => { async function loadComposition( options: { withDynamic: boolean; baseURL: string; reuseRoot?: string }, -): Promise<{ ctx: Context; settingsPath: string; envPath: string }> { +): Promise<{ ctx: Context; settingsPath: string; credentialsPath: string }> { // A reused root is the restart case: the same harness home, its documents // exactly as the previous process left them. const fresh = options.reuseRoot === undefined root = options.reuseRoot ?? await mkdtemp(join(tmpdir(), 'dsh-llm-composition-')) const settingsPath = join(root, 'settings.yaml') - const envPath = join(root, '.env') + const credentialsPath = join(root, '.credentials.yaml') if (options.withDynamic && fresh) { await writeFile(settingsPath, '# personal settings\n') - await writeFile(envPath, 'DEEPSEEK_API_KEY=boot-key\n') + await writeFile(credentialsPath, 'DEEPSEEK_API_KEY: boot-key\n', { mode: 0o600 }) } const configPath = join(root, 'cordis.yml') @@ -68,7 +68,7 @@ async function loadComposition( '- id: credentials', " name: '@deepseek-ai/dsh-credentials-local'", ' config:', - ` path: ${JSON.stringify(envPath)}`, + ` path: ${JSON.stringify(credentialsPath)}`, ' debounceMs: 10', ] : [], @@ -76,7 +76,6 @@ async function loadComposition( " name: '@deepseek-ai/dsh-llm-deepseek'", ' config:', ` baseURL: ${JSON.stringify(options.baseURL)}`, - ...options.withDynamic ? [] : [' apiKey: entry-key'], '', ].join('\n')) @@ -103,15 +102,15 @@ async function loadComposition( config: { path: pathToFileURL(configPath).href }, }) await ctx.loader.await() - return { ctx, settingsPath, envPath } + return { ctx, settingsPath, credentialsPath } } describe('llm-deepseek real dynamic composition', () => { - it('boots from cordis.yml and routes the next request after external settings and .env edits', async () => { + it('boots from cordis.yml and routes the next request after external settings and credential edits', async () => { vi.stubEnv('DEEPSEEK_API_KEY', '') const serverA = await mockServer([{ kind: 'sse', events: textEvents }]) const serverB = await mockServer([{ kind: 'sse', events: textEvents }]) - const { ctx, settingsPath, envPath } = await loadComposition({ withDynamic: true, baseURL: serverA.url }) + const { ctx, settingsPath, credentialsPath } = await loadComposition({ withDynamic: true, baseURL: serverA.url }) expect(ctx.get('settings')!.describe().map(entry => entry.ns)).toEqual([NS]) await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) @@ -122,7 +121,7 @@ describe('llm-deepseek real dynamic composition', () => { await vi.waitFor(() => { expect((ctx.get('settings')!.get(NS) as { baseURL?: string }).baseURL).toBe(serverB.url) }, { timeout: 5000 }) - await writeFile(envPath, 'DEEPSEEK_API_KEY=rotated-key\n') + await writeFile(credentialsPath, 'DEEPSEEK_API_KEY: rotated-key\n', { mode: 0o600 }) await vi.waitFor(async () => { expect(await ctx.get('credentials')!.resolve(KEY_REF)).toEqual({ value: 'rotated-key', source: 'file' }) }, { timeout: 5000 }) @@ -134,7 +133,7 @@ describe('llm-deepseek real dynamic composition', () => { it('keeps a stored key writable and rotatable across a real restart', async () => { // No ambient DEEPSEEK_API_KEY: the shipped surfaces no longer hoist - // $DSH_HOME/.env into process.env, so a stored key must stay file-sourced. + // the credentials document into process.env, so a stored key must stay file-sourced. vi.stubEnv('DEEPSEEK_API_KEY', '') const first = await mockServer([{ kind: 'sse', events: textEvents }]) const second = await mockServer([{ kind: 'sse', events: textEvents }]) @@ -161,8 +160,10 @@ describe('llm-deepseek real dynamic composition', () => { expect(second.headers[0]?.authorization).toBe('Bearer rotated-after-restart') }) - it('boots the same adapter without settings or credentials entries on entry config alone', async () => { - vi.stubEnv('DEEPSEEK_API_KEY', '') + it('boots the same adapter on entry config alone, resolving the reference from the environment', async () => { + // No settings and no credentials provider: configuration carries only the + // reference, so the environment is the whole credential plane here. + vi.stubEnv('DEEPSEEK_API_KEY', 'entry-key') const server = await mockServer([{ kind: 'sse', events: textEvents }]) const { ctx } = await loadComposition({ withDynamic: false, baseURL: server.url }) diff --git a/packages/llm/llm-deepseek/tsconfig.json b/packages/llm/llm-deepseek/tsconfig.json index ee8a81e73b..0b524a257b 100644 --- a/packages/llm/llm-deepseek/tsconfig.json +++ b/packages/llm/llm-deepseek/tsconfig.json @@ -23,6 +23,9 @@ { "path": "../../credentials/credentials" }, + { + "path": "../../util/environment" + }, { "path": "../../settings/settings" }, diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index b57043a84d..5dbff068d0 100644 --- a/packages/llm/llm-pi-ai/README.i18n.yaml +++ b/packages/llm/llm-pi-ai/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/llm/llm-pi-ai/README.md -README.md: 97bd629adedda9d63fee730bc31129b0c22cc704 -README.zh.md: 71d45b590f48f4b8162ae329b58b5ff4a9eb13b1 +README.md: d0968a7366943a36ba428355058ad14169884c0a +README.zh.md: d4c401698071bd494b95fc993b42de8b3197edc4 diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index 97bd629ade..d0968a7366 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -8,7 +8,7 @@ The package root exposes the Cordis plugin contract, `PiAiAdapter`, and `support ## Config -Configure credentials, the model catalog, and deployment-specific transport settings per provider, keyed by the provider route itself. Prefer `apiKeyEnv` — a credential *reference* resolved per request — over a literal `apiKey`, so no secret enters this file. Omitting **both** is what leaves the route unauthenticated, which for an installed catalog route means pi-ai's provider-native ambient discovery; a configured reference that resolves to nothing fails the request with `MISSING_CREDENTIAL` instead, because falling through would authenticate with whatever unrelated key the environment happens to hold. One credential serves every model on its route. +Configure credentials, the model catalog, and deployment-specific transport settings per provider, keyed by the provider route itself. `apiKeyEnv` is a credential *reference* resolved per request, so no secret enters this file. Omitting it leaves the route unauthenticated, which for an installed catalog route means pi-ai's provider-native ambient discovery; a configured reference that resolves to nothing fails the request with `MISSING_CREDENTIAL` instead, because falling through would authenticate with whatever unrelated key the environment happens to hold. One credential serves every model on its route. ```yaml - id: llm @@ -67,7 +67,7 @@ Resolution still fails loud, naming the offending route and model, when a route The adapter reads its profiles through a thunk **once per operation** instead of freezing them at construction. The plugin registers the `llm-pi-ai` namespace on the optional `ctx.settings` seam with this same `Config` schema and its `cordis.yml` entry as the composition `base`, and because `providers` is a dict, the base and the user's `llm-pi-ai:` settings section merge **per provider**: a user can add a route, override one field of a composition route, or point a route at another proxy, all effective on the next request with no restart. Without a mounted settings service the entry config alone drives the adapter, unchanged. -Credentials resolve per stream call: a non-empty literal `apiKey` wins, then `apiKeyEnv` through the optional `ctx.credentials` seam (`$DSH_HOME/.env` under the live environment; exactly that variable without a mounted seam). A profile naming no credential at all — and only that case — defers to pi-ai's ambient discovery. Every key is trimmed and format-checked before use — a literal `apiKey` when profiles resolve (plugin load, or the next settings snapshot), a value `apiKeyEnv` resolves at request time — so a value no HTTP header can carry is refused there instead of surfacing as an opaque `fetch` `TypeError`; the request-time refusal throws `LlmError('INVALID_CREDENTIAL')` naming the failing route and credential reference but never any part of the key. The route set and each route's captured retry policy are the registration-level facts: when either changes, the plugin replaces its registration atomically (same adapter instance, candidate set validated first), so a route another adapter already owns leaves the previous routes serving and reverting to a working configuration re-applies. Provider key order never counts as a change. A section this adapter could not serve is refused where it is written — the registered `validate` resolves the whole profile set, so `ctx.settings.mutate` rejects with the resolver's own error (the wire surface reports it as `settings-rejected`) and nothing is stored. A stored section that becomes unserviceable some other way — an external edit of `settings.yaml` — keeps the namespace's last good value at the settings seam and warns. The entry config itself still fails plugin load, and a route the llm registry refuses (one another adapter family already owns) is logged while the previously registered routes keep serving. +Credentials resolve per stream call through `apiKeyEnv` and the optional `ctx.credentials` seam; without that seam, the adapter reads exactly the referenced environment variable. A profile naming no credential at all — and only that case — defers to pi-ai's ambient discovery. Every resolved key is trimmed and format-checked before use, so a value no HTTP header can carry is refused instead of surfacing as an opaque `fetch` `TypeError`; the refusal throws `LlmError('INVALID_CREDENTIAL')` naming the failing route and credential reference but never any part of the key. The route set and each route's captured retry policy are the registration-level facts: when either changes, the plugin replaces its registration atomically (same adapter instance, candidate set validated first), so a route another adapter already owns leaves the previous routes serving and reverting to a working configuration re-applies. Provider key order never counts as a change. A section this adapter could not serve is refused where it is written — the registered `validate` resolves the whole profile set, so `ctx.settings.mutate` rejects with the resolver's own error (the wire surface reports it as `settings-rejected`) and nothing is stored. A stored section that becomes unserviceable some other way — an external edit of `settings.yaml` — keeps the namespace's last good value at the settings seam and warns. The entry config itself still fails plugin load, and a route the llm registry refuses (one another adapter family already owns) is logged while the previously registered routes keep serving. The adapter exposes each configured route's models through `ctx.llm.listModels(provider)`. This is provider-neutral selector metadata read from the same pi-ai `Models` collection the request path uses, so discovery does not create a second model registry. `ctx.llm.resolveModelInfo(provider, model)` performs that exact descriptor lookup once and returns its identity, context window, configured output cap, and selectable thinking levels, keeping authoritative metadata on the route-owning adapter rather than its consumers. A model's **configured** `maxTokens` becomes the seam's `defaultMaxTokens`, so a request that names no output cap carries the one the deployment chose; a value inherited from the installed catalog is the model's output *capability* and never becomes a request default on its own. @@ -75,7 +75,7 @@ A model that carries reasoning metadata exposes pi-ai's ordered `getSupportedThi A model **without** that metadata — every hand-declared one, and a catalog model pi-ai marks as non-reasoning — exposes no `reasoning` at all. pi-ai reports such a model as supporting the single level `off`, but `off` is translated to *omitting* the reasoning option, which is byte-for-byte the request that naming no effort already produces: selecting it could not disable anything, so a provider whose own default is to think would keep thinking with `off` shown as selected. Reporting the capability as unavailable leaves a surface offering the provider's default and nothing that misrepresents it. The profile `reasoning` value, including `off`, is the deployment default when configured; omitting it preserves the provider default. Per-request `GenerateOptions.reasoningEffort` takes precedence, and a level absent from the exact model capability fails the REQUEST with `UNSUPPORTED_REASONING_EFFORT` before network I/O instead of being clamped. Describing a model never fails that way: the models under one provider disagree about which levels they accept, so `resolveModel` reports a profile level the exact model cannot take as no default at all rather than throwing. A throw there would take the whole provider out of every model catalog built over it — one mis-set profile field hiding even the models that do support the level — so a bad configuration surfaces where it is acted on, not where it is described. pi-ai's common stream options represent `off` by omitting `reasoning`. -Supported profile fields are `apiKey`, `apiKeyEnv`, `displayName`, `api`, `baseURL`, `models`, `defaultContextWindow`, `defaultMaxTokens`, `headers`, `reasoning`, `thinkingBudgets`, `cacheRetention`, `transport`, `timeoutMs`, `websocketConnectTimeoutMs`, `streamIdleTimeoutMs`, and `retryPolicy`. Each profile's optional retry policy is captured with that provider route; omission uses bounded normal defaults. The stream-idle interval is a positive finite Node timer delay, defaults to five minutes, and covers only an outstanding provider read, not consumer think time. Harness app attribution wins a conflicting configured header name. +Supported profile fields are `apiKeyEnv`, `displayName`, `api`, `baseURL`, `models`, `defaultContextWindow`, `defaultMaxTokens`, `headers`, `reasoning`, `thinkingBudgets`, `cacheRetention`, `transport`, `timeoutMs`, `websocketConnectTimeoutMs`, `streamIdleTimeoutMs`, and `retryPolicy`. Each profile's optional retry policy is captured with that provider route; omission uses bounded normal defaults. The stream-idle interval is a positive finite Node timer delay, defaults to five minutes, and covers only an outstanding provider read, not consumer think time. Harness app attribution wins a conflicting configured header name. The adapter forces pi-ai's SDK `maxRetries` to zero so one `stream()` call makes one provider request. The removed profile fields `maxRetries` and `maxRetryDelayMs` fail load instead of silently multiplying or hiding the separately composed agent-level retry budget. Idle expiry aborts the SDK's stable request signal and surfaces `TIMEOUT`; an earlier caller abort remains `ABORTED`. @@ -85,7 +85,7 @@ The plugin offers `ctx.llm.registerModelDiscovery('llm-pi-ai', …)`, which answ A request naming a route the **installed catalog ships is answered from that catalog**, with no network call: pi-ai's registry is the authoritative list for its own providers, and it carries the context windows and output caps a listing endpoint would not disclose. Such a route needs no `baseURL` at all. Only a route the catalog does not describe — a gateway, a self-hosted server — is interrogated over the wire, and one that names no endpoint is told to set one or enter its models by hand. -A draft carries the credential the user typed, if any; a route that already stored one shows a configuration surface only a redacted descriptor, so the interrogation supplies that route's own credential — resolved exactly as a request to it would, `apiKey` then `apiKeyEnv` — rather than going out unauthenticated and reporting the endpoint's 401 as a wrong key. A typed key wins, being the one under test. Resolution happens only on the path that reaches the network, so a catalog route answers without touching credentials at all. A supplied or stored probe key is trimmed and format-checked the same way, so a value no HTTP header can carry is refused immediately as `LlmError('INVALID_CREDENTIAL')` instead of reaching `fetch`, where it would surface as an opaque `ByteString` failure indistinguishable from an unreachable endpoint. +A draft carries the credential the user typed, if any; a route that already stored one shows a configuration surface only a redacted descriptor, so the interrogation resolves that route's `apiKeyEnv` rather than going out unauthenticated and reporting the endpoint's 401 as a wrong key. A typed key wins, being the one under test. Resolution happens only on the path that reaches the network, so a catalog route answers without touching credentials at all. A supplied or stored probe key is trimmed and format-checked the same way, so a value no HTTP header can carry is refused immediately as `LlmError('INVALID_CREDENTIAL')` instead of reaching `fetch`, where it would surface as an opaque `ByteString` failure indistinguishable from an unreachable endpoint. Interrogation reads `openai-completions` and `openai-responses`, whose `GET /models` shape with bearer auth is the one a gateway, a self-hosted server, and the official endpoints all agree on. Azure is excluded despite its OpenAI lineage — it authenticates with an `api-key` header and requires an `api-version` query — and Codex uses OAuth; every other protocol answers `DISCOVERY_UNSUPPORTED` so the surface falls back to hand-entry instead of an authentication failure being reported as a provider with no models. The `baseURL` is treated as a prefix rather than a URL to resolve against, so a deployment path such as `https://gateway.example/openai/v1` keeps its segments. @@ -155,7 +155,7 @@ Recorded response content appends to the next request and does not invalidate it - **`headers` can carry a credential the redactor never sees** — the profile's `headers` dict is plain strings, so `Authorization` or `api-key` set there is returned verbatim by a redacted `describe()` and rendered by any configuration UI. Store credentials as `apiKeyEnv` references; making the dict write-only is deferred with the rest of the [wire-boundary work](../llm/README.md#known-limitations-and-deferred-work). - **A route's catalog never refreshes itself** — the catalog is whatever `settings.yaml` says, so a model list is only as current as its last edit. Nothing here queries a provider for the models it serves; a route gains a model when someone writes one. - **One wire protocol per route** — `api` applies to the whole route, so a mixed-protocol catalog route (an OpenAI-style catalog spanning Responses and Chat Completions) cannot host a model of the other protocol, and adding a model such a route does not describe requires naming `api` and moving every model onto it. Splitting the provider across two route keys is the workaround. -- **An unauthenticated route depends on its protocol** — naming no credential resolves the route as configured-but-keyless, but pi-ai's OpenAI-compatible implementation still requires an API key or an `Authorization` header, so a keyless local server needs a placeholder `apiKey` or an `Authorization` entry in `headers`. +- **An unauthenticated route depends on its protocol** — naming no credential resolves the route as configured-but-keyless, but pi-ai's OpenAI-compatible implementation still requires an API key or an `Authorization` header, so a keyless local server needs a placeholder credential referenced by `apiKeyEnv` or an `Authorization` entry in `headers`. - **`GenerateOptions.stop` is unsupported** — pi-ai's common stream options cannot guarantee stop-sequence behavior across providers, so the adapter rejects the field. - **In-history `system` messages use pi-ai's common context conversion** — provider-specific placement follows pi-ai rather than a harness-owned wire override. - **Provider HTTP status is unavailable** — pi-ai error events do not expose a stable HTTP status across providers; failures expose only stable harness error codes. diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index 71d45b590f..d4c4016980 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -8,7 +8,7 @@ ## 配置 -按提供方配置凭据、模型 catalog 与部署特定传输设置,并以提供方路由本身为键。优先使用 `apiKeyEnv`——按请求解析的凭据*引用*——而非字面 `apiKey`,让机密不进入该文件。**两者**都省略,才会让该路由处于未认证状态;对已安装 catalog 路由而言,这意味着交给 pi-ai 的提供方原生环境发现。已配置却解析不出任何值的引用则相反,会让请求以 `MISSING_CREDENTIAL` 失败,因为放行下去就会用环境里恰好持有的某个无关密钥完成认证。一条凭据服务该路由下的全部模型。 +按提供方配置凭据、模型 catalog 与部署特定传输设置,并以提供方路由本身为键。`apiKeyEnv` 是按请求解析的凭据*引用*,因此机密不进入该文件。省略它会让该路由处于未认证状态;对已安装 catalog 路由而言,这意味着交给 pi-ai 的提供方原生环境发现。已配置却解析不出任何值的引用则相反,会让请求以 `MISSING_CREDENTIAL` 失败,因为放行下去就会用环境里恰好持有的某个无关密钥完成认证。一条凭据服务该路由下的全部模型。 ```yaml - id: llm @@ -67,7 +67,7 @@ profile 的 `models` 列表是*替换*该路由已安装 catalog,而不是扩 适配器经由一个 thunk **每操作读取一次** profile,而非在构造期冻结。插件在可选的 `ctx.settings` seam 上用同一份 `Config` schema 注册 `llm-pi-ai` namespace,并以其 `cordis.yml` 条目为组合 `base`;由于 `providers` 是字典,base 与用户的 `llm-pi-ai:` settings 分节**按提供方**合并:用户可以新增路由、覆盖组合路由的单个字段,或把路由指向另一个 proxy,全部在下一次请求生效,无需重启。未挂载 settings 服务时,仅由 entry 配置驱动适配器,行为不变。 -凭据按每次 stream 调用解析:非空的字面 `apiKey` 优先,其次经可选的 `ctx.credentials` seam 解析 `apiKeyEnv`(活跃环境之下的 `$DSH_HOME/.env`;未挂载 seam 时恰好读取该环境变量)。只有完全没有点名任何凭据的 profile——仅限这一种情况——才交给 pi-ai 的环境发现。每个密钥在使用前都会被去除首尾空白并校验格式——字面 `apiKey` 在 profile 解析时(插件加载,或下一次 settings 快照)校验,`apiKeyEnv` 解析出的值则在请求时校验——因此 HTTP 标头无法承载的值会在这一步被拒绝,而不是以语义不明的 `fetch` `TypeError` 形式浮现;请求时的拒绝会抛出 `LlmError('INVALID_CREDENTIAL')`,点名失败的路由与凭据引用,但绝不透露密钥的任何部分。路由集合与每条路由捕获的重试策略是注册级事实:两者任一变化时,插件都会原子地替换自己的注册(同一适配器实例,候选集合先经校验),因此某条路由若已被另一适配器占有,先前的路由会继续服务,而改回可用配置时注册会重新生效。提供方键的顺序绝不算作变化。本适配器无法服务的分节会在写入处被拒——注册的 `validate` 会解析整份 profile 集合,因此 `ctx.settings.mutate` 以 resolver 自身的错误拒绝(协议面将其报为 `settings-rejected`),什么都不会存储。已存储分节若因其他途径变得不可服务——比如外部编辑了 `settings.yaml`——则由 settings seam 保留该 namespace 最后可用的值并告警。entry 配置本身仍会使插件加载失败;而 llm 注册表拒绝的路由(已被另一适配器族占有的那种)会被记录下来,先前注册的路由继续服务。 +凭据在每次 stream 调用时通过 `apiKeyEnv` 与可选的 `ctx.credentials` seam 解析;未挂载该 seam 时,适配器只读取该引用指向的环境变量。只有完全没有点名任何凭据的 profile——仅限这一种情况——才交给 pi-ai 的环境发现。每个解析出的密钥都会在使用前去除首尾空白并校验格式,因此 HTTP 标头无法承载的值会被拒绝,而不是以语义不明的 `fetch` `TypeError` 形式浮现;这种拒绝会抛出 `LlmError('INVALID_CREDENTIAL')`,点名失败的路由与凭据引用,但绝不透露密钥的任何部分。路由集合与每条路由捕获的重试策略是注册级事实:两者任一变化时,插件都会原子地替换自己的注册(同一适配器实例,候选集合先经校验),因此某条路由若已被另一适配器占有,先前的路由会继续服务,而改回可用配置时注册会重新生效。提供方键的顺序绝不算作变化。本适配器无法服务的分节会在写入处被拒——注册的 `validate` 会解析整份 profile 集合,因此 `ctx.settings.mutate` 以 resolver 自身的错误拒绝(协议面将其报为 `settings-rejected`),什么都不会存储。已存储分节若因其他途径变得不可服务——比如外部编辑了 `settings.yaml`——则由 settings seam 保留该 namespace 最后可用的值并告警。entry 配置本身仍会使插件加载失败;而 llm 注册表拒绝的路由(已被另一适配器族占有的那种)会被记录下来,先前注册的路由继续服务。 适配器通过 `ctx.llm.listModels(provider)` 公开每条已配置路由的模型。这是从请求路径所用的同一个 pi-ai `Models` 集合读取的提供方无关 selector 元数据,因此发现不会创建第二个模型注册表。`ctx.llm.resolveModelInfo(provider, model)` 会执行一次精确 descriptor 查找,并返回其身份、上下文窗口、已配置输出上限和可选思考级别,让权威元数据保留在拥有路由的适配器上,而非消费方。模型**已配置**的 `maxTokens` 会成为 seam 的 `defaultMaxTokens`,因此未点名输出上限的请求会携带部署选定的那一个;而从已安装 catalog 继承来的值是模型的输出**能力**,绝不会自行变成请求默认值。 @@ -75,7 +75,7 @@ profile 的 `models` 列表是*替换*该路由已安装 catalog,而不是扩 **没有**这份元数据的模型——每一个手工声明的模型,以及 pi-ai 标记为不具备推理能力的 catalog 模型——完全不公开 `reasoning`。pi-ai 会把这类模型报告为只支持 `off` 一档,但 `off` 会被翻译成*省略* reasoning 选项,而那与「不点名任何档位」产出的请求逐字节相同:选它关不掉任何东西,于是自身默认就在思考的提供方,会在界面显示 `off` 被选中的同时继续思考。把该能力报告为不可用,界面就只剩提供方默认这一项,不会再出现自相矛盾的控件。配置 profile 的 `reasoning` 值(包括 `off`)在存在时是部署默认值;省略它会保留提供方默认值。每次请求的 `GenerateOptions.reasoningEffort` 优先;未出现在确切模型能力中的档位会让**请求**在网络 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败,而不会被自动调整。**描述**一个模型则从不这样失败:同一提供方下各模型接受的档位并不一致,因此 `resolveModel` 对该模型拿不下的 profile 档位报告为「没有默认值」,而不是抛错。在那里抛错会让整个提供方从任何基于它构建的模型目录中消失——一个配错的 profile 字段连支持该档位的模型也一并藏起来——所以坏配置暴露在被执行处,而不是被描述处。pi-ai 的通用流选项通过省略 `reasoning` 表示 `off`。 -受支持的 profile 字段是 `apiKey`、`apiKeyEnv`、`displayName`、`api`、`baseURL`、`models`、`defaultContextWindow`、`defaultMaxTokens`、`headers`、`reasoning`、`thinkingBudgets`、`cacheRetention`、`transport`、`timeoutMs`、`websocketConnectTimeoutMs`、`streamIdleTimeoutMs` 和 `retryPolicy`。每个 profile 的可选重试策略都会与该提供方路由一同捕获;省略时使用有界的常规默认值。流空闲间隔必须是正的有限 Node 定时器延迟,默认为五分钟,且只覆盖未完成提供方读取,不包括消费方思考时间。若已配置标头中有同名项,则以 Harness 应用归因为准。 +受支持的 profile 字段是 `apiKeyEnv`、`displayName`、`api`、`baseURL`、`models`、`defaultContextWindow`、`defaultMaxTokens`、`headers`、`reasoning`、`thinkingBudgets`、`cacheRetention`、`transport`、`timeoutMs`、`websocketConnectTimeoutMs`、`streamIdleTimeoutMs` 和 `retryPolicy`。每个 profile 的可选重试策略都会与该提供方路由一同捕获;省略时使用有界的常规默认值。流空闲间隔必须是正的有限 Node 定时器延迟,默认为五分钟,且只覆盖未完成提供方读取,不包括消费方思考时间。若已配置标头中有同名项,则以 Harness 应用归因为准。 适配器强制 pi-ai SDK `maxRetries` 为零,因此一次 `stream()` 调用只会发起一次提供方请求。已移除 profile 字段 `maxRetries` 和 `maxRetryDelayMs` 会使加载失败,而不是静默倍增或隐藏单独组合的 agent(智能体)级重试预算。空闲超时会 abort SDK 的稳定请求信号,并以 `TIMEOUT` 呈现;较早的调用方 abort 仍为 `ABORTED`。 @@ -85,7 +85,7 @@ profile 的 `models` 列表是*替换*该路由已安装 catalog,而不是扩 点名了**已安装 catalog 所提供路由**的请求,直接由该 catalog 作答,完全不联网:pi-ai 的注册表才是它自家提供方的权威列表,且携带列表端点不会公布的上下文窗口与输出上限。这类路由根本不需要 `baseURL`。只有 catalog 未描述的路由——网关、自建服务——才会经协议层询问;若它也没给端点,则会被告知去设置一个或手工填写模型。 -草稿携带的是用户当下键入的凭据(如果有);已经存好凭据的路由,在配置界面上只呈现一个脱敏描述符,因此询问会自行取用该路由的凭据——解析方式与向它发请求时完全一致,先 `apiKey` 后 `apiKeyEnv`——而不是不带认证发出去、再把端点的 401 报成密钥不对。键入的密钥优先,因为那正是被测试的那一把。解析只发生在真正要联网的路径上,因此 catalog 路由作答时完全不会触碰凭据。用户提供或已存储的探测密钥也会经过同样的去除空白与格式校验:HTTP 标头无法承载的值会被立即以 `LlmError('INVALID_CREDENTIAL')` 拒绝,而不会传到 `fetch`——否则会呈现为一个和端点不可达难以区分的、语义不明的 `ByteString` 失败。 +草稿携带的是用户当下键入的凭据(如果有);已经存好凭据的路由,在配置界面上只呈现一个脱敏描述符,因此询问会解析该路由的 `apiKeyEnv`,而不是不带认证发出去、再把端点的 401 报成密钥不对。键入的密钥优先,因为那正是被测试的那一把。解析只发生在真正要联网的路径上,因此 catalog 路由作答时完全不会触碰凭据。用户提供或已存储的探测密钥也会经过同样的去除空白与格式校验:HTTP 标头无法承载的值会被立即以 `LlmError('INVALID_CREDENTIAL')` 拒绝,而不会传到 `fetch`——否则会呈现为一个和端点不可达难以区分的、语义不明的 `ByteString` 失败。 询问只读 `openai-completions` 与 `openai-responses`,它们「`GET /models` + bearer 认证」的形状是网关、自建服务与官方端点三方一致认可的那一种。Azure 尽管出身 OpenAI 也被排除——它用 `api-key` 标头认证并要求 `api-version` 查询参数——Codex 则走 OAuth;其余协议一律以 `DISCOVERY_UNSUPPORTED` 回答,让界面回退到手工填写,而不是把认证失败报成一个没有模型的提供方。`baseURL` 按前缀而非待解析 URL 处理,因此 `https://gateway.example/openai/v1` 这类部署路径会保留其路径段。 @@ -155,7 +155,7 @@ pi-ai 事件会变为 harness 推理、文本、工具调用、usage 与 finish - **`headers` 可能承载一条脱敏器看不见的凭据**:profile 的 `headers` 是纯字符串字典,因此设在其中的 `Authorization` 或 `api-key` 会被脱敏后的 `describe()` 原样返回,并被任何配置 UI 渲染出来。请把凭据存为 `apiKeyEnv` 引用;把该字典整体改为只写与其余[协议边界工作](../llm/README.md#known-limitations-and-deferred-work)一并暂缓。 - **路由的 catalog 不会自我刷新**:catalog 就是 `settings.yaml` 所写的内容,因此模型列表的新鲜度只到最近一次编辑为止。这里没有任何环节会去问提供方它服务哪些模型;路由要多一个模型,得有人写进去。 - **每条路由只有一种协议格式**:`api` 作用于整条路由,因此混合协议的 catalog 路由(跨 Responses 与 Chat Completions 的 OpenAI 式 catalog)无法承载另一种协议的模型,向这类路由添加它未描述的模型必须点名 `api` 并把全部模型一起迁过去。把该提供方拆成两个路由键是变通办法。 -- **未认证路由取决于其协议**:不点名凭据会让路由解析为「已配置但无密钥」,但 pi-ai 的 OpenAI 兼容实现仍要求 API key 或 `Authorization` 标头,因此无鉴权的本地服务需要一个占位 `apiKey`,或在 `headers` 中给出 `Authorization` 条目。 +- **未认证路由取决于其协议**:不点名凭据会让路由解析为「已配置但无密钥」,但 pi-ai 的 OpenAI 兼容实现仍要求 API key 或 `Authorization` 标头,因此无鉴权的本地服务需要一个由 `apiKeyEnv` 引用的占位凭据,或在 `headers` 中给出 `Authorization` 条目。 - **不支持 `GenerateOptions.stop`**:pi-ai 的通用流选项无法保证所有提供方都支持 stop sequence,因此适配器会拒绝该字段。 - **历史中的 `system` 消息使用 pi-ai 通用上下文转换**:提供方特定位置由 pi-ai 决定,而非由 harness 拥有的协议覆盖决定。 - **无法获取提供方 HTTP 状态**:pi-ai 错误事件不会在所有提供方上公开稳定 HTTP 状态;失败只公开稳定 harness 错误 code。 diff --git a/packages/llm/llm-pi-ai/package.json b/packages/llm/llm-pi-ai/package.json index cae6c07b7c..cbe385b7dc 100644 --- a/packages/llm/llm-pi-ai/package.json +++ b/packages/llm/llm-pi-ai/package.json @@ -26,6 +26,7 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-credentials": "^0.0.1", + "@deepseek-ai/dsh-environment": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-settings": "^0.0.1", @@ -38,6 +39,7 @@ }, "devDependencies": { "@deepseek-ai/dsh-credentials": "workspace:^", + "@deepseek-ai/dsh-environment": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", diff --git a/packages/llm/llm-pi-ai/src/config.ts b/packages/llm/llm-pi-ai/src/config.ts index 7e8374ab9f..266dd94511 100644 --- a/packages/llm/llm-pi-ai/src/config.ts +++ b/packages/llm/llm-pi-ai/src/config.ts @@ -19,7 +19,7 @@ import z from 'schemastery' import { credentialRef } from '@deepseek-ai/dsh-credentials' import type { CredentialRef } from '@deepseek-ai/dsh-credentials' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' -import { normalizeApiKey, resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/dsh-llm' +import { resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/dsh-llm' import type { ResolvedRetryPolicy, RetryPolicyConfig } from '@deepseek-ai/dsh-llm' import { resolveRouteModels } from './catalog.ts' import type { PiAiModelProfile } from './catalog.ts' @@ -38,12 +38,6 @@ export type { PiAiModelProfile } from './catalog.ts' /** Configuration for one pi-ai provider route; the `providers` dict key IS the route. */ export interface PiAiProviderProfile { - /** - * Literal provider credential; prefer {@link apiKeyEnv}. With both absent pi-ai uses its - * provider-native ambient discovery. Trimmed and format-checked by {@link resolveProfiles}; a - * value no HTTP header can carry fails there rather than inside `fetch`. - */ - apiKey?: string /** Credential reference (environment-variable name) resolved per request through `ctx.credentials`. */ apiKeyEnv?: string /** Name shown by configuration surfaces; defaults to the route key. */ @@ -147,7 +141,6 @@ const modelProfile: z = z.object({ }) const profile = z.object({ - apiKey: z.string().role('secret'), apiKeyEnv: z.string().role('credential-ref'), displayName: z.string(), api: z.union(supportedProtocols()), @@ -224,19 +217,6 @@ export function resolveProfiles( for (const [provider, source] of entries) { rejectRemovedFields(provider, source) if (provider.length === 0) throw new Error('llm-pi-ai: provider names must be non-empty') - // Omission selects the installed provider's own auth — ambient discovery - // or OAuth — so only a supplied key is judged. - let apiKey: string | undefined - if (source.apiKey !== undefined) { - const checked = normalizeApiKey(source.apiKey) - if (!checked.ok) { - throw new Error(checked.reason === 'empty' - ? `llm-pi-ai: provider "${provider}" has an empty apiKey; omit it to use ambient authentication` - : `llm-pi-ai: provider "${provider}" has an apiKey containing characters no HTTP header can carry;` - + ' paste the raw key only') - } - apiKey = checked.value - } if (source.baseURL !== undefined && source.baseURL.length === 0) { throw new Error(`llm-pi-ai: provider "${provider}" has an empty baseURL`) } @@ -266,7 +246,6 @@ export function resolveProfiles( const { apiKeyEnv, retryPolicy, models: _models, displayName: _displayName, ...rest } = source resolved.set(provider, { ...rest, - ...apiKey === undefined ? {} : { apiKey }, provider, displayName, ...apiKeyEnv === undefined ? {} : { apiKeyEnv: credentialRef(apiKeyEnv) }, @@ -281,7 +260,7 @@ export function resolveProfiles( ...source.api === undefined ? {} : { api: source.api }, ...source.baseURL === undefined ? {} : { baseURL: source.baseURL }, models: catalog.models, - namesCredential: source.apiKey !== undefined || apiKeyEnv !== undefined, + namesCredential: apiKeyEnv !== undefined, }), }) } diff --git a/packages/llm/llm-pi-ai/src/index.ts b/packages/llm/llm-pi-ai/src/index.ts index 2f98d7ac70..e15e1e1c2e 100644 --- a/packages/llm/llm-pi-ai/src/index.ts +++ b/packages/llm/llm-pi-ai/src/index.ts @@ -43,6 +43,7 @@ */ import type { Context } from 'cordis' +import { environmentOf } from '@deepseek-ai/dsh-environment' import { assertUsableApiKey, LlmError } from '@deepseek-ai/dsh-llm' import type { AdapterRegistrationHandle, DirectoryRegistrationHandle, LlmConfigurableProvider } from '@deepseek-ai/dsh-llm' import { deepEqualJson, installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings' @@ -141,7 +142,6 @@ export function apply(ctx: Context, config: Config): void { provider: string, profile: ResolvedPiAiProviderProfile, ): Promise => { - if (profile.apiKey !== undefined) return profile.apiKey const ref = profile.apiKeyEnv // Only a profile that names no credential at all defers to pi-ai's // provider-native discovery. Once one is named, a miss must fail loud: @@ -152,9 +152,8 @@ export function apply(ctx: Context, config: Config): void { const credentials = ctx.get('credentials') const hit = credentials !== undefined ? (await credentials.resolve(ref))?.value - // Without the seam, read exactly the named variable so a plain - // cordis.yml composition works from the environment alone. - : process.env[ref] + // Without the seam the environment is the whole credential plane. + : environmentOf(ctx).get(ref)?.value if (hit !== undefined && hit.length > 0) return assertUsableApiKey(hit, 'llm-pi-ai', ref) throw new LlmError( `llm-pi-ai: no credential for provider route "${provider}"; its profile resolves ${ref}, which is not` diff --git a/packages/llm/llm-pi-ai/src/provider.ts b/packages/llm/llm-pi-ai/src/provider.ts index 893199d8fa..b4331b26ef 100644 --- a/packages/llm/llm-pi-ai/src/provider.ts +++ b/packages/llm/llm-pi-ai/src/provider.ts @@ -97,10 +97,11 @@ export interface ProviderSpec { /** The route's materialized models, in configuration order. */ models: readonly Model[] /** - * Whether the profile names a credential — a literal key or a reference. - * Only that decides whether {@link routeAuth} adds the harness's own api-key - * method to a catalog provider that offers none; the key itself still arrives - * per request, never at construction. + * Whether the profile names a credential, which it does through `apiKeyEnv` + * alone: configuration carries the reference, never the secret. Only that + * decides whether {@link routeAuth} adds the harness's own api-key method to + * a catalog provider that offers none; the key itself still arrives per + * request, never at construction. */ namesCredential: boolean } diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index 0184ca05cc..aaca7f8bdf 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import LlmService, { createUserMessage, CONTEXT_WINDOW_EXCEEDED_CODE, LlmError, ReasoningEffortId, userAgent } from '@deepseek-ai/dsh-llm' import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai' @@ -15,22 +15,32 @@ afterEach(async () => { }) async function harness(baseURL: string, overrides: Record = {}): Promise { + vi.stubEnv('PI_TEST_KEY', 'test-key') const ctx = new Context() await ctx.plugin(LlmService) await ctx.plugin(LlmPiAi, { - providers: { deepseek: { apiKey: 'test-key', baseURL, ...overrides } }, + providers: { deepseek: { apiKeyEnv: 'PI_TEST_KEY', baseURL, ...overrides } }, }) return ctx } -/** Direct adapter over the real profile resolver, with literal-key resolution. */ -function adapterOf(providers: Record): PiAiAdapter { +/** Direct adapter over the real profile resolver, with a fixed key per call. */ +function adapterOf( + providers: Record, + apiKey: string | undefined = 'test-key', +): PiAiAdapter { return new PiAiAdapter({ profiles: () => resolveProfiles(providers), - resolveApiKey: (_provider, profile) => Promise.resolve(profile.apiKey), + resolveApiKey: () => Promise.resolve(apiKey), }) } +beforeEach(() => { + // Configuration carries only the reference; these mounts resolve it from + // the environment, which is the whole credential plane without a seam. + vi.stubEnv('PI_TEST_KEY', 'test-key') +}) + describe('PiAiAdapter provider routing', () => { it('resolves a catalog model dynamically and uses a private endpoint', async () => { const server = await mockServer([{ events: textEvents }]) @@ -121,7 +131,7 @@ describe('PiAiAdapter provider routing', () => { const ctx = new Context() await ctx.plugin(LlmService) ctx.llm.registerAdapter(['deepseek'], adapterOf({ - deepseek: { apiKey: 'test-key', baseURL: server.url }, + deepseek: { apiKeyEnv: 'PI_TEST_KEY', baseURL: server.url }, })) const result = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) @@ -131,7 +141,6 @@ describe('PiAiAdapter provider routing', () => { it('names a route by its displayName, and by its own key once the profiles drop it', () => { const adapter = adapterOf({ 'acme-gateway': { - apiKey: 'k', displayName: 'Acme Gateway', api: 'openai-completions', baseURL: 'https://acme.test/v1', @@ -167,7 +176,7 @@ describe('PiAiAdapter provider routing', () => { const ctx = new Context() await ctx.plugin(LlmService) await ctx.plugin(LlmPiAi, { - providers: { openai: { apiKey: 'test-key', baseURL: `${server.url}/v1` } }, + providers: { openai: { apiKeyEnv: 'PI_TEST_KEY', baseURL: `${server.url}/v1` } }, }) const result = await assemble(ctx, { provider: 'openai', model: 'gpt-4.1', messages: [] }) expect(result.finish.kind).toBe('error') @@ -187,7 +196,7 @@ describe('PiAiAdapter provider routing', () => { const ctx = new Context() await ctx.plugin(LlmService) await ctx.plugin(LlmPiAi, { - providers: { openai: { apiKey: 'test-key', baseURL: `${server.url}/v1` } }, + providers: { openai: { apiKeyEnv: 'PI_TEST_KEY', baseURL: `${server.url}/v1` } }, }) const result = await assemble(ctx, { provider: 'openai', model: 'gpt-4.1', messages: [] }) @@ -203,7 +212,7 @@ describe('PiAiAdapter provider routing', () => { await ctx.plugin(LlmPiAi, { providers: { openai: { - apiKey: 'test-key', + apiKeyEnv: 'PI_TEST_KEY', baseURL: `${server.url}/api/projects/openai/openai/v1`, headers: { 'api-key': 'test-key', Authorization: '' }, }, @@ -403,7 +412,9 @@ describe('provider profile lifecycle', () => { it('accepts absent credentials for pi-ai ambient authentication', async () => { vi.stubEnv('DEEPSEEK_API_KEY', 'ambient-key') const server = await mockServer([{ events: textEvents }]) - const ctx = await harness(server.url, { apiKey: undefined }) + // A profile that names no reference at all is the one case that defers to + // pi-ai's own provider-native discovery. + const ctx = await harness(server.url, { apiKeyEnv: undefined }) await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) expect(server.headers[0]?.authorization).toBe('Bearer ambient-key') }) @@ -445,8 +456,6 @@ describe('provider profile lifecycle', () => { // loud with migration directions instead of half-working. expect(() => resolveProfiles([{ provider: 'openai' }] as never)).toThrow(/dict keyed by provider/) expect(() => resolveProfiles({ openai: { provider: 'openai' } as never })).toThrow(/moved to the providers dict key/) - expect(() => resolveProfiles({ openai: { apiKey: '' } })).toThrow(/empty apiKey/) - expect(() => resolveProfiles({ openai: { apiKey: ' ' } })).toThrow(/empty apiKey/) expect(() => resolveProfiles({ openai: { baseURL: '' } })).toThrow(/empty baseURL/) expect(() => resolveProfiles({ openai: { apiKeyEnv: 'not-a-var!' } })).toThrow(/must match/) }) @@ -521,7 +530,7 @@ describe('abort wiring', () => { const message = Object.defineProperty({}, 'role', { get() { throw original }, }) - const adapter = adapterOf({ deepseek: { apiKey: 'test-key' } }) + const adapter = adapterOf({ deepseek: {} }) const drain = async (): Promise => { for await (const _chunk of adapter.stream({ provider: 'deepseek', @@ -542,7 +551,7 @@ describe('abort wiring', () => { throw original }, }) - const adapter = adapterOf({ deepseek: { apiKey: 'test-key' } }) + const adapter = adapterOf({ deepseek: {} }) const drain = async (): Promise => { for await (const _chunk of adapter.stream({ provider: 'deepseek', @@ -556,7 +565,7 @@ describe('abort wiring', () => { }) it('resolves catalog endpoints without an override before honoring pre-abort', async () => { - const adapter = adapterOf({ deepseek: { apiKey: 'test-key' } }) + const adapter = adapterOf({ deepseek: {} }) const controller = new AbortController() controller.abort('already stopped') const chunks = [] diff --git a/packages/llm/llm-pi-ai/tests/catalog.spec.ts b/packages/llm/llm-pi-ai/tests/catalog.spec.ts index 2afbb87ec0..b83c94be28 100644 --- a/packages/llm/llm-pi-ai/tests/catalog.spec.ts +++ b/packages/llm/llm-pi-ai/tests/catalog.spec.ts @@ -1,7 +1,7 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import LlmService, { createUserMessage } from '@deepseek-ai/dsh-llm' import type { StreamChunk } from '@deepseek-ai/dsh-llm' @@ -19,7 +19,17 @@ import { closeMockServers, mockServer, textEvents } from './mock-server.ts' const homes: string[] = [] +// Routes name their credential by reference; the value lives in the +// environment, which is the layer the adapter falls back to without a +// mounted credentials seam. +const KEY_ENV = 'PI_TEST_KEY' + +beforeEach(() => { + vi.stubEnv(KEY_ENV, 'test-key') +}) + afterEach(async () => { + vi.unstubAllEnvs() await closeMockServers() await Promise.all(homes.splice(0).map(dir => rm(dir, { recursive: true, force: true }))) }) @@ -46,7 +56,7 @@ function gateway(baseURL: string, overrides: Record = {}): LlmP return { providers: { 'acme-gateway': { - apiKey: 'gw-key', + apiKeyEnv: KEY_ENV, displayName: 'Acme Gateway', api: 'openai-completions', baseURL, @@ -81,7 +91,8 @@ describe('hand-declared providers', () => { expect(result.message.content).toEqual([{ type: 'text', text: 'hello' }]) expect(result.finish).toEqual({ kind: 'stop' }) expect(server.paths).toEqual(['/v1/chat/completions']) - expect(server.headers[0]?.authorization).toBe('Bearer gw-key') + // The reference resolved through the environment and reached the wire. + expect(server.headers[0]?.authorization).toBe('Bearer test-key') }) it('lists and resolves the declared models rather than a catalog', async () => { @@ -114,7 +125,7 @@ describe('hand-declared providers', () => { // A catalog route is unaffected: its models carry the metadata that makes // `off` actually disable thinking. - const withCatalog = await harness({ providers: { deepseek: { apiKey: 'k', baseURL: server.url } } }) + const withCatalog = await harness({ providers: { deepseek: { baseURL: server.url } } }) const [catalogModel] = getBuiltinModels('deepseek') if (catalogModel === undefined) throw new Error('the installed catalog ships no deepseek model') expect((await withCatalog.llm.resolveModelInfo('deepseek', catalogModel.id)).reasoning?.efforts.map(e => e.id)) @@ -288,7 +299,7 @@ describe('hand-declared providers', () => { describe('catalog routes with per-model configuration', () => { it('serves the installed catalog untouched when the profile lists no models', async () => { const server = await mockServer([]) - const ctx = await harness({ providers: { deepseek: { apiKey: 'k', baseURL: server.url } } }) + const ctx = await harness({ providers: { deepseek: { baseURL: server.url } } }) const listed = await ctx.llm.listModels('deepseek') expect(listed.map(model => model.id).sort()) @@ -302,7 +313,6 @@ describe('catalog routes with per-model configuration', () => { const ctx = await harness({ providers: { deepseek: { - apiKey: 'k', baseURL: server.url, models: [{ id: catalogModel.id, contextWindow: 4096 }], }, @@ -327,7 +337,6 @@ describe('catalog routes with per-model configuration', () => { const ctx = await harness({ providers: { deepseek: { - apiKey: 'k', baseURL: server.url, models: [{ id: catalogModel.id, maxTokens: 4096 }], }, @@ -344,7 +353,7 @@ describe('catalog routes with per-model configuration', () => { const ctx = await harness({ providers: { deepseek: { - apiKey: 'k', + apiKeyEnv: KEY_ENV, baseURL: `${server.url}/v1`, models: [{ id: 'deepseek-preview', contextWindow: 200_000, maxTokens: 8192 }], }, @@ -362,7 +371,7 @@ describe('catalog routes with per-model configuration', () => { const server = await mockServer([]) const ctx = await harness({ providers: { - deepseek: { apiKey: 'k', baseURL: server.url, models: [{ id: 'deepseek-preview', contextWindow: 1, maxTokens: 1 }] }, + deepseek: { baseURL: server.url, models: [{ id: 'deepseek-preview', contextWindow: 1, maxTokens: 1 }] }, }, }) @@ -390,7 +399,7 @@ describe('catalog routes with per-model configuration', () => { it('delegates both stream methods back to the reused catalog provider', async () => { const server = await mockServer([{ events: textEvents }, { events: textEvents }]) - const resolved = resolveProfiles({ deepseek: { apiKey: 'k', baseURL: `${server.url}/v1` } }) + const resolved = resolveProfiles({ deepseek: { baseURL: `${server.url}/v1` } }) const built = resolved.get('deepseek')?.piProvider if (built === undefined) throw new Error('the deepseek route built no provider') const [model] = built.getModels() @@ -431,7 +440,7 @@ describe('catalog routes with per-model configuration', () => { // openai's catalog models speak the Responses API; naming the protocol // explicitly moves the whole route onto Chat Completions. openai: { - apiKey: 'k', + apiKeyEnv: KEY_ENV, api: 'openai-completions', baseURL: `${server.url}/v1`, models: [{ id: 'gpt-4.1', contextWindow: 100_000, maxTokens: 4096 }], @@ -456,7 +465,7 @@ describe('catalog routes with per-model configuration', () => { // declares an api-key method. `openai-codex` ships OAuth alone, so without // the harness method beside it the route refuses its own configured key as // `Provider is not configured` before any request goes out. - const resolved = resolveProfiles({ 'openai-codex': { apiKey: 'codex-token' } }) + const resolved = resolveProfiles({ 'openai-codex': { apiKeyEnv: 'CODEX_TOKEN' } }) const provider = resolved.get('openai-codex')?.piProvider expect(provider?.auth.oauth).toBeDefined() const models = createModels() @@ -478,7 +487,7 @@ describe('catalog routes with per-model configuration', () => { describe('resolution snapshots', () => { it('finishes an in-flight request under the configuration it started with', async () => { const server = await mockServer([{ events: textEvents }]) - let current = resolveProfiles({ deepseek: { apiKey: 'k', baseURL: `${server.url}/v1` } }) + let current = resolveProfiles({ deepseek: { baseURL: `${server.url}/v1` } }) let release: () => void = () => {} const held = new Promise((resolve) => { release = resolve }) const adapter = new PiAiAdapter({ @@ -499,7 +508,7 @@ describe('resolution snapshots', () => { // The route set changes while the request waits, and something else reads // the adapter meanwhile, which is what would rebuild a shared collection. - current = resolveProfiles({ openai: { apiKey: 'k', baseURL: `${server.url}/v1` } }) + current = resolveProfiles({ openai: { baseURL: `${server.url}/v1` } }) await expect(adapter.listModels('openai')).resolves.not.toHaveLength(0) release() await inFlight @@ -513,7 +522,7 @@ describe('resolution snapshots', () => { it('serves the next request from the new configuration', async () => { const first = await mockServer([{ events: textEvents }]) const second = await mockServer([{ events: textEvents }]) - let current = resolveProfiles({ deepseek: { apiKey: 'k', baseURL: `${first.url}/v1` } }) + let current = resolveProfiles({ deepseek: { baseURL: `${first.url}/v1` } }) const adapter = new PiAiAdapter({ profiles: () => current, resolveApiKey: () => Promise.resolve('k') }) const drain = async (): Promise => { for await (const _chunk of adapter.stream({ @@ -522,7 +531,7 @@ describe('resolution snapshots', () => { } await drain() - current = resolveProfiles({ deepseek: { apiKey: 'k', baseURL: `${second.url}/v1` } }) + current = resolveProfiles({ deepseek: { baseURL: `${second.url}/v1` } }) await drain() expect(first.paths).toHaveLength(1) @@ -544,7 +553,6 @@ describe('configurable-provider directory', () => { await ctx.settings.update(settingsNamespace('llm-pi-ai'), { providers: { 'deepseek-official': { - apiKey: 'k', api: 'openai-completions', baseURL: 'https://acme.test/v1', models: [{ id: 'm', contextWindow: 1, maxTokens: 1 }], @@ -567,7 +575,6 @@ describe('configurable-provider directory', () => { await ctx.settings.update(settingsNamespace('llm-pi-ai'), { providers: { 'acme-gateway': { - apiKey: 'k', displayName: 'Acme Gateway', api: 'openai-completions', baseURL: 'https://acme.test/v1', diff --git a/packages/llm/llm-pi-ai/tests/config.spec.ts b/packages/llm/llm-pi-ai/tests/config.spec.ts deleted file mode 100644 index 90f8487ad8..0000000000 --- a/packages/llm/llm-pi-ai/tests/config.spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { resolveProfiles } from '../src/config.ts' - -describe('API key format', () => { - it('trims a padded literal apiKey into the resolved profile', () => { - const resolved = resolveProfiles({ openai: { apiKey: ' sk-abc ', baseURL: 'https://acme.test' } }) - expect(resolved.get('openai')?.apiKey).toBe('sk-abc') - }) - - it('keeps an omitted apiKey absent so ambient authentication still applies', () => { - const resolved = resolveProfiles({ openai: { baseURL: 'https://acme.test' } }) - expect(resolved.get('openai')?.apiKey).toBeUndefined() - }) - - it('still tells an empty apiKey to omit itself', () => { - expect(() => resolveProfiles({ openai: { apiKey: ' ', baseURL: 'https://acme.test' } })) - .toThrow(/omit it to use ambient authentication/) - }) - - it('rejects an apiKey no header can carry', () => { - expect(() => resolveProfiles({ openai: { apiKey: 'sk-\u{1F600}', baseURL: 'https://acme.test' } })) - .toThrow(/no HTTP header can carry/) - }) -}) diff --git a/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts b/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts index 3a810daa77..d30cf18cf9 100644 --- a/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts +++ b/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts @@ -44,7 +44,7 @@ async function boot(dir: string, config: LlmPiAi.Config): Promise { }) await ctx.plugin(LlmService) await ctx.plugin(SettingsLocal, { path: join(dir, 'settings.yaml'), watch: false }) - await ctx.plugin(CredentialsLocal, { path: join(dir, '.env'), watch: false }) + await ctx.plugin(CredentialsLocal, { path: join(dir, '.credentials.yaml'), watch: false }) await ctx.plugin(LlmPiAi, config) return ctx } @@ -53,7 +53,11 @@ describe('request-level dynamic profiles', () => { it('mounts bare and dormant, then registers routes the moment settings supply providers', async () => { vi.stubEnv('PI_DYNAMIC_KEY', '') const dir = await home() - await writeFile(join(dir, '.env'), 'PI_DYNAMIC_KEY=pk-from-settings\n') + await writeFile( + join(dir, '.credentials.yaml'), + 'PI_DYNAMIC_KEY: pk-from-settings\nPI_LIVE_KEY: live-key\nPI_OTHER_KEY: other\n', + { mode: 0o600 }, + ) const server = await mockServer([{ events: textEvents }]) // The exact product posture: `- id: llm-pi-ai` with no config at all. const ctx = await boot(dir, {}) @@ -87,14 +91,19 @@ describe('request-level dynamic profiles', () => { it('adds a provider route from settings and drops it when the user layer resets', async () => { const dir = await home() + await writeFile( + join(dir, '.credentials.yaml'), + 'PI_LIVE_KEY: live-key\nPI_OTHER_KEY: other\n', + { mode: 0o600 }, + ) const server = await mockServer([{ events: textEvents }]) const ctx = await boot(dir, { - providers: { openai: { apiKey: 'k', baseURL: 'http://127.0.0.1:1/v1' } }, + providers: { openai: { apiKeyEnv: 'PI_DYNAMIC_KEY', baseURL: 'http://127.0.0.1:1/v1' } }, }) expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['openai']) await ctx.settings.update(NS, { - providers: { deepseek: { apiKey: 'live-key', baseURL: server.url } }, + providers: { deepseek: { apiKeyEnv: 'PI_LIVE_KEY', baseURL: server.url } }, }) expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['openai', 'deepseek']) @@ -113,7 +122,7 @@ describe('request-level dynamic profiles', () => { it('rotates the per-request credential referenced by apiKeyEnv', async () => { vi.stubEnv('PI_DYNAMIC_KEY', '') const dir = await home() - await writeFile(join(dir, '.env'), 'PI_DYNAMIC_KEY=pk-one\n') + await writeFile(join(dir, '.credentials.yaml'), 'PI_DYNAMIC_KEY: pk-one\n', { mode: 0o600 }) const server = await mockServer([{ events: textEvents }, { events: textEvents }]) const ctx = await boot(dir, { providers: { deepseek: { apiKeyEnv: 'PI_DYNAMIC_KEY', baseURL: server.url } }, @@ -162,15 +171,20 @@ describe('request-level dynamic profiles', () => { it('keeps serving its routes when a settings-born route collides with another adapter', async () => { const dir = await home() + await writeFile( + join(dir, '.credentials.yaml'), + 'PI_LIVE_KEY: live-key\nPI_OTHER_KEY: other\n', + { mode: 0o600 }, + ) const server = await mockServer([{ events: textEvents }, { events: textEvents }]) - const ctx = await boot(dir, { providers: { openai: { apiKey: 'pk', baseURL: `${server.url}/v1` } } }) + const ctx = await boot(dir, { providers: { openai: { apiKeyEnv: 'PI_LIVE_KEY', baseURL: `${server.url}/v1` } } }) // Another adapter owns `anthropic`; the registry must refuse to hand it over. ctx.llm.registerAdapter(['anthropic'], new StubAdapter()) await ctx.settings.update(NS, { providers: { - openai: { apiKey: 'pk', baseURL: `${server.url}/v1` }, - anthropic: { apiKey: 'other' }, + openai: { apiKeyEnv: 'PI_LIVE_KEY', baseURL: `${server.url}/v1` }, + anthropic: { apiKeyEnv: 'PI_OTHER_KEY' }, }, }) diff --git a/packages/llm/llm-pi-ai/tests/loader-composition.spec.ts b/packages/llm/llm-pi-ai/tests/loader-composition.spec.ts index 460e78b7c2..d5eed60e5e 100644 --- a/packages/llm/llm-pi-ai/tests/loader-composition.spec.ts +++ b/packages/llm/llm-pi-ai/tests/loader-composition.spec.ts @@ -3,7 +3,7 @@ * settings-local, credentials-local, and a bare `llm-pi-ai` row boot from a * test-only cordis.yml through the actual Loader + Include path, an external * edit of settings.yaml registers the route live, and the next request - * carries the credential the .env supplies. A hand-mounted `ctx.plugin` cannot + * carries the credential the credentials document supplies. A hand-mounted `ctx.plugin` cannot * catch Loader export-shape failures, which is why the twin adapter has the * same guard. */ @@ -40,7 +40,7 @@ async function loadComposition(): Promise<{ ctx: Context; settingsPath: string } root = await mkdtemp(join(tmpdir(), 'dsh-pi-composition-')) const settingsPath = join(root, 'settings.yaml') await writeFile(settingsPath, '# personal settings\n') - await writeFile(join(root, '.env'), 'PI_COMPOSITION_KEY=key-from-store\n') + await writeFile(join(root, '.credentials.yaml'), 'PI_COMPOSITION_KEY: key-from-store\n', { mode: 0o600 }) const configPath = join(root, 'cordis.yml') await writeFile(configPath, [ @@ -54,7 +54,7 @@ async function loadComposition(): Promise<{ ctx: Context; settingsPath: string } '- id: credentials', " name: '@deepseek-ai/dsh-credentials-local'", ' config:', - ` path: ${JSON.stringify(join(root, '.env'))}`, + ` path: ${JSON.stringify(join(root, '.credentials.yaml'))}`, ' debounceMs: 10', '- id: llm-pi-ai', " name: '@deepseek-ai/dsh-llm-pi-ai'", diff --git a/packages/llm/llm-pi-ai/tests/sdk-options.spec.ts b/packages/llm/llm-pi-ai/tests/sdk-options.spec.ts index 86c646cba6..317b803f0c 100644 --- a/packages/llm/llm-pi-ai/tests/sdk-options.spec.ts +++ b/packages/llm/llm-pi-ai/tests/sdk-options.spec.ts @@ -21,7 +21,6 @@ function gatewayAdapter(): PiAiAdapter { return new PiAiAdapter({ profiles: () => resolveProfiles({ 'local-gateway': { - apiKey: 'test-key', api: 'openai-completions', baseURL: 'http://127.0.0.1:9/v1', models: [{ id: 'local-model', contextWindow: 8192, maxTokens: 1024 }], diff --git a/packages/llm/llm-pi-ai/tsconfig.json b/packages/llm/llm-pi-ai/tsconfig.json index ee8a81e73b..dd364e493a 100644 --- a/packages/llm/llm-pi-ai/tsconfig.json +++ b/packages/llm/llm-pi-ai/tsconfig.json @@ -8,6 +8,9 @@ "src" ], "references": [ + { + "path": "../../util/environment" + }, { "path": "../../../vendor/cosmokit" }, diff --git a/packages/llm/llm-retry/README.i18n.yaml b/packages/llm/llm-retry/README.i18n.yaml index b1157f806f..65e0c911ed 100644 --- a/packages/llm/llm-retry/README.i18n.yaml +++ b/packages/llm/llm-retry/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/llm/llm-retry/README.md -README.md: 23b55a30989cc51d4dd9076b61b6595452b0abd0 -README.zh.md: 267ef12a87561fd8effef726a781e505225baf03 +README.md: e6e56ec44032d714393c6fcc1c42d7271017a294 +README.zh.md: b7ce8bee4acd2c4f7c88870745dff96ec5695435 diff --git a/packages/llm/llm-retry/README.md b/packages/llm/llm-retry/README.md index 23b55a3098..e6e56ec440 100644 --- a/packages/llm/llm-retry/README.md +++ b/packages/llm/llm-retry/README.md @@ -15,7 +15,7 @@ The separately published `./invariant` companion checks that every retry record ```yaml - name: '@deepseek-ai/dsh-llm-deepseek' config: - apiKey: !!js process.env.DEEPSEEK_API_KEY + apiKeyEnv: DEEPSEEK_API_KEY retryPolicy: mode: always backoff: diff --git a/packages/llm/llm-retry/README.zh.md b/packages/llm/llm-retry/README.zh.md index 267ef12a87..b7ce8bee4a 100644 --- a/packages/llm/llm-retry/README.zh.md +++ b/packages/llm/llm-retry/README.zh.md @@ -15,7 +15,7 @@ ```yaml - name: '@deepseek-ai/dsh-llm-deepseek' config: - apiKey: !!js process.env.DEEPSEEK_API_KEY + apiKeyEnv: DEEPSEEK_API_KEY retryPolicy: mode: always backoff: diff --git a/packages/llm/llm-retry/tests/transport-recovery.spec.ts b/packages/llm/llm-retry/tests/transport-recovery.spec.ts index a5e7552496..3acb8605fc 100644 --- a/packages/llm/llm-retry/tests/transport-recovery.spec.ts +++ b/packages/llm/llm-retry/tests/transport-recovery.spec.ts @@ -1,7 +1,7 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' import { createServer } from 'node:http' import type { AddressInfo } from 'node:net' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' @@ -34,10 +34,10 @@ async function harness( baseURL: string, options: { streamIdleTimeoutMs?: number; initialDelayMs?: number } = {}, ): Promise { + vi.stubEnv('DEEPSEEK_API_KEY', 'mock-key') const ctx = new Context() await mountAgentLoopTestDependencies(ctx) await ctx.plugin(LlmDeepSeek, { - apiKey: 'mock-key', baseURL, streamIdleTimeoutMs: options.streamIdleTimeoutMs ?? 1_000, retryPolicy: { diff --git a/packages/sdk/create-sdk/tests/create.spec.ts b/packages/sdk/create-sdk/tests/create.spec.ts index a099b38b23..6c2995ec7d 100644 --- a/packages/sdk/create-sdk/tests/create.spec.ts +++ b/packages/sdk/create-sdk/tests/create.spec.ts @@ -381,7 +381,7 @@ describe('CreateWizard and scaffolder', () => { }).run() await scaffoldProject(resolved.directory, resolved.request) expect(await readFile(join(resolved.directory, '.env'), 'utf8')).toBe( - '# Required before start; an empty value makes provider startup fail.\nDEEPSEEK_API_KEY=\n', + '# Required before the first model request.\nDEEPSEEK_API_KEY=\n', ) expect(port.requests).toContain('Keep the API key empty and fill .env later?') }) diff --git a/packages/sdk/helper/src/features/builtin/provider.ts b/packages/sdk/helper/src/features/builtin/provider.ts index 94ea8a9679..a72daff94b 100644 --- a/packages/sdk/helper/src/features/builtin/provider.ts +++ b/packages/sdk/helper/src/features/builtin/provider.ts @@ -4,7 +4,6 @@ * @module @deepseek-ai/dsh-helper/features/builtin/provider */ -import { JsExpression } from '../../documents/cordis-yaml-file.ts' import { featureId } from '../../ids.ts' import type { FeatureSelection, ProjectProfile } from '../../project/types.ts' import { @@ -17,7 +16,7 @@ import { npmCordisConfigEntry, environment } from './helpers.ts' const ID = featureId('provider') const DEFAULT_MODEL = 'deepseek-v4-flash' -const API_KEY_COMMENT = 'Required before start; an empty value makes provider startup fail.' +const API_KEY_COMMENT = 'Required before the first model request.' class DeepSeekOption extends FeatureOption { override readonly id = 'deepseek-official' @@ -34,8 +33,7 @@ class DeepSeekOption extends FeatureOption { ...npmCordisConfigEntry(ID, { id: 'llm-deepseek', name: '@deepseek-ai/dsh-llm-deepseek', - config: { apiKey: new JsExpression('process.env.DEEPSEEK_API_KEY') }, - }, ['apiKey', 'baseURL', 'models']), + }, ['baseURL', 'models']), environment(ID, 'DEEPSEEK_API_KEY', secrets.apiKey, API_KEY_COMMENT), ]) } @@ -60,8 +58,7 @@ class CustomOption extends FeatureOption { ...npmCordisConfigEntry(ID, { id: 'llm-pi-ai', name: '@deepseek-ai/dsh-llm-pi-ai', - config: { apiKey: new JsExpression('process.env.DEEPSEEK_API_KEY') }, - }, ['apiKey', 'baseURL', 'models']), + }, ['baseURL', 'models']), environment(ID, 'DEEPSEEK_API_KEY', secrets.apiKey, API_KEY_COMMENT), ]) } diff --git a/packages/sdk/helper/tests/documents.spec.ts b/packages/sdk/helper/tests/documents.spec.ts index 86e50ca8ad..e3ffe18b77 100644 --- a/packages/sdk/helper/tests/documents.spec.ts +++ b/packages/sdk/helper/tests/documents.spec.ts @@ -86,20 +86,20 @@ config: expect(flow.serialize()).not.toContain('{') const document = CordisYamlFile.parse(`# lead - id: provider - name: '@deepseek-ai/dsh-llm-deepseek' + name: 'provider-package' config: - apiKey: !!js process.env.DEEPSEEK_API_KEY + endpoint: !!js process.env.PROVIDER_URL custom: keep `) - const apiKey = document.entry('provider')?.config?.apiKey - expect(apiKey).toBeInstanceOf(JsExpression) - document.updateOwnedConfig('provider', ['apiKey'], { apiKey: new JsExpression('process.env.NEXT_KEY') }) + const endpoint = document.entry('provider')?.config?.endpoint + expect(endpoint).toBeInstanceOf(JsExpression) + document.updateOwnedConfig('provider', ['endpoint'], { endpoint: new JsExpression('process.env.NEXT_URL') }) document.setDisabled('provider', true) document.addEntry({ id: 'tool', name: 'demo-tool' }) document.validate() const text = document.serialize() expect(text).toContain('# lead') - expect(text).toContain('!!js process.env.NEXT_KEY') + expect(text).toContain('!!js process.env.NEXT_URL') expect(text).toContain('custom: keep') expect(document.removeEntry('tool')).toBe(true) expect(document.removeEntry('tool')).toBe(false) diff --git a/packages/sdk/helper/tests/project.spec.ts b/packages/sdk/helper/tests/project.spec.ts index 20446216ab..d0f275ca4c 100644 --- a/packages/sdk/helper/tests/project.spec.ts +++ b/packages/sdk/helper/tests/project.spec.ts @@ -193,6 +193,7 @@ describe('SdkProject and ProjectEditSession', () => { expect(project.packageManifest().dependencies).not.toHaveProperty('@deepseek-ai/dsh-scope/invariant') expect(project.packageManifest().dependencies).not.toHaveProperty('node-addon-require-builtin') expect(project.cordis.entry('hmr')).toMatchObject({ name: '@cordisjs/plugin-hmr' }) + expect(project.cordis.entry('llm-deepseek')).not.toHaveProperty('config.apiKey') expect(project.cordis.entry('llm-deepseek')?.config).not.toHaveProperty('baseURL') expect(project.cordis.entry('llm-deepseek')?.config).not.toHaveProperty('models') }) @@ -580,7 +581,7 @@ describe('SdkProject and ProjectEditSession', () => { await writeFile(join(partialRoot, 'cordis.yml'), `- id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' config: - apiKey: test + apiKeyEnv: DEEPSEEK_API_KEY `) const partial = await SdkProject.open(partialRoot) const installation = createBuiltinRegistry(partial.profile) diff --git a/packages/sdk/scripts/tests/scripts.spec.ts b/packages/sdk/scripts/tests/scripts.spec.ts index 35df90ce98..6d2ea27991 100644 --- a/packages/sdk/scripts/tests/scripts.spec.ts +++ b/packages/sdk/scripts/tests/scripts.spec.ts @@ -535,7 +535,7 @@ describe('ConfigWorkflow', () => { ]), outputBuffer().stream, async () => {}) const result = await workflow.run(project, registry) const provider = result.commit?.project.cordis.entry('llm-pi-ai') - expect(provider?.config?.apiKey).toBeDefined() + expect(provider?.config).not.toHaveProperty('apiKey') expect(provider?.config?.baseURL).toBe('https://provider.example/v1') expect(result.commit?.project.cordis.entry('acp')).toBeDefined() expect(result.commit?.project.cordis.entry('agent-loop')).toBeDefined() diff --git a/packages/sdk/telemetry/tests/consent-resolver.spec.ts b/packages/sdk/telemetry/tests/consent-resolver.spec.ts index ca0cec3bbd..b9c9300e78 100644 --- a/packages/sdk/telemetry/tests/consent-resolver.spec.ts +++ b/packages/sdk/telemetry/tests/consent-resolver.spec.ts @@ -77,7 +77,8 @@ describe('ConsentResolver cordis.yml state', () => { '- id: llm', ' name: \'@deepseek-ai/dsh-llm-deepseek\'', ' config:', - ' apiKey: !!js process.env.DEEPSEEK_API_KEY', + ' apiKeyEnv: DEEPSEEK_API_KEY', + ' model: !!js process.env.DEEPSEEK_MODEL', '', ].join('\n') expect(await resolver.resolve(await projectDir(yml))) diff --git a/packages/settings/settings-local/src/index.ts b/packages/settings/settings-local/src/index.ts index 88a2619e13..0ce07af40c 100644 --- a/packages/settings/settings-local/src/index.ts +++ b/packages/settings/settings-local/src/index.ts @@ -272,10 +272,17 @@ export class SettingsLocal extends Settings { private parse(text: string): Record { let root: unknown if (this.spec.format === 'yaml') { + // `prettyErrors` is on only for `linePos`; `error.message` is never + // used, because the parser quotes the offending source line and a + // settings document can hold a `role('secret')` value. const document = parseDocument(text, { prettyErrors: true }) if (document.errors.length > 0) { throw new Error(`settings-local: invalid document at ${this.spec.filename}: ${ - document.errors.map(error => error.message).join('; ')}`) + document.errors.map((error) => { + const at = error.linePos?.[0] + /* v8 ignore next -- `prettyErrors` populates linePos on every error; the guard answers its optional type */ + return `${error.code}${at === undefined ? '' : ` at line ${String(at.line)}, column ${String(at.col)}`}` + }).join('; ')}`) } root = document.toJS() ?? {} } else { 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/ui/app-boot/README.i18n.yaml b/packages/ui/app-boot/README.i18n.yaml index 398ec6e923..422e585575 100644 --- a/packages/ui/app-boot/README.i18n.yaml +++ b/packages/ui/app-boot/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/ui/app-boot/README.md -README.md: cdd78047b6ad71148c6ebeba598b63b4ae4cfa7b -README.zh.md: ee2b07884e68510e2b59b9f2c27053c263d15f1a +README.md: 359f05a83b41db6db5ede40db7317a0fb15de43b +README.zh.md: a916236e30b50cc884d9d5876f27fcb1aa6f0777 diff --git a/packages/ui/app-boot/README.md b/packages/ui/app-boot/README.md index cdd78047b6..359f05a83b 100644 --- a/packages/ui/app-boot/README.md +++ b/packages/ui/app-boot/README.md @@ -8,6 +8,7 @@ Shared boot glue for the app bins ([`dsh`](../../../apps/cli/README.md), [`dsh-c |---|---| | `resolveConfigPath(path, snapshotMode, cwd?)` | Absolute config path; `snapshotMode === 'replay'` swaps a `cordis.yml`/`.yaml` basename for its sibling `cordis.snapshot.yml` | | `loadEnv(binName, dir?, warn?)` | Load the gitignored `.env` (Node `process.loadEnvFile`); absent file is fine, an unloadable one warns a single labelled line (default: stderr) | +| `loadLayeredEnv(binName, cwd?, warn?)` | Build the product CLI's frozen inherited > project `.env` > user `.env` snapshot, reject bootstrap-only file variables, and materialize accepted file values without replacing inherited ones | | `installFailLoud(binName, proc?, release?)` | Turn an unhandled boot or later Loader rejection into one labelled stderr line + `exit(1)`; the optional `release` teardown is awaited between the two (bounded by `FAIL_LOUD_RELEASE_TIMEOUT_MS`) so a terminal-owning surface restores the terminal before exit; returns the uninstaller | | `FAIL_LOUD_RELEASE_TIMEOUT_MS` | How long `installFailLoud` waits for its `release` hook; a wedged disposer delays the fatal exit, never cancels it | | `assertEntriesLoaded(ctx, binName)` | Throw when a settled tree holds an enabled entry with no fiber, reporting every unresolved plugin name as a Cordis startup failure | @@ -36,7 +37,7 @@ A profile is a directory under `$DSH_HOME/profiles/` (the Harness home res User-level machine-local preferences also live in the Harness home: -- **`.env`** — the credential store of [`dsh-credentials-local`](../../credentials/credentials-local/README.md), read by that provider alone. No surface hoists it into `process.env`: doing so would make every stored key look like a read-only launch override on the next run, blocking rotation from the Web settings page. The environment layers are the ambient one and the invoking directory's `.env` (loaded by the bin; `process.loadEnvFile` never overrides), and a composition without the credential provider keeps resolving keys from those alone. +- **`.env`** — the product CLI's ordinary environment layers: the invoking directory's file outranks the Harness-home file, and both sit below the inherited environment. `loadLayeredEnv` snapshots each value's source, rejects [bootstrap-only file variables](../../../.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md#decision) case-insensitively, and materializes accepted values into `process.env` for Loader expressions and third-party libraries. Managed credentials live separately in [`.credentials.yaml`](../../credentials/credentials-local/README.md); a credential left in either `.env` remains a lower-priority fallback. - **`cordis.patch.yml`** (home level) and **`profiles//cordis.patch.yml`** — the user patch layers, applied after every bundle layer (per-profile first, then the home-level file, which therefore outranks it): an id-targeted patch replaces the named entry's whole `config` (restate unchanged fields), `insert` adds entries, and `!!js` expressions interpolate at mount. A patch naming an entry id absent from the composed tree is a stderr warning. An empty or comments-only file throws (it parses to nothing, not to a list); disable the layer with `[]`. Long-lived surfaces keep `cordis.patch.yml` live through `watchUserPatches`; one-shot runs read only the startup value. The watcher targets the exact path even when the file or immediate parent does not exist, serializes bursts, and recomposes the user patches inside the caller's layer order (bundle layers below, overlay/flag patches above). A rejected read, parse, or Loader candidate leaves the last good tree running and the HMR service broadcasts `hmr/config-update-failed(filename, Error)` after logging it; observer failures are contained. Disposing the context closes the watcher and drains an active refresh. @@ -53,5 +54,5 @@ No direct invalidation from `boot()`; a consumer that calls `addHarnessSourceSec - **Bare package specifiers depend on Loader internals** — production bins need Loader's optional native helper; an in-process caller without it must use resolvable relative/file specifiers or provide its own module-resolution hook. - **Snapshot replay swapping is basename-specific** — only a config ending in `cordis.yml` or `cordis.yaml` maps to the sibling `cordis.snapshot.yml`; custom config names require caller-managed selection. -- **Environment loading is cwd-scoped and optional** — the helper loads one `.env` file and warns on failure; it does not search parents, merge profiles, or validate required variables. +- **Environment discovery is launch-scoped** — `loadLayeredEnv` reads only the invocation directory and Harness home once; it does not search parents or follow a workspace selected later. `loadEnv` remains the one-directory helper for non-product bins. - **User patch layers are patch-shaped** — an id-targeted patch replaces the entry's whole `config` rather than deep-merging, so a profile override restates the bundle fields it keeps. diff --git a/packages/ui/app-boot/README.zh.md b/packages/ui/app-boot/README.zh.md index ee2b07884e..a916236e30 100644 --- a/packages/ui/app-boot/README.zh.md +++ b/packages/ui/app-boot/README.zh.md @@ -8,6 +8,7 @@ |---|---| | `resolveConfigPath(path, snapshotMode, cwd?)` | 生成绝对配置路径;当 `snapshotMode === 'replay'` 时,把 basename 为 `cordis.yml`/`.yaml` 的文件替换为同级 `cordis.snapshot.yml` | | `loadEnv(binName, dir?, warn?)` | 加载已被 git 忽略的 `.env`(Node `process.loadEnvFile`);文件不存在不影响启动,文件无法加载时输出一行带标签的警告(默认写入 stderr) | +| `loadLayeredEnv(binName, cwd?, warn?)` | 构建产品 CLI(命令行界面)冻结的「继承环境 > 项目 `.env` > 用户 `.env`」快照,拒绝文件中的 bootstrap-only 变量,并在不替换继承值的前提下物化其余文件值 | | `installFailLoud(binName, proc?, release?)` | 将启动期或后续未处理的 Loader rejection 转换为一行带标签的 stderr 消息并执行 `exit(1)`;两者之间会等待可选的 `release` 拆卸回调(以 `FAIL_LOUD_RELEASE_TIMEOUT_MS` 为上限),使持有终端的界面能在退出前恢复终端;返回卸载函数 | | `FAIL_LOUD_RELEASE_TIMEOUT_MS` | `installFailLoud` 等待其 `release` 回调的时长;卡住的 disposer 只会延迟致命退出,而不会取消它 | | `assertEntriesLoaded(ctx, binName)` | 树结算后,如果其中存在已启用但没有 fiber 的条目,则抛出异常,并以 Cordis 启动故障的形式报告每个未解析插件的名称 | @@ -36,7 +37,7 @@ profile 是位于 `$DSH_HOME/profiles/` 下的目录(Harness home 由 [` 用户级的机器本地偏好同样位于 Harness home 中: -- **`.env`**:[`dsh-credentials-local`](../../credentials/credentials-local/README.md) 的凭据存储,只由该 provider 读取。没有任何表层会把它提升进 `process.env`:那样做会让每个已存密钥在下次运行时看起来都像只读的启动时覆盖,从而阻断从 Web 设置页面轮换密钥。环境层次由环境中的值与调用目录的 `.env` 构成(由 bin 加载;`process.loadEnvFile` 从不覆盖已有值),没有凭据 provider 的组合仍然只从这两者解析密钥。 +- **`.env`**:产品 CLI 的普通环境层;调用目录的文件优先于 Harness home 的文件,两者都低于继承环境。`loadLayeredEnv` 记录每个值的来源,按不区分大小写的方式拒绝 [bootstrap-only 文件变量](../../../.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md#decision),并把其余值物化进 `process.env`,供 Loader 表达式和第三方库使用。受管凭据另存于 [`.credentials.yaml`](../../credentials/credentials-local/README.md);留在任一 `.env` 中的凭据仍是低优先级后备值。 - **`cordis.patch.yml`**(home 级)与 **`profiles//cordis.patch.yml`**:用户 patch 层,应用在所有组合包层之后(先应用逐 profile 的文件,再应用 home 级文件,因此后者优先级更高):按 id 定位的 patch 会替换对应条目的整个 `config`(未改字段也要重述),`insert` 会添加条目,`!!js` 表达式则在挂载时插值。如果 patch 指定的条目 id 不在组合后的树中,则输出一条 stderr 警告。空文件或仅含注释的文件会抛出异常(其解析结果为空,而不是列表);如需禁用该层,请使用 `[]`。 长期运行的 surface 会持续应用 `cordis.patch.yml` 的变更,具体由 `watchUserPatches` 负责;一次性运行只读取启动时的值。即使该文件或其直接父目录不存在,watcher 仍会监视确切路径;它会串行处理突发变更,并按调用方的层次顺序重新组合用户 patch(组合包层在下、overlay/标志 patch 在上)。读取失败、解析失败或 Loader 候选被拒时,最后一个可用树会继续运行;HMR 服务记录错误后广播 `hmr/config-update-failed(filename, Error)`,并隔离 observer 失败。上下文 dispose 时会关闭 watcher,并等待进行中的刷新结束。 @@ -53,5 +54,5 @@ profile 是位于 `$DSH_HOME/profiles/` 下的目录(Harness home 由 [` - **裸包 specifier 依赖 Loader 内部机制**:生产 bin 需要 Loader 的可选原生 helper;没有该 helper 的进程内调用方必须使用可解析的相对/file specifier,或提供自己的模块解析钩子。 - **快照回放替换仅识别特定 basename**:只有以 `cordis.yml` 或 `cordis.yaml` 结尾的配置会映射到同级 `cordis.snapshot.yml`;自定义配置名称需要调用方自行选择。 -- **环境加载局限于 cwd 且为可选操作**:helper 只加载一个 `.env` 文件,并在失败时发出警告;它不会搜索父目录、合并 profile 或验证必需变量。 +- **环境发现以启动为界**:`loadLayeredEnv` 只读取一次调用目录与 Harness home 中的 `.env`;它不搜索父目录,也不跟随之后选择的 workspace。`loadEnv` 仍是非产品 bin 使用的单目录 helper。 - **用户 patch 层采用 patch 形式**:按 id 定位的 patch 会替换条目的整个 `config`,而不是深度合并,因此 profile 覆盖必须重述需要保留的组合包字段。 diff --git a/packages/ui/app-boot/package.json b/packages/ui/app-boot/package.json index c1214953a5..e2d0ed2391 100644 --- a/packages/ui/app-boot/package.json +++ b/packages/ui/app-boot/package.json @@ -31,6 +31,7 @@ "@cordisjs/plugin-hmr": "^1.0.15", "@cordisjs/plugin-include": "^1.0.4", "@cordisjs/plugin-loader": "^1.0.0-rc.5", + "@deepseek-ai/dsh-environment": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-paths": "^0.0.1", "@deepseek-ai/dsh-system-prompt": "^0.0.1", @@ -46,6 +47,7 @@ "@cordisjs/plugin-include": "workspace:^", "@cordisjs/plugin-loader": "workspace:^", "@cordisjs/plugin-timer": "workspace:^", + "@deepseek-ai/dsh-environment": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-paths": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", diff --git a/packages/ui/app-boot/src/index.ts b/packages/ui/app-boot/src/index.ts index e14b249f5c..72c2e8137f 100644 --- a/packages/ui/app-boot/src/index.ts +++ b/packages/ui/app-boot/src/index.ts @@ -8,12 +8,14 @@ import { pathToFileURL } from 'node:url' import { readFileSync } from 'node:fs' +import { parseEnv } from 'node:util' import { basename, dirname, resolve } from 'node:path' import * as yaml from 'js-yaml' import { Context, type FiberState } from 'cordis' import Loader, { type Entry, type EntryOptions } from '@cordisjs/plugin-loader' import Include, { applyEntryPatches, entryListSchema, type PatchOptions } from '@cordisjs/plugin-include' -import { dshHomePath } from '@deepseek-ai/dsh-paths' +import { dshHomePath, resolveDshHome } from '@deepseek-ai/dsh-paths' +import { createEnvironmentSnapshot, type EnvironmentSnapshot } from '@deepseek-ai/dsh-environment' import type {} from '@cordisjs/plugin-hmr' // Side-effect type import: resolves `ctx.get('systemPrompt')` to the service. import type {} from '@deepseek-ai/dsh-system-prompt' @@ -86,6 +88,113 @@ export function loadEnv( } } +/** Exact names no discovered file may set. */ +const BOOTSTRAP_NAMES = new Set([ + // Process launch and module resolution. + 'PATH', 'HOME', 'USERPROFILE', 'SHELL', + 'NODE_OPTIONS', 'NODE_PATH', 'NODE_EXTRA_CA_CERTS', + 'LD_PRELOAD', 'LD_LIBRARY_PATH', 'LD_AUDIT', + // Interpreter startup hooks. + 'BASH_ENV', 'ENV', 'SHELLOPTS', 'BASHOPTS', + 'PERL5OPT', 'PERL5LIB', 'PYTHONSTARTUP', 'PYTHONPATH', 'RUBYOPT', 'RUBYLIB', + 'JAVA_TOOL_OPTIONS', '_JAVA_OPTIONS', 'JDK_JAVA_OPTIONS', + 'PYTHONHOME', + // Version-control command hooks and config redirects. + 'GIT_SSH', 'GIT_SSH_COMMAND', 'GIT_EXTERNAL_DIFF', 'GIT_PAGER', 'GIT_EDITOR', + 'GIT_ASKPASS', 'SSH_ASKPASS', + 'GIT_CONFIG_GLOBAL', 'GIT_CONFIG_SYSTEM', 'GIT_CONFIG_COUNT', + 'EDITOR', 'VISUAL', 'PAGER', + // Network reach and trust. + 'SSL_CERT_FILE', 'SSL_CERT_DIR', + 'HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'NO_PROXY', + 'REQUESTS_CA_BUNDLE', 'CURL_CA_BUNDLE', + 'NODE_TLS_REJECT_UNAUTHORIZED', +]) + +/** Name prefixes no discovered file may set. */ +const BOOTSTRAP_PREFIXES = ['DSH_', 'XDG_', 'DYLD_', 'BASH_FUNC_'] + +/** + * Whether a variable may come only from the inherited process environment + * because it changes process, runtime, VCS, or network bootstrap. + * @param name - the variable name. + * @returns true when only the inherited environment may supply it. + */ +function isBootstrapOnly(name: string): boolean { + const upper = name.toUpperCase() + return BOOTSTRAP_NAMES.has(upper) || BOOTSTRAP_PREFIXES.some(prefix => upper.startsWith(prefix)) +} + +/** + * Parse one directory's `.env` without applying it, rejecting bootstrap-only + * names before any value is materialized. + * @param binName - the diagnostic prefix on the thrown error. + * @param dir - the directory whose `.env` to read. + * @param warn - sink for the one-line unreadable-file diagnostic. + * @returns the parsed entries, or `undefined` when the file is absent or unreadable. + * @throws when the file declares a name {@link isBootstrapOnly} rejects. + */ +function readEnvLayer( + binName: string, dir: string, warn: (line: string) => void, +): { path: string; values: Record } | undefined { + const path = resolve(dir, '.env') + let content: string + try { + content = readFileSync(path, 'utf8') + } catch (error) { + if ((error as NodeJS.ErrnoException | null)?.code !== 'ENOENT') { + warn(`${binName}: failed to load .env: ${String(error)}\n`) + } + // ENOENT (no .env) is fine — rely on the ambient environment. + return undefined + } + // Parse once so validation and materialization use exactly the same entries. + const values = parseEnv(content) as Record + for (const name of Object.keys(values)) { + if (!isBootstrapOnly(name)) continue + throw new Error( + `${binName}: ${path} sets "${name}", which only the launching environment may set` + + ' (it decides how this process starts, where its code and instructions load from, or how it' + + ` reaches the network); export ${name} instead of putting it in a .env file`, + ) + } + return { path, values } +} + +/** + * Load the product CLI's inherited > invoking-directory `.env` > Harness-home + * `.env` snapshot. The Harness home resolves before either file; both files + * are checked before either is applied, and accepted values are materialized + * without replacing inherited ones. The snapshot preserves source provenance. + * @param binName - the diagnostic prefix on the diagnostics. + * @param cwd - the invoking directory whose `.env` is the project layer. + * @param warn - sink for the one-line misconfiguration diagnostics. + * @returns this run's frozen environment snapshot. + * @throws when either file declares a bootstrap-only variable. + */ +export function loadLayeredEnv( + binName: string, cwd: string = process.cwd(), + warn: (line: string) => void = line => void process.stderr.write(line), +): EnvironmentSnapshot { + const home = resolveDshHome() + const inherited = { ...process.env } as Record + // Parse both layers first: a rejection must not leave one file applied. + const project = readEnvLayer(binName, cwd, warn) + const user = home === resolve(cwd) ? undefined : readEnvLayer(binName, home, warn) + // Apply the checked values without replacing a higher-ranked name. + for (const layer of [project, user]) { + if (layer === undefined) continue + for (const [name, value] of Object.entries(layer.values)) { + if (process.env[name] === undefined) process.env[name] = value + } + } + return createEnvironmentSnapshot([ + { source: 'process', values: inherited }, + ...project === undefined ? [] : [{ source: 'project-env' as const, path: project.path, values: project.values }], + ...user === undefined ? [] : [{ source: 'user-env' as const, path: user.path, values: user.values }], + ]) +} + const bootstrapIncludes = new WeakMap() // The include's YAML dialect (`!!js` scalars become expression nodes the diff --git a/packages/ui/app-boot/tests/app-boot.spec.ts b/packages/ui/app-boot/tests/app-boot.spec.ts index 24a24b4c58..baeb98fe77 100644 --- a/packages/ui/app-boot/tests/app-boot.spec.ts +++ b/packages/ui/app-boot/tests/app-boot.spec.ts @@ -7,7 +7,7 @@ import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt' import { addHarnessSourceSection, assertEntriesActivated, assertEntriesLoaded, boot, FAIL_LOUD_RELEASE_TIMEOUT_MS, HARNESS_SOURCE_SECTION, - installFailLoud, loadEnv, loadOverlayPatches, resolveConfigPath, type FailLoudProcess, + installFailLoud, loadEnv, loadLayeredEnv, loadOverlayPatches, resolveConfigPath, type FailLoudProcess, } from '../src/index.ts' const NAME = 'dsh-test-bin' @@ -86,6 +86,190 @@ describe('loadEnv', () => { }) }) +describe('loadLayeredEnv', () => { + const NAMES = ['APP_BOOT_LAYERED_SHARED', 'APP_BOOT_LAYERED_USER', 'APP_BOOT_LAYERED_PROJECT'] as const + + function clear(): void { + for (const name of NAMES) Reflect.deleteProperty(process.env, name) + } + + it('layers user under project under the inherited environment', () => { + const home = tmp() + const project = tmp() + writeFileSync(join(home, '.env'), [ + `${NAMES[0]}=user`, + `${NAMES[1]}=user-only`, + 'APP_BOOT_LAYERED_INHERITED=user-loses', + '', + ].join('\n')) + writeFileSync(join(project, '.env'), [ + `${NAMES[0]}=project`, + `${NAMES[2]}=project-only`, + 'APP_BOOT_LAYERED_INHERITED=project-loses', + '', + ].join('\n')) + clear() + vi.stubEnv('DSH_HOME', home) + vi.stubEnv('APP_BOOT_LAYERED_INHERITED', 'inherited') + const warn = vi.fn() + try { + loadLayeredEnv(NAME, project, warn) + expect(process.env[NAMES[0]]).toBe('project') + expect(process.env[NAMES[1]]).toBe('user-only') + expect(process.env[NAMES[2]]).toBe('project-only') + expect(process.env['APP_BOOT_LAYERED_INHERITED']).toBe('inherited') + expect(warn).not.toHaveBeenCalled() + } finally { + clear() + vi.unstubAllEnvs() + } + }) + + it.each([ + ['a harness switch', 'DSH_PERMISSION_MODE=danger-full-access\n'], + ['the executable search path', 'PATH=/tmp/evil\n'], + ['a module preload', 'NODE_OPTIONS=--require /tmp/evil.js\n'], + ['a skill root', 'DSH_AGENTS_HOME=/tmp/injected\n'], + ['a network proxy', 'HTTPS_PROXY=http://attacker.example\n'], + ['a lowercase network proxy', 'https_proxy=http://attacker.example\n'], + ])('refuses to launch when a .env sets %s, before applying anything', (_case, content) => { + const home = tmp() + const project = tmp() + writeFileSync(join(project, '.env'), `${NAMES[1]}=applied-anyway\n${content}`) + clear() + vi.stubEnv('DSH_HOME', home) + try { + expect(() => loadLayeredEnv(NAME, project, vi.fn())).toThrow(/only the launching environment may set/) + expect(process.env[NAMES[1]]).toBeUndefined() + } finally { + clear() + vi.unstubAllEnvs() + } + }) + + it('reports each file value with its absolute path', () => { + const home = tmp() + const project = tmp() + writeFileSync(join(home, '.env'), `${NAMES[1]}=u\n`) + writeFileSync(join(project, '.env'), `${NAMES[2]}=p\n`) + clear() + vi.stubEnv('DSH_HOME', home) + try { + const snapshot = loadLayeredEnv(NAME, project, vi.fn()) + expect(snapshot.get(NAMES[1])).toEqual({ value: 'u', source: 'user-env', path: join(home, '.env') }) + expect(snapshot.get(NAMES[2])).toEqual({ value: 'p', source: 'project-env', path: join(project, '.env') }) + expect(snapshot.getFrom(NAMES[2], ['process', 'user-env'])).toBeUndefined() + } finally { + clear() + vi.unstubAllEnvs() + } + }) + + it('resolves the harness home from the inherited environment, never from a file', () => { + const home = tmp() + const project = tmp() + writeFileSync(join(home, '.env'), `${NAMES[1]}=real-home\n`) + writeFileSync(join(project, '.env'), `${NAMES[2]}=set-by-project\n`) + clear() + vi.stubEnv('DSH_HOME', home) + try { + loadLayeredEnv(NAME, project, vi.fn()) + expect(process.env[NAMES[1]]).toBe('real-home') + expect(process.env[NAMES[2]]).toBe('set-by-project') + } finally { + clear() + vi.unstubAllEnvs() + } + }) + + it('warns and continues when a layer exists but cannot be read', () => { + const home = tmp() + const project = tmp() + // A directory named `.env` is a present-but-unreadable layer. + mkdirSync(join(home, '.env')) + writeFileSync(join(project, '.env'), `${NAMES[2]}=project-only\n`) + clear() + vi.stubEnv('DSH_HOME', home) + const warn = vi.fn() + try { + const snapshot = loadLayeredEnv(NAME, project, warn) + expect(warn).toHaveBeenCalledWith(expect.stringContaining(`${NAME}: failed to load .env`)) + expect(snapshot.get(NAMES[1])).toBeUndefined() + expect(snapshot.get(NAMES[2])).toEqual({ value: 'project-only', source: 'project-env', path: join(project, '.env') }) + expect(process.env[NAMES[2]]).toBe('project-only') + } finally { + clear() + vi.unstubAllEnvs() + } + }) + + it('reports to stderr when the caller supplies no reporter', () => { + const home = tmp() + const project = tmp() + mkdirSync(join(home, '.env')) + writeFileSync(join(project, '.env'), `${NAMES[2]}=project-only\n`) + clear() + vi.stubEnv('DSH_HOME', home) + const write = vi.spyOn(process.stderr, 'write').mockReturnValue(true) + try { + const snapshot = loadLayeredEnv(NAME, project) + expect(write).toHaveBeenCalledWith(expect.stringContaining(`${NAME}: failed to load .env`)) + expect(snapshot.get(NAMES[2])).toEqual({ value: 'project-only', source: 'project-env', path: join(project, '.env') }) + expect(process.env[NAMES[2]]).toBe('project-only') + } finally { + write.mockRestore() + clear() + vi.unstubAllEnvs() + } + }) + + it('passes over an absent layer without reporting it', () => { + const home = tmp() + const project = tmp() + writeFileSync(join(project, '.env'), `${NAMES[2]}=project-only\n`) + clear() + vi.stubEnv('DSH_HOME', home) + const warn = vi.fn() + try { + const snapshot = loadLayeredEnv(NAME, project, warn) + expect(warn).not.toHaveBeenCalled() + expect(snapshot.get(NAMES[2])).toEqual({ value: 'project-only', source: 'project-env', path: join(project, '.env') }) + } finally { + clear() + vi.unstubAllEnvs() + } + }) + + it('carries only the inherited environment when neither file exists', () => { + const home = tmp() + const project = tmp() + clear() + vi.stubEnv('DSH_HOME', home) + vi.stubEnv('APP_BOOT_LAYERED_INHERITED', 'inherited') + try { + const snapshot = loadLayeredEnv(NAME, project, vi.fn()) + expect(snapshot.get('APP_BOOT_LAYERED_INHERITED')).toEqual({ value: 'inherited', source: 'process' }) + } finally { + clear() + vi.unstubAllEnvs() + } + }) + + it('reads a harness home that is also the invocation directory exactly once', () => { + const both = tmp() + writeFileSync(join(both, '.env'), `${NAMES[2]}=one-file\n`) + clear() + vi.stubEnv('DSH_HOME', both) + try { + const snapshot = loadLayeredEnv(NAME, both, vi.fn()) + expect(snapshot.get(NAMES[2])).toEqual({ value: 'one-file', source: 'project-env', path: join(both, '.env') }) + } finally { + clear() + vi.unstubAllEnvs() + } + }) +}) + describe('installFailLoud', () => { function fakeProc(): FailLoudProcess & { handlers: Array<(err: unknown) => void>; written: string[]; exits: number[] } { const handlers: Array<(err: unknown) => void> = [] diff --git a/packages/ui/app-boot/tests/config-dump.spec.ts b/packages/ui/app-boot/tests/config-dump.spec.ts index 99af81f2c2..4f5d8e83e5 100644 --- a/packages/ui/app-boot/tests/config-dump.spec.ts +++ b/packages/ui/app-boot/tests/config-dump.spec.ts @@ -49,17 +49,17 @@ describe('renderConfigDump', () => { ' name: ./noop.mjs', '', ].join('\n')) - const personal = join(dir, 'personal.yml') - writeFileSync(personal, [ + const user = join(dir, 'user.yml') + writeFileSync(user, [ '- id: surface-extra', ' config:', - ' value: personal', + ' value: user', '', ].join('\n')) const dump = renderConfigDump(NAME, base, [ { label: 'surface.yml', patches: loadOverlayPatches(NAME, surface) }, - { label: 'personal.yml', patches: loadOverlayPatches(NAME, personal) }, + { label: 'user.yml', patches: loadOverlayPatches(NAME, user) }, ], () => {}) // Comments do not break loadability: the dump parses as one document // equal to what boot() would mount. @@ -74,7 +74,7 @@ describe('renderConfigDump', () => { config: { value: 'surface', key: { __jsExpr: 'process.env.DSH_DUMP_SPEC' } }, }, { id: 'untouched', name: './noop.mjs' }, - { id: 'surface-extra', name: './noop.mjs', config: { value: 'personal' } }, + { id: 'surface-extra', name: './noop.mjs', config: { value: 'user' } }, ]) // Unevaluated: the expression text round-trips as a !!js scalar. expect(dump).toContain('!!js process.env.DSH_DUMP_SPEC') @@ -82,7 +82,7 @@ describe('renderConfigDump', () => { // row; an inserted row carries the inserting layer as its origin. expect(dump).toContain('# == base.yml, patched by surface.yml') expect(dump).toContain('# == base.yml\n- id: untouched') - expect(dump).toContain('# == surface.yml, patched by personal.yml\n- id: surface-extra') + expect(dump).toContain('# == surface.yml, patched by user.yml\n- id: surface-extra') expect(dump.indexOf('# == base.yml, patched by surface.yml')).toBeLessThan(dump.indexOf('# == base.yml\n- id: untouched')) }) diff --git a/packages/ui/app-boot/tsconfig.json b/packages/ui/app-boot/tsconfig.json index beb61317dc..18ddbedad3 100644 --- a/packages/ui/app-boot/tsconfig.json +++ b/packages/ui/app-boot/tsconfig.json @@ -26,6 +26,9 @@ { "path": "../../core/system-prompt" }, + { + "path": "../../util/environment" + }, { "path": "../../util/paths" } diff --git a/packages/util/environment/README.i18n.yaml b/packages/util/environment/README.i18n.yaml new file mode 100644 index 0000000000..1c5f784bc4 --- /dev/null +++ b/packages/util/environment/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/util/environment/README.md +README.md: af6b0d9cc66b0bdfa1ad9ffb273260d0f4f06ddc +README.zh.md: 98c3c69ec96f835721e042960fe044fe075e6159 diff --git a/packages/util/environment/README.md b/packages/util/environment/README.md new file mode 100644 index 0000000000..af6b0d9cc6 --- /dev/null +++ b/packages/util/environment/README.md @@ -0,0 +1,36 @@ +# dsh-environment + +English | [中文](README.zh.md) + +This run's environment as one immutable snapshot that remembers **which layer supplied each value**. Consumers resolve user-facing values against it instead of `process.env`, because the layers are not equally trusted and a flattened view cannot tell them apart. + +| Layer | Source id | What it is | +|---|---|---| +| Inherited process environment | `process` | What the launching shell, CI job, or container passed in — this run's explicit intent | +| `/.env` | `project-env` | The project the harness was launched in, which the product trusts to configure its own agent | +| `$DSH_HOME/.env` | `user-env` | The user's own machine-level defaults | + +Values do also reach `process.env` — a user's `--config` tree and third-party libraries read it — but that flattened view is not the authority for anything the harness resolves. + +## Resolving + +`get(name)` searches every layer, most trusted first. `getFrom(name, sources)` searches only the named layers without changing that trust order. + +**Omitting a layer is a refusal, not a demotion** — a caller that must never accept a layer leaves it out of the list, so no future reordering can let it back in. The provider adapters name all three, because the product trusts the project it runs in; the mechanism exists for the decisions where that is not true. + +Names match the way the platform matches them: exactly on POSIX, case-insensitively on Windows. A case-sensitive lookup there would rank the wrong layer — a shell's `deepseek_api_key` and a project `.env`'s `DEEPSEEK_API_KEY` are one variable to the OS, and treating them as two would let the project win. + +```ts +import type { Context } from 'cordis' +import { environmentOf } from '@deepseek-ai/dsh-environment' + +declare const ctx: Context +const endpoint = environmentOf(ctx).get('DEEPSEEK_BASE_URL')?.value +``` + +`environmentOf(ctx)` returns the launcher's snapshot when the product CLI booted the tree, and otherwise the inherited environment as the only layer. That fallback does not weaken the rules: an SDK host or a bare `cordis.yml` discovered no files, so everything it has really is the environment it was launched with. + +## Known Limitations and Deferred Work + +- **The snapshot is not a subprocess boundary** — every layer is also materialized into `process.env`, so ordinary project variables reach child processes under [`dsh-subprocess`](../../subprocess/subprocess/README.md)'s scrub. The product launcher's [`.env` contract](../../ui/app-boot/README.md#profiles) rejects bootstrap variables before materialization. +- **No per-workspace layer** — the project layer is the *invoking* directory, fixed at launch. A workspace selected later in the Web UI contributes nothing, deliberately: following it would let a model's own workspace change the harness environment mid-session. diff --git a/packages/util/environment/README.zh.md b/packages/util/environment/README.zh.md new file mode 100644 index 0000000000..98c3c69ec9 --- /dev/null +++ b/packages/util/environment/README.zh.md @@ -0,0 +1,36 @@ +# dsh-environment + +[English](README.md) | 中文 + +把本次运行的环境冻结为一份不可变快照,并记住**每个值来自哪一层**。消费方用它而不是 `process.env` 解析面向用户的值,因为各层的可信程度并不相同,而压平后的视图无法区分它们。 + +| 层 | 来源 id | 它是什么 | +|---|---|---| +| 继承的进程环境 | `process` | 启动 shell、CI 任务或容器传入的东西——本次运行的明确意图 | +| `/.env` | `project-env` | harness 被启动于其中的项目;产品信任它配置自己的 agent | +| `$DSH_HOME/.env` | `user-env` | 用户自己的机器级默认值 | + +这些值同样会进入 `process.env`——用户自己的 `--config` 树和第三方库要读它——但那份压平的视图不是 harness 解析任何值的依据。 + +## 解析 + +`get(name)` 按可信度从高到低搜索所有层。`getFrom(name, sources)` 只搜索指定的层,不改变这一可信顺序。 + +**省略某一层是拒绝,不是降级**——绝不能接受某一层的调用方直接不把它列进去,后续任何重新排序都无法让它回来。provider 适配器三层全列,因为产品信任它所运行的项目;该机制是为那些「并非如此」的决策准备的。 + +变量名按平台自身的规则匹配:POSIX 上精确匹配,Windows 上不区分大小写。在 Windows 上做大小写敏感的查找会选错层——shell 里的 `deepseek_api_key` 与项目 `.env` 里的 `DEEPSEEK_API_KEY` 对操作系统而言是同一个变量,把它们当成两个就会让项目胜出。 + +```ts +import type { Context } from 'cordis' +import { environmentOf } from '@deepseek-ai/dsh-environment' + +declare const ctx: Context +const endpoint = environmentOf(ctx).get('DEEPSEEK_BASE_URL')?.value +``` + +当产品 CLI(命令行界面)启动了这棵树时,`environmentOf(ctx)` 返回启动器的快照;否则返回只含继承环境的那一层。该回退并不削弱规则:SDK 宿主或裸 `cordis.yml` 从未发现过任何文件,因此它拥有的一切确实就是它被启动时的环境。 + +## Known Limitations and Deferred Work + +- **快照不是子进程边界**:每一层同样会被物化进 `process.env`,因此项目里的普通变量会按 [`dsh-subprocess`](../../subprocess/subprocess/README.md) 的清洗规则抵达子进程。产品启动器的 [`.env` 契约](../../ui/app-boot/README.md#profiles) 会在物化之前拒绝 bootstrap 变量。 +- **没有按工作区划分的层**:项目层是*调用*目录,在启动时固定。之后在 Web UI 中选择的工作区不贡献任何内容,这是刻意的:跟随它等于让模型自己的工作区在会话中途改变 harness 的环境。 diff --git a/packages/util/environment/package.json b/packages/util/environment/package.json new file mode 100644 index 0000000000..6029a9f52a --- /dev/null +++ b/packages/util/environment/package.json @@ -0,0 +1,35 @@ +{ + "name": "@deepseek-ai/dsh-environment", + "description": "Immutable launch-time environment snapshot with per-layer provenance for the DeepSeek Harness", + "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" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.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/util/environment/src/index.ts b/packages/util/environment/src/index.ts new file mode 100644 index 0000000000..741e5752cd --- /dev/null +++ b/packages/util/environment/src/index.ts @@ -0,0 +1,124 @@ +/** + * Immutable launch-time environment snapshot with per-value source + * provenance. Harness consumers resolve through it instead of a flattened + * `process.env`; launchers may still materialize accepted values for config + * expressions and third-party libraries. + * @module @deepseek-ai/dsh-environment + */ + +import type { Context } from 'cordis' + +/** + * Which layer supplied a value, from most to least trusted: the environment + * this process inherited, the invoking directory's `.env`, the Harness home's + * `.env`. + */ +export type EnvironmentSource = 'process' | 'project-env' | 'user-env' + +/** Layer order, most trusted first. */ +const SOURCE_ORDER: readonly EnvironmentSource[] = ['process', 'project-env', 'user-env'] + +/** One resolved variable and the layer it came from. */ +export interface EnvironmentEntry { + /** The value as the layer supplied it; may be empty, which each owner judges for itself. */ + value: string + /** The layer that supplied it. */ + source: EnvironmentSource + /** Absolute path of the file that supplied it; absent for `process`. */ + path?: string +} + +/** + * The frozen environment of one launch. Construct through + * {@link createEnvironmentSnapshot}; nothing mutates it afterwards, so a + * later `chdir`, workspace switch, or resumed session observes the same + * values a consumer resolved at boot. + */ +export interface EnvironmentSnapshot { + /** + * Resolve one name across every layer, most trusted first. + * @param name - the variable name. + * @returns the winning entry, or `undefined` when no layer supplies it. + */ + get(name: string): EnvironmentEntry | undefined + /** + * Resolve one name only from `sources`, retaining canonical trust order; + * omitted layers are unreachable. + * @param name - the variable name. + * @param sources - the layers allowed in the canonical trust order. + * @returns the first matching entry, or `undefined`. + */ + getFrom(name: string, sources: readonly EnvironmentSource[]): EnvironmentEntry | undefined +} + +/** + * The map key one variable name resolves under. Windows treats environment + * names case-insensitively; every other platform does not. + * @param name - the variable name as written. + * @returns the key to store and look up by. + */ +function lookupKey(name: string): string { + /* v8 ignore next -- native Windows coverage exercises the folding arm; POSIX covers the exact one */ + return process.platform === 'win32' ? name.toUpperCase() : name +} + +/** One layer's raw contents, as {@link createEnvironmentSnapshot} receives them. */ +export interface EnvironmentLayerInput { + source: EnvironmentSource + /** Absolute path of the file behind this layer; omit for `process`. */ + path?: string + values: Readonly> +} + +/** + * Build the snapshot from each layer's contents. + * @param layers - the layers in any order; the result searches them by canonical trust order. + * @returns the immutable snapshot. + */ +export function createEnvironmentSnapshot(layers: readonly EnvironmentLayerInput[]): EnvironmentSnapshot { + // Copy every layer so later mutations cannot change the snapshot. Fold names + // on Windows so case variants cannot split precedence; POSIX remains exact. + const bySource = new Map }>() + for (const layer of layers) { + bySource.set(layer.source, { + ...layer.path === undefined ? {} : { path: layer.path }, + values: new Map(Object.entries(layer.values).map(([name, value]) => [lookupKey(name), value])), + }) + } + const getFrom = (name: string, sources: readonly EnvironmentSource[]): EnvironmentEntry | undefined => { + const key = lookupKey(name) + for (const source of SOURCE_ORDER) { + if (!sources.includes(source)) continue + const layer = bySource.get(source) + const value = layer?.values.get(key) + if (value === undefined) continue + return { value, source, ...layer?.path === undefined ? {} : { path: layer.path } } + } + return undefined + } + return { + get: name => getFrom(name, SOURCE_ORDER), + getFrom, + } +} + +/** Context slot the launcher fills with this run's snapshot before any config entry mounts. */ +export const DSH_ENVIRONMENT_KEY = 'launcherEnvironment' + +/** + * Return the launcher's snapshot, or the inherited environment as the sole + * layer when the host provided none. + * @param ctx - the consuming plugin's context. + * @returns the snapshot to resolve user-facing values against. + */ +export function environmentOf(ctx: Context): EnvironmentSnapshot { + return ctx.get(DSH_ENVIRONMENT_KEY) + ?? createEnvironmentSnapshot([{ source: 'process', values: process.env as Record }]) +} + +declare module 'cordis' { + interface Context { + /** Launcher-owned snapshot of this run's environment; absent in compositions the product CLI did not boot. */ + launcherEnvironment?: EnvironmentSnapshot + } +} diff --git a/packages/util/environment/src/invariant.ts b/packages/util/environment/src/invariant.ts new file mode 100644 index 0000000000..96e53828ae --- /dev/null +++ b/packages/util/environment/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-environment`. + * @module @deepseek-ai/dsh-environment/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-environment' + +/** Cordis companion plugin name. */ +export const name = 'environment-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: the snapshot is frozen before any fiber starts and this package owns no + * event stream or mutable runtime data; its lookup and rejection rules are enforced by unit tests. + */ +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/util/environment/tests/environment.spec.ts b/packages/util/environment/tests/environment.spec.ts new file mode 100644 index 0000000000..5951484a83 --- /dev/null +++ b/packages/util/environment/tests/environment.spec.ts @@ -0,0 +1,69 @@ +import { describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import { + createEnvironmentSnapshot, DSH_ENVIRONMENT_KEY, environmentOf, +} from '../src/index.ts' + +const layered = createEnvironmentSnapshot([ + { source: 'process', values: { SHARED: 'from-process', ONLY_PROCESS: 'p' } }, + { source: 'project-env', path: '/work/.env', values: { SHARED: 'from-project', ONLY_PROJECT: 'j' } }, + { source: 'user-env', path: '/home/.dsh/.env', values: { SHARED: 'from-user', ONLY_USER: 'u' } }, +]) + +describe('createEnvironmentSnapshot', () => { + it('resolves across every layer, most trusted first, and reports the winning source', () => { + expect(layered.get('SHARED')).toEqual({ value: 'from-process', source: 'process' }) + expect(layered.get('ONLY_PROJECT')).toEqual({ value: 'j', source: 'project-env', path: '/work/.env' }) + expect(layered.get('ONLY_USER')).toEqual({ value: 'u', source: 'user-env', path: '/home/.dsh/.env' }) + expect(layered.get('ABSENT')).toBeUndefined() + }) + + it('filters layers without changing their trust order', () => { + // The point of getFrom: a routing field that must never come from a + // project directory cannot be reached by reordering, only by listing it. + expect(layered.getFrom('ONLY_PROJECT', ['process', 'user-env'])).toBeUndefined() + expect(layered.getFrom('SHARED', ['user-env', 'process'])) + .toEqual({ value: 'from-process', source: 'process' }) + expect(layered.getFrom('SHARED', [])).toBeUndefined() + }) + + it('copies each layer, so a later mutation of the source object cannot change it', () => { + const values: Record = { KEY: 'first' } + const snapshot = createEnvironmentSnapshot([{ source: 'process', values }]) + values.KEY = 'second' + values.LATE = 'added' + expect(snapshot.get('KEY')).toEqual({ value: 'first', source: 'process' }) + expect(snapshot.get('LATE')).toBeUndefined() + }) + + it('keeps an empty value as a present value, for its owner to judge', () => { + const snapshot = createEnvironmentSnapshot([{ source: 'process', values: { EMPTY: '' } }]) + expect(snapshot.get('EMPTY')).toEqual({ value: '', source: 'process' }) + }) + + it('orders lookups canonically regardless of construction order', () => { + const reversed = createEnvironmentSnapshot([ + { source: 'user-env', path: '/u', values: { K: 'u' } }, + { source: 'process', values: { K: 'p' } }, + ]) + expect(reversed.get('K')).toEqual({ value: 'p', source: 'process' }) + }) +}) + +describe('environmentOf', () => { + it('returns the launcher snapshot when the product CLI provided one', () => { + const ctx = new Context() + ctx.provide(DSH_ENVIRONMENT_KEY, layered) + expect(environmentOf(ctx)).toBe(layered) + }) + + it('falls back to the inherited environment as the only layer', () => { + vi.stubEnv('DSH_ENV_SPEC_FALLBACK', 'ambient') + try { + const snapshot = environmentOf(new Context()) + expect(snapshot.get('DSH_ENV_SPEC_FALLBACK')).toEqual({ value: 'ambient', source: 'process' }) + } finally { + vi.unstubAllEnvs() + } + }) +}) diff --git a/packages/util/environment/tsconfig.json b/packages/util/environment/tsconfig.json new file mode 100644 index 0000000000..d970a00263 --- /dev/null +++ b/packages/util/environment/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "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/packages/web/web-search-deepseek/README.i18n.yaml b/packages/web/web-search-deepseek/README.i18n.yaml index 96c182e552..a552a7c9b2 100644 --- a/packages/web/web-search-deepseek/README.i18n.yaml +++ b/packages/web/web-search-deepseek/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/web-search-deepseek/README.md -README.md: 9046934de209ed0787efa50332e5be16bfdf55c6 -README.zh.md: 1265fbd1ccda55374559f4633ca6a947bd04a6fb +README.md: 95340314fe08d0963b899f4a1d704a98f85963a5 +README.zh.md: 48d94b64d15e4c0902e5222fe9a737b1e67270fc diff --git a/packages/web/web-search-deepseek/README.md b/packages/web/web-search-deepseek/README.md index 9046934de2..95340314fe 100644 --- a/packages/web/web-search-deepseek/README.md +++ b/packages/web/web-search-deepseek/README.md @@ -20,7 +20,7 @@ It reuses the `DEEPSEEK_API_KEY` credential reference (no new secret) but **not* |---|---|---| | `apiKey` | omitted | Literal DeepSeek API key. Prefer `apiKeyEnv` so no secret enters configuration; a non-empty literal wins. | | `apiKeyEnv` | `DEEPSEEK_API_KEY` | Credential reference resolved for each search through `ctx.credentials`, or from the process environment when that seam is absent. A missing value fails the call as `WEB_PROVIDER_CREDENTIAL_MISSING`. | -| `baseURL` | `https://api.deepseek.com/anthropic/v1` | Anthropic-compatible endpoint base; `/messages` is appended. Use a separate env var such as `$DEEPSEEK_SEARCH_BASE_URL` when overriding it; do not reuse `$DEEPSEEK_BASE_URL`, which belongs to the chat-completions LLM adapter. An unparseable value makes the provider unavailable. | +| `baseURL` | `https://api.deepseek.com/anthropic/v1` | Anthropic-compatible endpoint base; `/messages` is appended. Falls back to `$DEEPSEEK_SEARCH_BASE_URL` from any environment layer; do not reuse `$DEEPSEEK_BASE_URL`, which belongs to the chat-completions LLM adapter. An unparseable value makes the provider unavailable. | | `model` | `deepseek-v4-flash` | Anthropic-format model name. | | `apiVersion` | `2023-06-01` | `anthropic-version` header value. | | `maxTokens` | `4096` | Positive-integer upper bound on generated tokens for the Messages request. | @@ -31,7 +31,7 @@ It reuses the `DEEPSEEK_API_KEY` credential reference (no new secret) but **not* name: '@deepseek-ai/dsh-web-search-deepseek' config: apiKeyEnv: DEEPSEEK_API_KEY - baseURL: !!js process.env.DEEPSEEK_SEARCH_BASE_URL + baseURL: https://gateway.internal/anthropic/v1 ``` ## Mapping diff --git a/packages/web/web-search-deepseek/README.zh.md b/packages/web/web-search-deepseek/README.zh.md index 1265fbd1cc..48d94b64d1 100644 --- a/packages/web/web-search-deepseek/README.zh.md +++ b/packages/web/web-search-deepseek/README.zh.md @@ -20,7 +20,7 @@ Exa 和 Perplexity 提供专用搜索端点,DeepSeek 则没有。该提供方 |---|---|---| | `apiKey` | 未设置 | DeepSeek API 密钥字面值。优先使用 `apiKeyEnv`,避免密钥进入配置;非空字面值优先。 | | `apiKeyEnv` | `DEEPSEEK_API_KEY` | 每次搜索都会通过 `ctx.credentials` 解析该凭据引用;没有该 seam 时则从进程环境解析。值缺失时,调用以 `WEB_PROVIDER_CREDENTIAL_MISSING` 失败。 | -| `baseURL` | `https://api.deepseek.com/anthropic/v1` | Anthropic 兼容端点基址;追加 `/messages`。覆盖时使用 `$DEEPSEEK_SEARCH_BASE_URL` 等独立环境变量;禁止复用属于 chat-completions LLM 适配器的 `$DEEPSEEK_BASE_URL`。无法解析时提供方不可用。 | +| `baseURL` | `https://api.deepseek.com/anthropic/v1` | Anthropic 兼容端点基址;追加 `/messages`。缺省时回退到任一环境层中的 `$DEEPSEEK_SEARCH_BASE_URL`;禁止复用属于 chat-completions LLM 适配器的 `$DEEPSEEK_BASE_URL`。无法解析时提供方不可用。 | | `model` | `deepseek-v4-flash` | Anthropic 格式模型名称。 | | `apiVersion` | `2023-06-01` | `anthropic-version` 标头值。 | | `maxTokens` | `4096` | Messages 请求生成 token 的正整数上限。 | @@ -31,7 +31,7 @@ Exa 和 Perplexity 提供专用搜索端点,DeepSeek 则没有。该提供方 name: '@deepseek-ai/dsh-web-search-deepseek' config: apiKeyEnv: DEEPSEEK_API_KEY - baseURL: !!js process.env.DEEPSEEK_SEARCH_BASE_URL + baseURL: https://gateway.internal/anthropic/v1 ``` ## 映射 diff --git a/packages/web/web-search-deepseek/package.json b/packages/web/web-search-deepseek/package.json index 546ca2ec87..0ca52390de 100644 --- a/packages/web/web-search-deepseek/package.json +++ b/packages/web/web-search-deepseek/package.json @@ -27,6 +27,7 @@ "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-credentials": "^0.0.1", + "@deepseek-ai/dsh-environment": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-web": "^0.0.1", @@ -39,6 +40,7 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^", "@deepseek-ai/dsh-credentials-local": "workspace:^", + "@deepseek-ai/dsh-environment": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-web": "workspace:^", diff --git a/packages/web/web-search-deepseek/src/index.ts b/packages/web/web-search-deepseek/src/index.ts index 8569f0e944..5e55e12457 100644 --- a/packages/web/web-search-deepseek/src/index.ts +++ b/packages/web/web-search-deepseek/src/index.ts @@ -9,6 +9,7 @@ import type { Context } from 'cordis' import z from 'schemastery' import type {} from '@deepseek-ai/dsh-agent' import { credentialRef } from '@deepseek-ai/dsh-credentials' +import { environmentOf } from '@deepseek-ai/dsh-environment' import type {} from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-web' import { @@ -67,6 +68,14 @@ export const Config: z = z.object({ maxUses: z.number().step(1).min(1), }) +/** + * Environment variable naming this provider's endpoint. Deliberately distinct + * from `$DEEPSEEK_BASE_URL`, which belongs to the chat-completions adapter: + * search speaks the Anthropic-compatible Messages API, so one variable cannot + * serve both. + */ +const SEARCH_BASE_URL_ENV = 'DEEPSEEK_SEARCH_BASE_URL' + /** Register the DeepSeek search provider with `ctx.web`. */ export function apply(ctx: Context, config: Config): void { const maxTokens = config.maxTokens ?? DEEPSEEK_DEFAULT_MAX_TOKENS @@ -80,11 +89,14 @@ export function apply(ctx: Context, config: Config): void { resolveApiKey: async () => { const credentials = ctx.get('credentials') if (credentials !== undefined) return (await credentials.resolve(apiKeyEnv))?.value - const ambient = process.env[apiKeyEnv] - return ambient !== undefined && ambient.length > 0 ? ambient : undefined + // Without the seam the environment is the whole credential plane. + const ambient = environmentOf(ctx).get(apiKeyEnv) + return ambient !== undefined && ambient.value.length > 0 ? ambient.value : undefined }, apiKeyEnv, - baseURL: config.baseURL ?? DEEPSEEK_DEFAULT_BASE_URL, + baseURL: config.baseURL + ?? environmentOf(ctx).get(SEARCH_BASE_URL_ENV)?.value + ?? DEEPSEEK_DEFAULT_BASE_URL, model: config.model ?? DEEPSEEK_DEFAULT_MODEL, apiVersion: config.apiVersion ?? DEEPSEEK_DEFAULT_API_VERSION, maxTokens, diff --git a/packages/web/web-search-deepseek/tests/deepseek.spec.ts b/packages/web/web-search-deepseek/tests/deepseek.spec.ts index 7990a96a9b..23c2d2c237 100644 --- a/packages/web/web-search-deepseek/tests/deepseek.spec.ts +++ b/packages/web/web-search-deepseek/tests/deepseek.spec.ts @@ -455,7 +455,7 @@ describe('web-search-deepseek plugin registration', () => { const ctx = new Context() try { await ctx.plugin(WebService, { searchProvider: DEEPSEEK_PROVIDER_ID }) - await ctx.plugin(CredentialsLocal, { path: join(dir, '.env'), watch: false }) + await ctx.plugin(CredentialsLocal, { path: join(dir, '.credentials.yaml'), watch: false }) await ctx.plugin(deepseekPlugin, { baseURL: 'https://api.deepseek.test/anthropic/v1' }) await expect(ctx.web.search({ query: 'missing' })) diff --git a/packages/web/web-search-deepseek/tsconfig.json b/packages/web/web-search-deepseek/tsconfig.json index 76c411d089..b3d8e2ade6 100644 --- a/packages/web/web-search-deepseek/tsconfig.json +++ b/packages/web/web-search-deepseek/tsconfig.json @@ -8,6 +8,9 @@ "src" ], "references": [ + { + "path": "../../util/environment" + }, { "path": "../../../vendor/cosmokit" }, diff --git a/packages/web/web-search-exa/package.json b/packages/web/web-search-exa/package.json index 7bffc94331..c2d55e00ff 100644 --- a/packages/web/web-search-exa/package.json +++ b/packages/web/web-search-exa/package.json @@ -25,6 +25,7 @@ ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-environment": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-web": "^0.0.1", "cordis": "^4.0.0-rc.7" @@ -33,6 +34,7 @@ "schemastery": "^3.18.0" }, "devDependencies": { + "@deepseek-ai/dsh-environment": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-web": "workspace:^", "cordis": "^4.0.0-rc.7" diff --git a/packages/web/web-search-exa/src/index.ts b/packages/web/web-search-exa/src/index.ts index 67b2eed574..2ecb71336a 100644 --- a/packages/web/web-search-exa/src/index.ts +++ b/packages/web/web-search-exa/src/index.ts @@ -9,6 +9,7 @@ */ import type { Context } from 'cordis' +import { environmentOf } from '@deepseek-ai/dsh-environment' import z from 'schemastery' import type {} from '@deepseek-ai/dsh-web' import { @@ -58,7 +59,9 @@ export const Config: z = z.object({ /** Register the Exa search provider with `ctx.web`. */ export function apply(ctx: Context, config: Config): void { ctx.web.registerSearchProvider(new ExaSearchProvider({ - apiKey: config.apiKey ?? process.env.EXA_API_KEY ?? '', + // Every environment layer may name this key: the product trusts the + // project it is launched in, and the managed store is not involved here. + apiKey: config.apiKey ?? environmentOf(ctx).get('EXA_API_KEY')?.value ?? '', baseURL: config.baseURL ?? EXA_DEFAULT_BASE_URL, searchType: config.searchType ?? EXA_DEFAULT_SEARCH_TYPE, highlightsPerResult: config.highlightsPerResult ?? EXA_DEFAULT_HIGHLIGHTS_PER_RESULT, diff --git a/packages/web/web-search-exa/tsconfig.json b/packages/web/web-search-exa/tsconfig.json index e9610ea5c9..770ee55a04 100644 --- a/packages/web/web-search-exa/tsconfig.json +++ b/packages/web/web-search-exa/tsconfig.json @@ -8,6 +8,9 @@ "src" ], "references": [ + { + "path": "../../util/environment" + }, { "path": "../../../vendor/cosmokit" }, diff --git a/packages/web/web-search-perplexity/package.json b/packages/web/web-search-perplexity/package.json index a6f6a6bea6..1ece514da9 100644 --- a/packages/web/web-search-perplexity/package.json +++ b/packages/web/web-search-perplexity/package.json @@ -25,6 +25,7 @@ ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-environment": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-web": "^0.0.1", "cordis": "^4.0.0-rc.7" @@ -33,6 +34,7 @@ "schemastery": "^3.18.0" }, "devDependencies": { + "@deepseek-ai/dsh-environment": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-web": "workspace:^", "cordis": "^4.0.0-rc.7" diff --git a/packages/web/web-search-perplexity/src/index.ts b/packages/web/web-search-perplexity/src/index.ts index d673f575c8..e1fe6a2606 100644 --- a/packages/web/web-search-perplexity/src/index.ts +++ b/packages/web/web-search-perplexity/src/index.ts @@ -8,6 +8,7 @@ */ import type { Context } from 'cordis' +import { environmentOf } from '@deepseek-ai/dsh-environment' import z from 'schemastery' import type {} from '@deepseek-ai/dsh-web' import { PerplexitySearchProvider, PERPLEXITY_DEFAULT_BASE_URL, PERPLEXITY_DEFAULT_MAX_TOKENS, PERPLEXITY_DEFAULT_MODEL } from './provider.ts' @@ -52,7 +53,9 @@ export const Config: z = z.object({ /** Register the Perplexity search provider with `ctx.web`. */ export function apply(ctx: Context, config: Config): void { ctx.web.registerSearchProvider(new PerplexitySearchProvider({ - apiKey: config.apiKey ?? process.env.PERPLEXITY_API_KEY ?? '', + // Every environment layer may name this key: the product trusts the + // project it is launched in, and the managed store is not involved here. + apiKey: config.apiKey ?? environmentOf(ctx).get('PERPLEXITY_API_KEY')?.value ?? '', baseURL: config.baseURL ?? PERPLEXITY_DEFAULT_BASE_URL, model: config.model ?? PERPLEXITY_DEFAULT_MODEL, maxTokens: config.maxTokens ?? PERPLEXITY_DEFAULT_MAX_TOKENS, diff --git a/packages/web/web-search-perplexity/tsconfig.json b/packages/web/web-search-perplexity/tsconfig.json index e9610ea5c9..770ee55a04 100644 --- a/packages/web/web-search-perplexity/tsconfig.json +++ b/packages/web/web-search-perplexity/tsconfig.json @@ -8,6 +8,9 @@ "src" ], "references": [ + { + "path": "../../util/environment" + }, { "path": "../../../vendor/cosmokit" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 391c0947a4..b390c886a0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -207,6 +207,9 @@ importers: '@deepseek-ai/dsh-host-webserver': specifier: workspace:^ version: link:../../packages/host/webserver + '@deepseek-ai/dsh-llm-mock-server': + specifier: workspace:^ + version: link:../../packages/support/llm-mock-server '@deepseek-ai/dsh-loader-smoke': specifier: workspace:^ version: link:../../packages/support/loader-smoke @@ -627,6 +630,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': @@ -910,6 +966,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 @@ -1096,6 +1155,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 @@ -1152,6 +1217,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 @@ -1420,12 +1488,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 @@ -1623,9 +1700,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 @@ -2832,6 +2909,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 @@ -2896,6 +2979,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 @@ -2970,12 +3059,12 @@ importers: chokidar: specifier: ^4.0.3 version: 4.0.3 - dotenv: - specifier: ^17.2.0 - version: 17.4.2 schemastery: specifier: ^3.18.0 version: link:../../../vendor/schemastery + yaml: + specifier: ^2.9.0 + version: 2.9.0 devDependencies: '@deepseek-ai/dsh-atomic-write': specifier: workspace:^ @@ -2983,6 +3072,9 @@ importers: '@deepseek-ai/dsh-credentials': specifier: workspace:^ version: link:../credentials + '@deepseek-ai/dsh-environment': + specifier: workspace:^ + version: link:../../util/environment '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -3530,6 +3622,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 @@ -3753,6 +3848,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 @@ -3829,6 +3927,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 @@ -4012,6 +4116,9 @@ importers: '@deepseek-ai/dsh-credentials': specifier: workspace:^ version: link:../../credentials/credentials + '@deepseek-ai/dsh-environment': + specifier: workspace:^ + version: link:../../util/environment '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -4040,6 +4147,9 @@ importers: '@deepseek-ai/dsh-credentials': specifier: workspace:^ version: link:../../credentials/credentials + '@deepseek-ai/dsh-environment': + specifier: workspace:^ + version: link:../../util/environment '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -6157,6 +6267,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 @@ -6201,6 +6314,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 @@ -6212,6 +6328,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: @@ -6230,6 +6355,9 @@ importers: '@cordisjs/plugin-timer': specifier: workspace:^ version: link:../../../vendor/timer + '@deepseek-ai/dsh-environment': + specifier: workspace:^ + version: link:../../util/environment '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -6438,6 +6566,15 @@ importers: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis + packages/util/environment: + devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + cordis: + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + packages/util/native-command: devDependencies: '@deepseek-ai/dsh-invariants': @@ -6579,6 +6716,9 @@ importers: '@deepseek-ai/dsh-credentials-local': specifier: workspace:^ version: link:../../credentials/credentials-local + '@deepseek-ai/dsh-environment': + specifier: workspace:^ + version: link:../../util/environment '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -6598,6 +6738,9 @@ importers: specifier: ^3.18.0 version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/dsh-environment': + specifier: workspace:^ + version: link:../../util/environment '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -6614,6 +6757,9 @@ importers: specifier: ^3.18.0 version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/dsh-environment': + specifier: workspace:^ + version: link:../../util/environment '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -6873,6 +7019,9 @@ importers: '@deepseek-ai/dsh-credentials': specifier: workspace:^ version: link:../../packages/credentials/credentials + '@deepseek-ai/dsh-environment': + specifier: workspace:^ + version: link:../../packages/util/environment '@deepseek-ai/dsh-fs': specifier: workspace:^ version: link:../../packages/fs/fs @@ -7077,6 +7226,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 @@ -10387,10 +10539,6 @@ packages: dompurify@3.4.11: resolution: {integrity: sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==} - dotenv@17.4.2: - resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} - engines: {node: '>=12'} - dts-resolver@3.0.0: resolution: {integrity: sha512-1T1f+z+4tl9XD+m+0HBgWoL/nm0bOIffyWaUuUSBlFg/86IWvfx+wjNaO/ybU0AJzG9/Mi5hBUgGV6zCmWEN7Q==} engines: {node: ^22.18.0 || >=24.0.0} @@ -15440,8 +15588,6 @@ snapshots: optionalDependencies: '@types/trusted-types': 2.0.7 - dotenv@17.4.2: {} - dts-resolver@3.0.0(oxc-resolver@11.20.0): optionalDependencies: oxc-resolver: 11.20.0 diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index 4fd7291633..1cf370661c 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -25,6 +25,7 @@ "@deepseek-ai/dsh-compact-basic": "workspace:^", "@deepseek-ai/dsh-compact-tool-result-prune": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^", + "@deepseek-ai/dsh-environment": "workspace:^", "@deepseek-ai/dsh-fs": "workspace:^", "@deepseek-ai/dsh-fs-local": "workspace:^", "@deepseek-ai/dsh-fs-policy": "workspace:^", @@ -93,6 +94,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/python/sdk-runtime/src/deepseek_harness_runtime/runtime/cordis.yml b/python/sdk-runtime/src/deepseek_harness_runtime/runtime/cordis.yml index 2f35e58d43..318bda59b0 100644 --- a/python/sdk-runtime/src/deepseek_harness_runtime/runtime/cordis.yml +++ b/python/sdk-runtime/src/deepseek_harness_runtime/runtime/cordis.yml @@ -13,13 +13,12 @@ workspaceContext: maxBytes: 65536 -# Stock DeepSeek adapters. Loading requires an API key; initialize and shutdown -# may use a dummy key because they do not call the model. +# Stock DeepSeek adapters. The adapter resolves DEEPSEEK_API_KEY through the +# credential seam and, with no provider mounted here, from the launching +# environment; DEEPSEEK_BASE_URL follows the same environment ladder. Neither +# is inlined, so this file names no secret and no route. - id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' - config: - apiKey: !!js process.env.DEEPSEEK_API_KEY - baseURL: !!js process.env.DEEPSEEK_BASE_URL # JSONL persistence; $DSH_SESSION_ROOT wins over ./.sessions in the process cwd. - id: sessions diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index 37ac29a1a5..5b05a7385f 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 @@ -126,6 +126,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 @@ -149,9 +150,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] @@ -179,8 +208,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 20b9eeaea0..4a33f32e1e 100644 --- a/scripts/publish-npm-baseline.ts +++ b/scripts/publish-npm-baseline.ts @@ -18,7 +18,7 @@ import { basename, dirname, isAbsolute, join, normalize, relative, resolve, sep import { createInterface } from 'node:readline/promises' import { pathToFileURL } from 'node:url' import { parseArgs } from 'node:util' -import { validateTarballPayload } from './publication-payload.ts' +import { hasTypeRTRemoteNavigation, validateTarballPayload } from './publication-payload.ts' const DEFAULT_REGISTRY = 'https://registry.npm.harnessment.com' const DEFAULT_OUTPUT_DIRECTORY = '.artifacts/npm-baseline' @@ -320,7 +320,11 @@ class ReleaseBundle { if (expected === undefined || !missingNames.delete(artifact.name)) { throw new Error(`unexpected or duplicate packed package: ${artifact.name}`) } - if (expected.origin === 'harness') validateTarballPayload(artifact.files, tarball) + if (expected.origin === 'harness') { + validateTarballPayload(artifact.files, tarball, { + typeRTRemoteNavigation: hasTypeRTRemoteNavigation(artifact.manifest), + }) + } if (artifact.version !== version) { throw new Error(`${tarball} has version ${artifact.version}; expected ${version}`) } @@ -394,7 +398,11 @@ class ReleaseBundle { throw new Error(`tarball checksum mismatch: ${pkg.tarball}`) } const artifact = inspectTarball(path, runner) - if (pkg.origin === 'harness') validateTarballPayload(artifact.files, pkg.tarball) + if (pkg.origin === 'harness') { + validateTarballPayload(artifact.files, pkg.tarball, { + typeRTRemoteNavigation: hasTypeRTRemoteNavigation(artifact.manifest), + }) + } if (artifact.name !== pkg.name || artifact.version !== this.manifest.version) { throw new Error(`tarball identity mismatch: ${pkg.tarball}`) } diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index f7669eac7e..c1c3e1699c 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, @@ -569,6 +574,7 @@ function docSyncLeafGates(options: { pnpmScript('markdown-links', 'verify-md-links', { label: 'markdown links' }), pnpmScript('doc-refs', 'verify-doc-refs', { label: 'doc refs' }), pnpmScript('package-paths', 'verify-package-paths', { label: 'package paths' }), + pnpmScript('config-source-ownership', 'verify-config-source-ownership', { label: 'config source ownership' }), pnpmScript('package-readme-model-experience', 'verify-package-readme-model-experience', { label: 'package README model experience' }), pnpmScript('mermaid', 'verify-mermaid'), pnpmScript('agent-note-classification', 'verify-agent-note-classification', { label: 'agent note classification' }), @@ -601,6 +607,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-config-source-ownership.spec.ts b/scripts/verify-config-source-ownership.spec.ts new file mode 100644 index 0000000000..41026c5fe4 --- /dev/null +++ b/scripts/verify-config-source-ownership.spec.ts @@ -0,0 +1,30 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { collectConfigSourceOwnershipViolations } from './verify-config-source-ownership.ts' + +const roots: string[] = [] + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) +}) + +describe('configuration source ownership gate', () => { + it('rejects inline endpoints in shipped bundle patches', () => { + const root = mkdtempSync(join(tmpdir(), 'dsh-config-source-ownership-')) + roots.push(root) + const directory = join(root, 'packages/bundle/base') + mkdirSync(directory, { recursive: true }) + writeFileSync( + join(directory, 'cordis.patch.yml'), + 'config:\n baseURL: !!js process.env.DEEPSEEK_SEARCH_BASE_URL\n', + ) + + expect(collectConfigSourceOwnershipViolations(root)).toEqual([ + 'packages/bundle/base/cordis.patch.yml:2: inlines a credential or endpoint from the environment.' + + ' The adapter resolves apiKeyEnv through ctx.credentials and the endpoint through the' + + ' environment snapshot; inlining here bypasses both ladders.', + ]) + }) +}) diff --git a/scripts/verify-config-source-ownership.ts b/scripts/verify-config-source-ownership.ts new file mode 100644 index 0000000000..b0f4b89cdf --- /dev/null +++ b/scripts/verify-config-source-ownership.ts @@ -0,0 +1,56 @@ +/** + * Gate for forbidden credential or endpoint environment inlines in shipped + * Cordis configuration. + * @module scripts/verify-config-source-ownership + */ + +import { globSync, readFileSync } from 'node:fs' +import { resolve, sep } from 'node:path' + +const ROOT = resolve(import.meta.dirname, '..') + +/** Shipped Cordis configuration these rules apply to. */ +const SHIPPED_CONFIG_GLOBS = [ + 'apps/*/config/*.yml', + 'examples/*/*.cordis.yml', + 'examples/*/cordis.yml', + 'packages/bundle/*/cordis.patch.yml', + // The Python runtime ships its own default composition inside the wheel. + 'python/*/src/**/cordis.yml', +] + +/** Ordinary single-line forms this narrow source-shape check rejects; not full YAML analysis. */ +const INLINE_DENY = /^\s*(apiKey|baseURL|apiKeyEnv|authToken|headers)\s*:\s*!!js\b/ + +/** Return every forbidden inline environment form in shipped configuration. */ +export function collectConfigSourceOwnershipViolations(root: string): string[] { + const failures: string[] = [] + for (const glob of SHIPPED_CONFIG_GLOBS) { + for (const file of globSync(glob, { cwd: root })) { + const rel = file.split(sep).join('/') + readFileSync(resolve(root, rel), 'utf8').split('\n').forEach((line, index) => { + if (!INLINE_DENY.test(line)) return + failures.push( + `${rel}:${String(index + 1)}: inlines a credential or endpoint from the environment.` + + ' The adapter resolves apiKeyEnv through ctx.credentials and the endpoint through the' + + ' environment snapshot; inlining here bypasses both ladders.', + ) + }) + } + } + return failures +} + +if (process.argv[1] && import.meta.filename === resolve(process.argv[1])) { + const failures = collectConfigSourceOwnershipViolations(ROOT) + if (failures.length > 0) { + process.stderr.write('verify-config-source-ownership: configuration source ownership violated:\n') + for (const failure of failures) process.stderr.write(` ${failure}\n`) + process.exit(1) + } + + process.stdout.write( + 'verify-config-source-ownership: no credential or endpoint uses the ordinary inline environment form' + + ' in shipped configuration.\n', + ) +} diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 663501c763..1c3ebb9488 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -33,6 +33,7 @@ const NO_MODEL_EXPERIENCE_SECTION: Readonly> = { 'packages/core/scope': 'The package is a model-agnostic registration and lifecycle primitive; model-facing consumers own any context selection.', 'packages/util/brand': 'The package is a type-only primitive erased at compile time.', 'packages/util/paths': 'The package only resolves harness-owned host paths; model-facing consumers own any rendered use.', + 'packages/util/environment': 'The package only resolves host environment values; model-facing consumers own any rendered use.', } /** @@ -57,6 +58,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.' }, @@ -126,6 +128,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 8e6f3789b7..376ec36571 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -91,6 +91,7 @@ { "path": "./vendor/hmr" }, { "path": "./vendor/logger-console" }, { "path": "./packages/util/brand" }, + { "path": "./packages/util/environment" }, { "path": "./packages/util/native-command" }, { "path": "./packages/util/paths" }, { "path": "./packages/util/timeout" }, @@ -100,7 +101,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 dc0848ff0d..e89c129078 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 @@ -104,7 +104,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). @@ -115,7 +115,7 @@ export default defineConfig({ // always fork. projects: [ { - plugins: [pathsPlugin()], + plugins: [pathsPlugin(), standardDecoratorPlugin()], test: { name: 'thread-safe', execArgv: vitestExecArgv, @@ -135,7 +135,7 @@ export default defineConfig({ }, }, { - plugins: [pathsPlugin()], + plugins: [pathsPlugin(), standardDecoratorPlugin()], test: { name: 'process-bound', execArgv: vitestExecArgv, @@ -195,6 +195,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'],