From dcbfd8079ae2b1b44c7eeaa7551b442baa740057 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 20 Oct 2026 10:59:59 +0800 Subject: [PATCH 01/85] feat(experimental): bind code host change requests to Sessions Add the experimental forge capability seam: - dsh-experimental-forge: provider-neutral repository and change request references, a provider registry, and shared status polling. Watchers of one change request share one loop; each forge has one batched fetch in flight, and the cadence follows check, mergeability, and lifecycle state. - dsh-experimental-forge-github: GitHub provider that runs the GitHub CLI on the host with a scrubbed environment and reads every due pull request of a host in one GraphQL query. - dsh-experimental-forge-session: a host-only projection derives Session bindings from gh pr create and git push shell results and from plugin:forge-session/binding records; push results trigger head-branch discovery of open pull requests, and Session watchers poll bound change requests through ctx.forge. --- ...-session-change-request-bindings.i18n.yaml | 19 ++ ...6-10-09-session-change-request-bindings.md | 31 ++ ...0-09-session-change-request-bindings.zh.md | 31 ++ docs/capability-seams.i18n.yaml | 4 +- docs/capability-seams.md | 11 + docs/capability-seams.zh.md | 11 + docs/config-catalog.md | 62 ++++ docs/config-catalog.zh.md | 62 ++++ docs/event-producer-consumer.md | 2 +- docs/event-producer-consumer.zh.md | 2 +- ...experimental-persistence-catalog.i18n.yaml | 4 +- docs/experimental-persistence-catalog.md | 1 + docs/experimental-persistence-catalog.zh.md | 1 + docs/module-graph.md | 11 + docs/module-graph.zh.md | 11 + docs/subsystems/README.i18n.yaml | 4 +- docs/subsystems/README.md | 1 + docs/subsystems/README.zh.md | 1 + docs/subsystems/forge.i18n.yaml | 16 + docs/subsystems/forge.md | 299 +++++++++++++++++ docs/subsystems/forge.zh.md | 299 +++++++++++++++++ package.json | 5 +- packages/experimental/README.i18n.yaml | 8 +- packages/experimental/README.md | 4 + packages/experimental/README.zh.md | 4 + .../forge-github/README.i18n.yaml | 37 +++ packages/experimental/forge-github/README.md | 100 ++++++ .../experimental/forge-github/README.zh.md | 100 ++++++ .../experimental/forge-github/package.json | 45 +++ packages/experimental/forge-github/src/gh.ts | 56 ++++ .../experimental/forge-github/src/graphql.ts | 281 ++++++++++++++++ .../experimental/forge-github/src/index.ts | 48 +++ .../experimental/forge-github/src/provider.ts | 100 ++++++ .../experimental/forge-github/src/urls.ts | 59 ++++ .../forge-github/tests/fixtures.ts | 56 ++++ .../forge-github/tests/gh.spec.ts | 80 +++++ .../forge-github/tests/graphql.spec.ts | 203 ++++++++++++ .../forge-github/tests/plugin.spec.ts | 60 ++++ .../forge-github/tests/provider.spec.ts | 118 +++++++ .../forge-github/tests/urls.spec.ts | 60 ++++ .../experimental/forge-github/tsconfig.json | 30 ++ .../forge-session/README.i18n.yaml | 37 +++ packages/experimental/forge-session/README.md | 101 ++++++ .../experimental/forge-session/README.zh.md | 101 ++++++ .../experimental/forge-session/package.json | 61 ++++ .../forge-session/src/commands.ts | 103 ++++++ .../experimental/forge-session/src/index.ts | 223 +++++++++++++ .../forge-session/src/projection.ts | 133 ++++++++ .../experimental/forge-session/src/types.ts | 92 ++++++ .../forge-session/tests/commands.spec.ts | 125 +++++++ .../forge-session/tests/fixtures.ts | 171 ++++++++++ .../tests/loader-composition.spec.ts | 215 ++++++++++++ .../forge-session/tests/projection.spec.ts | 193 +++++++++++ .../forge-session/tests/service.spec.ts | 298 +++++++++++++++++ .../experimental/forge-session/tsconfig.json | 36 ++ packages/experimental/forge/README.i18n.yaml | 37 +++ packages/experimental/forge/README.md | 94 ++++++ packages/experimental/forge/README.zh.md | 94 ++++++ packages/experimental/forge/package.json | 40 +++ packages/experimental/forge/src/index.ts | 278 ++++++++++++++++ packages/experimental/forge/src/keys.ts | 15 + packages/experimental/forge/src/schedule.ts | 32 ++ packages/experimental/forge/src/types.ts | 151 +++++++++ .../experimental/forge/tests/fake-provider.ts | 106 ++++++ .../experimental/forge/tests/registry.spec.ts | 90 +++++ .../experimental/forge/tests/watch.spec.ts | 309 ++++++++++++++++++ packages/experimental/forge/tsconfig.json | 27 ++ .../extensions/tool-cordis/src/api-catalog.ts | 150 +++++++++ .../references/packages.md | 3 + pnpm-lock.yaml | 120 +++++++ scripts/gen-cordis-catalog.ts | 9 + scripts/gen-doc-graphs.ts | 16 + scripts/release/families.spec.ts | 3 + scripts/type-equiv.manifest.json | 50 +++ .../verify-package-readme-model-experience.ts | 3 + tsconfig.base.json | 3 + tsconfig.host.json | 3 + vitest.config.ts | 4 + 78 files changed, 5850 insertions(+), 13 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-10-09-session-change-request-bindings.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-10-09-session-change-request-bindings.md create mode 100644 .agents/notes/implemented/feature/2026-10-09-session-change-request-bindings.zh.md create mode 100644 docs/subsystems/forge.i18n.yaml create mode 100644 docs/subsystems/forge.md create mode 100644 docs/subsystems/forge.zh.md create mode 100644 packages/experimental/forge-github/README.i18n.yaml create mode 100644 packages/experimental/forge-github/README.md create mode 100644 packages/experimental/forge-github/README.zh.md create mode 100644 packages/experimental/forge-github/package.json create mode 100644 packages/experimental/forge-github/src/gh.ts create mode 100644 packages/experimental/forge-github/src/graphql.ts create mode 100644 packages/experimental/forge-github/src/index.ts create mode 100644 packages/experimental/forge-github/src/provider.ts create mode 100644 packages/experimental/forge-github/src/urls.ts create mode 100644 packages/experimental/forge-github/tests/fixtures.ts create mode 100644 packages/experimental/forge-github/tests/gh.spec.ts create mode 100644 packages/experimental/forge-github/tests/graphql.spec.ts create mode 100644 packages/experimental/forge-github/tests/plugin.spec.ts create mode 100644 packages/experimental/forge-github/tests/provider.spec.ts create mode 100644 packages/experimental/forge-github/tests/urls.spec.ts create mode 100644 packages/experimental/forge-github/tsconfig.json create mode 100644 packages/experimental/forge-session/README.i18n.yaml create mode 100644 packages/experimental/forge-session/README.md create mode 100644 packages/experimental/forge-session/README.zh.md create mode 100644 packages/experimental/forge-session/package.json create mode 100644 packages/experimental/forge-session/src/commands.ts create mode 100644 packages/experimental/forge-session/src/index.ts create mode 100644 packages/experimental/forge-session/src/projection.ts create mode 100644 packages/experimental/forge-session/src/types.ts create mode 100644 packages/experimental/forge-session/tests/commands.spec.ts create mode 100644 packages/experimental/forge-session/tests/fixtures.ts create mode 100644 packages/experimental/forge-session/tests/loader-composition.spec.ts create mode 100644 packages/experimental/forge-session/tests/projection.spec.ts create mode 100644 packages/experimental/forge-session/tests/service.spec.ts create mode 100644 packages/experimental/forge-session/tsconfig.json create mode 100644 packages/experimental/forge/README.i18n.yaml create mode 100644 packages/experimental/forge/README.md create mode 100644 packages/experimental/forge/README.zh.md create mode 100644 packages/experimental/forge/package.json create mode 100644 packages/experimental/forge/src/index.ts create mode 100644 packages/experimental/forge/src/keys.ts create mode 100644 packages/experimental/forge/src/schedule.ts create mode 100644 packages/experimental/forge/src/types.ts create mode 100644 packages/experimental/forge/tests/fake-provider.ts create mode 100644 packages/experimental/forge/tests/registry.spec.ts create mode 100644 packages/experimental/forge/tests/watch.spec.ts create mode 100644 packages/experimental/forge/tsconfig.json diff --git a/.agents/notes/implemented/feature/2026-10-09-session-change-request-bindings.i18n.yaml b/.agents/notes/implemented/feature/2026-10-09-session-change-request-bindings.i18n.yaml new file mode 100644 index 0000000000..877a85b4ba --- /dev/null +++ b/.agents/notes/implemented/feature/2026-10-09-session-change-request-bindings.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-10-09-session-change-request-bindings.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-10-09-session-change-request-bindings.md +/agent-note-session-change-request-bindings: + en: 89ec58adddf53fec + zh: 919e8557d9923fdb +/agent-note-session-change-request-bindings/problem: + en: e736740546fb8219 + zh: 46a16a7a633716ec +/agent-note-session-change-request-bindings/decision: + en: c91bbf1c6e224e2e + zh: 8c48d97146457381 +/agent-note-session-change-request-bindings/alternatives-considered: + en: 15fdaef4e432fc72 + zh: 8d36c859bd89f403 +/agent-note-session-change-request-bindings/consequences: + en: 932453846bdb0c0a + zh: 14068a18104ece4d diff --git a/.agents/notes/implemented/feature/2026-10-09-session-change-request-bindings.md b/.agents/notes/implemented/feature/2026-10-09-session-change-request-bindings.md new file mode 100644 index 0000000000..8f466a3da1 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-10-09-session-change-request-bindings.md @@ -0,0 +1,31 @@ +# Agent Note: Session change request bindings + +Status: implemented + +English | [中文](2026-10-09-session-change-request-bindings.zh.md) + +## Problem + +A Session that opens or updates pull requests leaves them only in its shell output. Neither the user nor later consumers have a list of the change requests the Session works on or a shared view of their status. Code hosts differ in URLs, command-line tools, and status models, and any association must survive fork and resume without changing what the model sees. + +## Decision + +The experimental forge capability seam has three roles. [`dsh-experimental-forge`](../../../../packages/experimental/forge/README.md) defines provider-neutral repository and change request references, a provider registry, and shared polling: watchers of one change request share one loop, each forge has one batched fetch in flight, and the cadence follows check, mergeability, and lifecycle state. [`dsh-experimental-forge-github`](../../../../packages/experimental/forge-github/README.md) is the first provider; it reads GitHub through the GitHub CLI on the host, so credentials stay in that tool's own store and never enter Session execution worlds. [`dsh-experimental-forge-session`](../../../../packages/experimental/forge-session/README.md) binds change requests to Sessions. + +Bindings derive from the Session log instead of a separate store. A host-only projection reads `bash` and `pwsh` calls: `gh pr create` results bind the URLs they print, and `git push` results record the branches they updated. Caller actions and discovery results are `plugin:forge-session/binding` records. Because every input is a log event, fork and resume carry bindings without new persistence, and the projection rebuilds them from any checkpoint. The printed URL or ref line is the evidence, not the exit status: an existing pull request reported by a failing `gh pr create` still binds, and a partially rejected push still records the branches it updated. + +After each push result, the service looks up open change requests for the pushed branches through the provider and records the unbound ones. A dismissed binding stays hidden from discovery and returns only through `track`. Polled status is not logged; it is process-local state that exists while a watcher displays the Session. + +## Alternatives considered + +**A host-side binding database.** A separate store would need its own fork, resume, and deletion rules; plugin records and projections already provide them. + +**A model tool that registers each change request.** The association would depend on the model calling an extra tool, and every request would carry its schema. Parsing the commands the model already runs costs no tokens. + +**Logging polled status in the Session.** Status changes independently of the conversation. Logging it would grow the log with every poll and expose stale snapshots to later readers. + +**Running `gh` through the Session's subprocess provider.** Remote and sandboxed execution worlds would need GitHub credentials, and status would depend on the world a Session uses. The host CLI keeps one credential owner. + +## Consequences + +Bindings appear without model cooperation for the common `gh pr create` and `git push` flows and add no model tokens. Commands run through scripts, aliases, or other command-line tools are not recognized, and a change request opened elsewhere appears only after another push or a `track`. Another provider adds its own access and URL forms behind the same provider contract without changing the binding rules or the polling. The host needs `gh` signed in for each GitHub host. diff --git a/.agents/notes/implemented/feature/2026-10-09-session-change-request-bindings.zh.md b/.agents/notes/implemented/feature/2026-10-09-session-change-request-bindings.zh.md new file mode 100644 index 0000000000..24d9703c4d --- /dev/null +++ b/.agents/notes/implemented/feature/2026-10-09-session-change-request-bindings.zh.md @@ -0,0 +1,31 @@ +# Agent Note: Session 变更请求绑定 + +Status: implemented + +[English](2026-10-09-session-change-request-bindings.md) | 中文 + +## Problem + +打开或更新 pull request 的 Session 只在 shell 输出里留下它们。用户和后续消费方都无法得到该 Session 正在处理的变更请求(change request)列表,也无法共享查看其状态。各代码托管平台的 URL、命令行工具与状态模型各不相同,而这种关联必须在 fork 与恢复后保留,且不能改变模型看到的内容。 + +## Decision + +实验性的 forge 能力 seam 由三个角色组成。[`dsh-experimental-forge`](../../../../packages/experimental/forge/README.zh.md) 定义提供方无关的仓库与变更请求引用、提供方注册表与共享轮询:同一变更请求的监听方共享一个循环,每个 forge 同时只有一次批量读取,轮询节奏随检查、可合并性与生命周期状态变化。[`dsh-experimental-forge-github`](../../../../packages/experimental/forge-github/README.zh.md) 是第一个提供方;它通过宿主上的 GitHub CLI 读取 GitHub,因此凭据保留在该工具自己的存储中,从不进入 Session 的执行环境。[`dsh-experimental-forge-session`](../../../../packages/experimental/forge-session/README.zh.md) 把变更请求绑定到 Session。 + +绑定从 Session 日志派生,而不是存放在单独的存储中。一个仅宿主侧的投影读取 `bash` 与 `pwsh` 调用:`gh pr create` 的结果绑定其输出的 URL,`git push` 的结果记录其更新的分支。调用方操作与发现结果是 `plugin:forge-session/binding` 插件记录。由于所有输入都是日志事件,fork 与恢复无需新的持久化即可带上绑定,投影也能从任意检查点重建它们。证据是输出中的 URL 或 ref 行,而不是退出状态:失败的 `gh pr create` 报告的已有 pull request 仍会绑定,部分被拒绝的推送仍会记录它已更新的分支。 + +每次推送结果之后,服务通过提供方为被推送的分支查找打开状态的变更请求,并记录尚未绑定的那些。被隐藏的绑定不会被发现逻辑恢复,只能通过 `track` 恢复。轮询到的状态不写入日志;它是进程内状态,仅在有监听方展示该 Session 时存在。 + +## Alternatives considered + +**宿主侧绑定数据库。** 单独的存储需要自己的 fork、恢复与删除规则;插件记录与投影已经提供了这些规则。 + +**由模型工具登记每个变更请求。** 关联将取决于模型是否调用额外的工具,而且每个请求都要携带该工具的 schema。解析模型本来就会运行的命令不消耗 token。 + +**把轮询状态写入 Session 日志。** 状态的变化独立于对话。写入日志会让日志随每次轮询增长,并把过期快照暴露给后续读取方。 + +**通过 Session 的子进程提供方运行 `gh`。** 远端与沙箱执行环境将需要 GitHub 凭据,状态也会取决于 Session 使用的执行环境。宿主 CLI 让凭据只有一个所有者。 + +## Consequences + +在常见的 `gh pr create` 与 `git push` 流程中,绑定无需模型配合即可出现,且不增加模型 token。通过脚本、别名或其他命令行工具运行的命令无法识别;在别处打开的变更请求要等到下一次推送或 `track` 才会出现。新的提供方在同一提供方契约之后加入自己的访问方式与 URL 形式,无需改变绑定规则或轮询。宿主需要为每个 GitHub 主机登录 `gh`。 diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index b35db923e6..9162441ce9 100644 --- a/docs/capability-seams.i18n.yaml +++ b/docs/capability-seams.i18n.yaml @@ -6,5 +6,5 @@ en: 41d772192075e133 zh: 5ef3da69b4de1d8a /capability-seams-and-core-services: - en: 3e87315977a89b5a - zh: 626a75f3bebaa39f + en: cd68131cccc623de + zh: 9c977cdc8767be10 diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 7c66c89b46..9002a66c72 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -29,6 +29,11 @@ flowchart LR svc_connection["ctx.connection
Authenticated browser transport"] pkg_api_gateway["api-gateway"] pkg_host_frontend_static["host-frontend-static"] + pkg_experimental_forge["experimental-forge"] + svc_forge["ctx.forge
Code host change requests"] + pkg_experimental_forge_github["experimental-forge-github"] + pkg_experimental_forge_session["experimental-forge-session"] + svc_forgeSession["ctx.forgeSession
Session change request bindings"] pkg_experimental_worktree["experimental-worktree"] svc_worktrees["ctx.worktrees
Git worktree creation"] pkg_experimental_tool_worktree["experimental-tool-worktree"] @@ -352,6 +357,9 @@ flowchart LR pkg_experimental_client_ui_cot_translation --> svc_cotTranslation pkg_experimental_computer_use_cua_driver_mcp --> svc_computerUse pkg_experimental_computer_use_cua_driver_native --> svc_computerUse + pkg_experimental_forge --> svc_forge + pkg_experimental_forge_github --> svc_forge + pkg_experimental_forge_session --> svc_forgeSession pkg_experimental_ptc_runtime_python --> svc_ptcRuntime pkg_experimental_speech_to_text --> svc_speechToText pkg_experimental_speech_to_text_sensevoice --> svc_speechToText @@ -499,6 +507,7 @@ flowchart LR svc_dynamicCordisRunner --> pkg_tool_cordis svc_fileReferences --> pkg_api_session_controller svc_fileUploads --> pkg_api_session_controller + svc_forge --> pkg_experimental_forge_session svc_fs --> pkg_tool_fs svc_headlessStartup --> pkg_headless svc_hmr --> pkg_app_boot @@ -631,6 +640,8 @@ flowchart LR | `ctx.pluginMarket` | `core` | [`plugin-market`](../packages/boot/plugin-market) | - | [`client-ui-plugin-market`](../packages/client/ui-plugin-market), [`plugin-market`](../packages/boot/plugin-market) | - | Fetches and validates the index sources the user configures; installation goes through the plugin manager. | | `ctx.profileContext` | `core` | [`app-boot`](../packages/boot/app-boot) | - | [`plugin-manager`](../packages/boot/plugin-manager) | - | The dsh launcher supplies data-only profile locations and composition inputs; reload scheduling belongs to dsh-hmr. | | `ctx.connection` | `core` | [`client-connection`](../packages/client/connection) | - | [`api-gateway`](../packages/api/gateway), [`host-frontend-static`](../packages/host/frontend-static) | - | Owns browser authentication and shared HTTP request dispatch; API adapters register endpoints and streams. | +| `ctx.forge` | `seam` | [`experimental-forge`](../packages/experimental/forge) | [`experimental-forge-github`](../packages/experimental/forge-github) | [`experimental-forge-session`](../packages/experimental/forge-session) | - | Providers map remote and change request URLs, look up change requests by head branch, and read status in batches; watchers of one change request share one polling loop. | +| `ctx.forgeSession` | `service` | [`experimental-forge-session`](../packages/experimental/forge-session) | - | - | - | A host-only projection derives bindings from shell results and binding records; push results trigger head-branch discovery, and Session watchers poll bound change requests through the forge registry. | | `ctx.worktrees` | `seam` | [`experimental-worktree`](../packages/experimental/worktree) | [`experimental-worktree`](../packages/experimental/worktree) | [`experimental-tool-worktree`](../packages/experimental/tool-worktree) | - | Explicit experimental creation from a pinned local commit. Existing write permissions govern checkout and shared Git metadata; the working-directory service owns the resulting Session directory. | | `ctx.workingDirectory` | `seam` | [`working-directory`](../packages/session/working-directory) | [`working-directory`](../packages/session/working-directory) | [`tool-working-directory`](../packages/session/tool-working-directory), [`tool-fs`](../packages/fs/tool-fs), [`tool-bash`](../packages/shell/tool-bash), [`tool-pwsh`](../packages/shell/tool-pwsh), [`subagent`](../packages/subagent/subagent), [`sdk-jsonrpc-server`](../packages/sdk/server) | - | One Session projection owns the effective execution directory. The filesystem validates changes; user context reports them while original metadata and write grants stay fixed. | | `ctx.mcpResources` | `seam` | [`mcp-resources`](../packages/mcp/mcp-resources) | [`mcp-client`](../packages/mcp/mcp-client) | [`mcp-resources`](../packages/mcp/mcp-resources) | - | Connection-owned providers serve shared resource tools in the calling agent scope. | diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index 8e649183bc..8c926481d6 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -31,6 +31,11 @@ flowchart LR svc_connection["ctx.connection
Authenticated browser transport"] pkg_api_gateway["api-gateway"] pkg_host_frontend_static["host-frontend-static"] + pkg_experimental_forge["experimental-forge"] + svc_forge["ctx.forge
Code host change requests"] + pkg_experimental_forge_github["experimental-forge-github"] + pkg_experimental_forge_session["experimental-forge-session"] + svc_forgeSession["ctx.forgeSession
Session change request bindings"] pkg_experimental_worktree["experimental-worktree"] svc_worktrees["ctx.worktrees
Git worktree creation"] pkg_experimental_tool_worktree["experimental-tool-worktree"] @@ -354,6 +359,9 @@ flowchart LR pkg_experimental_client_ui_cot_translation --> svc_cotTranslation pkg_experimental_computer_use_cua_driver_mcp --> svc_computerUse pkg_experimental_computer_use_cua_driver_native --> svc_computerUse + pkg_experimental_forge --> svc_forge + pkg_experimental_forge_github --> svc_forge + pkg_experimental_forge_session --> svc_forgeSession pkg_experimental_ptc_runtime_python --> svc_ptcRuntime pkg_experimental_speech_to_text --> svc_speechToText pkg_experimental_speech_to_text_sensevoice --> svc_speechToText @@ -501,6 +509,7 @@ flowchart LR svc_dynamicCordisRunner --> pkg_tool_cordis svc_fileReferences --> pkg_api_session_controller svc_fileUploads --> pkg_api_session_controller + svc_forge --> pkg_experimental_forge_session svc_fs --> pkg_tool_fs svc_headlessStartup --> pkg_headless svc_hmr --> pkg_app_boot @@ -633,6 +642,8 @@ flowchart LR | `ctx.pluginMarket` | `core` | [`plugin-market`](../packages/boot/plugin-market) | - | [`client-ui-plugin-market`](../packages/client/ui-plugin-market), [`plugin-market`](../packages/boot/plugin-market) | - | 获取并校验用户配置的索引源;安装通过插件管理器完成。 | | `ctx.profileContext` | `core` | [`app-boot`](../packages/boot/app-boot) | - | [`plugin-manager`](../packages/boot/plugin-manager) | - | dsh launcher 提供纯数据形式的 profile 位置与组合输入;重载调度由 dsh-hmr 负责。 | | `ctx.connection` | `core` | [`client-connection`](../packages/client/connection) | - | [`api-gateway`](../packages/api/gateway), [`host-frontend-static`](../packages/host/frontend-static) | - | 负责浏览器认证与共享 HTTP 请求分发;API 适配器注册端点和流。 | +| `ctx.forge` | `seam` | [`experimental-forge`](../packages/experimental/forge) | [`experimental-forge-github`](../packages/experimental/forge-github) | [`experimental-forge-session`](../packages/experimental/forge-session) | - | 提供方映射远端与变更请求 URL、按 head 分支查找变更请求并批量读取状态;同一变更请求的监听方共享一个轮询循环。 | +| `ctx.forgeSession` | `service` | [`experimental-forge-session`](../packages/experimental/forge-session) | - | - | - | 仅宿主侧的投影从 shell 结果与绑定记录派生绑定;推送结果触发按 head 分支的发现,Session 监听方通过 forge 注册表轮询已绑定的变更请求。 | | `ctx.worktrees` | `seam` | [`experimental-worktree`](../packages/experimental/worktree) | [`experimental-worktree`](../packages/experimental/worktree) | [`experimental-tool-worktree`](../packages/experimental/tool-worktree) | - | 显式实验组合从固定的本地提交创建工作树。检出目录与共享 Git 元数据受现有写权限约束;工作目录服务拥有创建后的 Session 目录。 | | `ctx.workingDirectory` | `seam` | [`working-directory`](../packages/session/working-directory) | [`working-directory`](../packages/session/working-directory) | [`tool-working-directory`](../packages/session/tool-working-directory), [`tool-fs`](../packages/fs/tool-fs), [`tool-bash`](../packages/shell/tool-bash), [`tool-pwsh`](../packages/shell/tool-pwsh), [`subagent`](../packages/subagent/subagent), [`sdk-jsonrpc-server`](../packages/sdk/server) | - | 一个 Session 投影拥有有效执行目录。文件系统验证变更,用户上下文报告目录信息,原始元数据与写入授权保持不变。 | | `ctx.mcpResources` | `seam` | [`mcp-resources`](../packages/mcp/mcp-resources) | [`mcp-client`](../packages/mcp/mcp-client) | [`mcp-resources`](../packages/mcp/mcp-resources) | - | 连接所有者提供的操作在调用 agent 的作用域内服务于共享资源工具。 | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index bc34e4f61f..6b96f3a40e 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1049,6 +1049,68 @@ export type Config = CuaConfig ``` + + + +## `@deepseek-ai/dsh-experimental-forge` + +- `source`: [`packages/experimental/forge/src/types.ts:142`](../packages/experimental/forge/src/types.ts) + +```ts config-catalog +/** Poll cadence and fetch deadline for watched change requests. */ +export interface Config { + /** Poll interval in milliseconds while checks run or mergeability is unknown. */ + activeIntervalMs?: number + /** Poll interval in milliseconds for an open change request whose checks have settled. */ + idleIntervalMs?: number + /** Largest delay in milliseconds between polls after consecutive failures. */ + maxBackoffMs?: number + /** Deadline in milliseconds for one provider fetch. */ + fetchTimeoutMs?: number +} +``` + + + + + +## `@deepseek-ai/dsh-experimental-forge-github` + +- `inject`: `forge` +- `source`: [`packages/experimental/forge-github/src/index.ts:14`](../packages/experimental/forge-github/src/index.ts) + +```ts config-catalog +/** GitHub CLI executable, served hosts, and invocation limits. */ +export interface Config { + /** GitHub CLI executable name or absolute host path. */ + ghCommand?: string + /** Host names this provider serves; list GitHub Enterprise hosts explicitly. */ + hosts?: string[] + /** Deadline in milliseconds for one `gh` invocation. */ + timeoutMs?: number + /** Maximum captured bytes per `gh` output stream. */ + maxOutputBytes?: number +} +``` + + + + + +## `@deepseek-ai/dsh-experimental-forge-session` + +- `inject`: `forge` · `sessionProjections` +- `source`: [`packages/experimental/forge-session/src/types.ts:82`](../packages/experimental/forge-session/src/types.ts) + +```ts config-catalog +/** Deployment settings of `forgeSession`. */ +export interface Config { + /** Deadline in milliseconds for one change request lookup after a push. */ + discoveryTimeoutMs?: number +} +``` + + diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 7a4c2a2fc3..7bb196013a 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -1051,6 +1051,68 @@ export type Config = CuaConfig ``` + + + +## `@deepseek-ai/dsh-experimental-forge` + +- `source`: [`packages/experimental/forge/src/types.ts:142`](../packages/experimental/forge/src/types.ts) + +```ts config-catalog +/** Poll cadence and fetch deadline for watched change requests. */ +export interface Config { + /** Poll interval in milliseconds while checks run or mergeability is unknown. */ + activeIntervalMs?: number + /** Poll interval in milliseconds for an open change request whose checks have settled. */ + idleIntervalMs?: number + /** Largest delay in milliseconds between polls after consecutive failures. */ + maxBackoffMs?: number + /** Deadline in milliseconds for one provider fetch. */ + fetchTimeoutMs?: number +} +``` + + + + + +## `@deepseek-ai/dsh-experimental-forge-github` + +- `inject`: `forge` +- `source`: [`packages/experimental/forge-github/src/index.ts:14`](../packages/experimental/forge-github/src/index.ts) + +```ts config-catalog +/** GitHub CLI executable, served hosts, and invocation limits. */ +export interface Config { + /** GitHub CLI executable name or absolute host path. */ + ghCommand?: string + /** Host names this provider serves; list GitHub Enterprise hosts explicitly. */ + hosts?: string[] + /** Deadline in milliseconds for one `gh` invocation. */ + timeoutMs?: number + /** Maximum captured bytes per `gh` output stream. */ + maxOutputBytes?: number +} +``` + + + + + +## `@deepseek-ai/dsh-experimental-forge-session` + +- `inject`: `forge` · `sessionProjections` +- `source`: [`packages/experimental/forge-session/src/types.ts:82`](../packages/experimental/forge-session/src/types.ts) + +```ts config-catalog +/** Deployment settings of `forgeSession`. */ +export interface Config { + /** Deadline in milliseconds for one change request lookup after a push. */ + discoveryTimeoutMs?: number +} +``` + + diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 99d814ce08..1d6e8423b4 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -66,7 +66,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:44`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | | `session/created` | `emit` | [`packages/core/session/src/index.ts:54`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`permission-presets`](../packages/interaction/permission-presets), [`schedule`](../packages/schedule/schedule), `server`, `session-controller`, [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:63`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:74`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `agent-team`, `claude-code-mods`, [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`loader-smoke`](../packages/test-support/loader-smoke), `product-analytics`, [`ptc-runtime-node`](../packages/ptc-runtime/ptc-runtime-node), `server`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-questions`](../packages/interaction/user-questions), [`workspace-changes`](../packages/deliverables/workspace-changes) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:74`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `agent-team`, `claude-code-mods`, [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, `forge-session`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`loader-smoke`](../packages/test-support/loader-smoke), `product-analytics`, [`ptc-runtime-node`](../packages/ptc-runtime/ptc-runtime-node), `server`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-questions`](../packages/interaction/user-questions), [`workspace-changes`](../packages/deliverables/workspace-changes) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:82`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) | | `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:75`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`emit`) | `remotes` | | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:296`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index ea4ee02415..7faa56c644 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -68,7 +68,7 @@ | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:44`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | | `session/created` | `emit` | [`packages/core/session/src/index.ts:54`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`permission-presets`](../packages/interaction/permission-presets), [`schedule`](../packages/schedule/schedule), `server`, `session-controller`, [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:63`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:74`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `agent-team`, `claude-code-mods`, [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`loader-smoke`](../packages/test-support/loader-smoke), `product-analytics`, [`ptc-runtime-node`](../packages/ptc-runtime/ptc-runtime-node), `server`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-questions`](../packages/interaction/user-questions), [`workspace-changes`](../packages/deliverables/workspace-changes) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:74`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `agent-team`, `claude-code-mods`, [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, `forge-session`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`loader-smoke`](../packages/test-support/loader-smoke), `product-analytics`, [`ptc-runtime-node`](../packages/ptc-runtime/ptc-runtime-node), `server`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-questions`](../packages/interaction/user-questions), [`workspace-changes`](../packages/deliverables/workspace-changes) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:82`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) | | `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:75`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`emit`) | `remotes` | | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:296`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - | diff --git a/docs/experimental-persistence-catalog.i18n.yaml b/docs/experimental-persistence-catalog.i18n.yaml index a65825ab71..1511a6adc2 100644 --- a/docs/experimental-persistence-catalog.i18n.yaml +++ b/docs/experimental-persistence-catalog.i18n.yaml @@ -6,5 +6,5 @@ en: a11f106978c9dce7 zh: a11f106978c9dce7 /experimental-plugin-record-catalog: - en: 84dbc9c06ab92877 - zh: b82ef2a46292ab0e + en: 90e85bc15ccdeb75 + zh: 3fd9e6d80b2bc689 diff --git a/docs/experimental-persistence-catalog.md b/docs/experimental-persistence-catalog.md index 0868849c51..245685d8e4 100644 --- a/docs/experimental-persistence-catalog.md +++ b/docs/experimental-persistence-catalog.md @@ -13,5 +13,6 @@ Shared Session event declarations and resolved schemas appear in the [Session pe | Record | Owner | Purpose | Payload and source | |---|---|---|---| +| `plugin:forge-session/binding` | `@deepseek-ai/dsh-experimental-forge-session` | A caller or discovery action that binds, restores, or hides one change request. | BindingRecord · [`packages/experimental/forge-session/src/types.ts`](../packages/experimental/forge-session/src/types.ts) | | `plugin:translator/request` | `@deepseek-ai/dsh-experimental-translator` | Provider-independent translation input, retained before dispatch. | TranslationRequestRecord · [`packages/experimental/translator/src/types.ts`](../packages/experimental/translator/src/types.ts) | | `plugin:translator/result` | `@deepseek-ai/dsh-experimental-translator` | Successful translation, retained before it is returned to the consumer. | TranslationResultRecord · [`packages/experimental/translator/src/types.ts`](../packages/experimental/translator/src/types.ts) | diff --git a/docs/experimental-persistence-catalog.zh.md b/docs/experimental-persistence-catalog.zh.md index 32cb1d0533..d9a22569e6 100644 --- a/docs/experimental-persistence-catalog.zh.md +++ b/docs/experimental-persistence-catalog.zh.md @@ -13,5 +13,6 @@ | 记录 | 所属包 | 用途 | 载荷与源码 | |---|---|---|---| +| `plugin:forge-session/binding` | `@deepseek-ai/dsh-experimental-forge-session` | A caller or discovery action that binds, restores, or hides one change request. | BindingRecord · [`packages/experimental/forge-session/src/types.ts`](../packages/experimental/forge-session/src/types.ts) | | `plugin:translator/request` | `@deepseek-ai/dsh-experimental-translator` | Provider-independent translation input, retained before dispatch. | TranslationRequestRecord · [`packages/experimental/translator/src/types.ts`](../packages/experimental/translator/src/types.ts) | | `plugin:translator/result` | `@deepseek-ai/dsh-experimental-translator` | Successful translation, retained before it is returned to the consumer. | TranslationResultRecord · [`packages/experimental/translator/src/types.ts`](../packages/experimental/translator/src/types.ts) | diff --git a/docs/module-graph.md b/docs/module-graph.md index 30610b57ba..6e6a0c340e 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -268,6 +268,9 @@ flowchart TD pkg_experimental_computer_use_cua_driver_native["experimental-computer-use-cua-driver-native"] pkg_experimental_cot_translation_bundle["experimental-cot-translation-bundle"] pkg_experimental_cua_macos["experimental-cua-macos"] + pkg_experimental_forge["experimental-forge"] + pkg_experimental_forge_github["experimental-forge-github"] + pkg_experimental_forge_session["experimental-forge-session"] pkg_experimental_inspector["experimental-inspector"] pkg_experimental_inspector_profile["experimental-inspector-profile"] pkg_experimental_plugin_market_bundle["experimental-plugin-market-bundle"] @@ -504,6 +507,8 @@ flowchart TD pkg_hmr --> pkg_app_boot pkg_hmr --> pkg_cmdline pkg_experimental_computer_use_cua_driver_mcp --> pkg_computer_use + pkg_experimental_forge_github --> pkg_experimental_forge + pkg_experimental_forge_github --> pkg_subprocess pkg_experimental_webworker_runtime --> pkg_client_connection pkg_experimental_webworker_runtime --> pkg_client_modules pkg_experimental_webworker_runtime --> pkg_host_webserver @@ -543,6 +548,9 @@ flowchart TD pkg_session_log_export --> pkg_session_persistence pkg_config_editor --> pkg_app_boot pkg_config_editor --> pkg_hmr + pkg_experimental_forge_session --> pkg_experimental_forge + pkg_experimental_forge_session --> pkg_session + pkg_experimental_forge_session --> pkg_session_projection pkg_experimental_speech_to_text --> pkg_settings pkg_experimental_translator --> pkg_credentials pkg_experimental_translator --> pkg_launch_environment @@ -1545,6 +1553,7 @@ flowchart TD | [`experimental-client-ui-claude-code-mods`](../packages/experimental/client-ui-claude-code-mods) | `experimental` | — | | [`experimental-client-ui-voice-input`](../packages/experimental/client-ui-voice-input) | `experimental` | — | | [`experimental-cot-translation-bundle`](../packages/experimental/cot-translation-bundle) | `experimental` | — | +| [`experimental-forge`](../packages/experimental/forge) | `experimental` | — | | [`experimental-inspector`](../packages/experimental/inspector) | `experimental` | — | | [`experimental-inspector-profile`](../packages/experimental/inspector-profile) | `experimental` | — | | [`experimental-plugin-market-bundle`](../packages/experimental/plugin-market-bundle) | `experimental` | — | @@ -1603,6 +1612,7 @@ flowchart TD | [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`home-paths`](../packages/util/home-paths) | | [`hmr`](../packages/boot/hmr) | `boot` | [`app-boot`](../packages/boot/app-boot), [`cmdline`](../packages/boot/cmdline) | | [`experimental-computer-use-cua-driver-mcp`](../packages/experimental/computer-use-cua-driver-mcp) | `experimental` | [`computer-use`](../packages/computer-use/computer-use) | +| [`experimental-forge-github`](../packages/experimental/forge-github) | `experimental` | [`experimental-forge`](../packages/experimental/forge), [`subprocess`](../packages/subprocess/subprocess) | | [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver) | | [`skill-badge`](../packages/experimental/skill-badge) | `experimental` | [`skill`](../packages/skill/skill) | | [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`client-connection`](../packages/client/connection), [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse), [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver) | @@ -1622,6 +1632,7 @@ flowchart TD | [`spill-local`](../packages/spill/spill-local) | `spill` | [`spill`](../packages/spill/spill) | | [`session-log-export`](../packages/session-query/session-log-export) | `session-query` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) | | [`config-editor`](../packages/boot/config-editor) | `boot` | [`app-boot`](../packages/boot/app-boot), [`hmr`](../packages/boot/hmr) | +| [`experimental-forge-session`](../packages/experimental/forge-session) | `experimental` | [`experimental-forge`](../packages/experimental/forge), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | | [`experimental-speech-to-text`](../packages/experimental/speech-to-text) | `experimental` | [`settings`](../packages/settings/settings) | | [`experimental-translator`](../packages/experimental/translator) | `experimental` | [`credentials`](../packages/credentials/credentials), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`timeout`](../packages/util/timeout), [`util-values`](../packages/util/values) | | [`host-product-telemetry-otel`](../packages/host/product-telemetry-otel) | `host` | [`otel`](../packages/telemetry/otel) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index f190ca7079..8430790bdf 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -270,6 +270,9 @@ flowchart TD pkg_experimental_computer_use_cua_driver_native["experimental-computer-use-cua-driver-native"] pkg_experimental_cot_translation_bundle["experimental-cot-translation-bundle"] pkg_experimental_cua_macos["experimental-cua-macos"] + pkg_experimental_forge["experimental-forge"] + pkg_experimental_forge_github["experimental-forge-github"] + pkg_experimental_forge_session["experimental-forge-session"] pkg_experimental_inspector["experimental-inspector"] pkg_experimental_inspector_profile["experimental-inspector-profile"] pkg_experimental_plugin_market_bundle["experimental-plugin-market-bundle"] @@ -506,6 +509,8 @@ flowchart TD pkg_hmr --> pkg_app_boot pkg_hmr --> pkg_cmdline pkg_experimental_computer_use_cua_driver_mcp --> pkg_computer_use + pkg_experimental_forge_github --> pkg_experimental_forge + pkg_experimental_forge_github --> pkg_subprocess pkg_experimental_webworker_runtime --> pkg_client_connection pkg_experimental_webworker_runtime --> pkg_client_modules pkg_experimental_webworker_runtime --> pkg_host_webserver @@ -545,6 +550,9 @@ flowchart TD pkg_session_log_export --> pkg_session_persistence pkg_config_editor --> pkg_app_boot pkg_config_editor --> pkg_hmr + pkg_experimental_forge_session --> pkg_experimental_forge + pkg_experimental_forge_session --> pkg_session + pkg_experimental_forge_session --> pkg_session_projection pkg_experimental_speech_to_text --> pkg_settings pkg_experimental_translator --> pkg_credentials pkg_experimental_translator --> pkg_launch_environment @@ -1547,6 +1555,7 @@ flowchart TD | [`experimental-client-ui-claude-code-mods`](../packages/experimental/client-ui-claude-code-mods) | `experimental` | — | | [`experimental-client-ui-voice-input`](../packages/experimental/client-ui-voice-input) | `experimental` | — | | [`experimental-cot-translation-bundle`](../packages/experimental/cot-translation-bundle) | `experimental` | — | +| [`experimental-forge`](../packages/experimental/forge) | `experimental` | — | | [`experimental-inspector`](../packages/experimental/inspector) | `experimental` | — | | [`experimental-inspector-profile`](../packages/experimental/inspector-profile) | `experimental` | — | | [`experimental-plugin-market-bundle`](../packages/experimental/plugin-market-bundle) | `experimental` | — | @@ -1605,6 +1614,7 @@ flowchart TD | [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`home-paths`](../packages/util/home-paths) | | [`hmr`](../packages/boot/hmr) | `boot` | [`app-boot`](../packages/boot/app-boot), [`cmdline`](../packages/boot/cmdline) | | [`experimental-computer-use-cua-driver-mcp`](../packages/experimental/computer-use-cua-driver-mcp) | `experimental` | [`computer-use`](../packages/computer-use/computer-use) | +| [`experimental-forge-github`](../packages/experimental/forge-github) | `experimental` | [`experimental-forge`](../packages/experimental/forge), [`subprocess`](../packages/subprocess/subprocess) | | [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver) | | [`skill-badge`](../packages/experimental/skill-badge) | `experimental` | [`skill`](../packages/skill/skill) | | [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`client-connection`](../packages/client/connection), [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse), [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver) | @@ -1624,6 +1634,7 @@ flowchart TD | [`spill-local`](../packages/spill/spill-local) | `spill` | [`spill`](../packages/spill/spill) | | [`session-log-export`](../packages/session-query/session-log-export) | `session-query` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) | | [`config-editor`](../packages/boot/config-editor) | `boot` | [`app-boot`](../packages/boot/app-boot), [`hmr`](../packages/boot/hmr) | +| [`experimental-forge-session`](../packages/experimental/forge-session) | `experimental` | [`experimental-forge`](../packages/experimental/forge), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | | [`experimental-speech-to-text`](../packages/experimental/speech-to-text) | `experimental` | [`settings`](../packages/settings/settings) | | [`experimental-translator`](../packages/experimental/translator) | `experimental` | [`credentials`](../packages/credentials/credentials), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`timeout`](../packages/util/timeout), [`util-values`](../packages/util/values) | | [`host-product-telemetry-otel`](../packages/host/product-telemetry-otel) | `host` | [`otel`](../packages/telemetry/otel) | diff --git a/docs/subsystems/README.i18n.yaml b/docs/subsystems/README.i18n.yaml index ec9d159bbb..7f6b7de64c 100644 --- a/docs/subsystems/README.i18n.yaml +++ b/docs/subsystems/README.i18n.yaml @@ -3,5 +3,5 @@ # After editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/README.md /subsystems: - en: 1d4818dbb07391a7 - zh: f1e27ce66c181295 + en: dcbb2f834453e166 + zh: d73f32a7baeec42d diff --git a/docs/subsystems/README.md b/docs/subsystems/README.md index c2a47fb70b..e14535c2f6 100644 --- a/docs/subsystems/README.md +++ b/docs/subsystems/README.md @@ -20,6 +20,7 @@ One page per subsystem of the DeepSeek Harness: what it is, the data structures | [session.md](session.md) | the full `SessionEventMap` variant catalog, `TurnEndReason`, `deriveMessages()`, execution enclosure, and standalone events | | [working-directory.md](working-directory.md) | Session execution directories, durable changes, recovery, and user context | | [worktrees.md](worktrees.md) | Experimental Git branch and checkout creation under existing permissions | +| [forge.md](forge.md) | Experimental code host change requests: provider contract, shared status polling, and Session bindings | | [persistence.md](persistence.md) | the durability seam: `SessionPersistence`, the JSONL provider, `session/flush`, crash recovery, `SessionHeader` | | [settings.md](settings.md) | the user-settings seam: `SettingsNamespace` registration, layered resolution (defaults → composition `base` → user document), owner scopes, hot commits | | [credentials.md](credentials.md) | the credential seam: `CredentialRef` references (never values) in configuration, per-operation resolution, UI-safe `CredentialInfo`, provider source layers | diff --git a/docs/subsystems/README.zh.md b/docs/subsystems/README.zh.md index a1380713bc..6239e5f2c0 100644 --- a/docs/subsystems/README.zh.md +++ b/docs/subsystems/README.zh.md @@ -20,6 +20,7 @@ | [session.md](session.zh.md) | 完整的 `SessionEventMap` 变体目录、`TurnEndReason`、`deriveMessages()`、执行封闭与独立事件 | | [working-directory.md](working-directory.zh.md) | Session 执行目录、持久化变更、恢复与用户上下文 | | [worktrees.md](worktrees.zh.md) | 现有权限下的实验性 Git 分支与检出目录创建 | +| [forge.md](forge.zh.md) | 实验性代码托管变更请求:提供方契约、共享状态轮询与 Session 绑定 | | [persistence.md](persistence.zh.md) | 持久性 seam:`SessionPersistence`、JSONL 提供方、`session/flush`、崩溃恢复、`SessionHeader` | | [settings.md](settings.zh.md) | 用户设置 seam:`SettingsNamespace` 注册、分层解析(默认值 → 组合 `base` → 用户文档)、owner scope、热提交 | | [credentials.md](credentials.zh.md) | 凭据 seam:配置中的 `CredentialRef` 引用(绝不含值)、按操作解析、对 UI 安全的 `CredentialInfo`、提供方来源层 | diff --git a/docs/subsystems/forge.i18n.yaml b/docs/subsystems/forge.i18n.yaml new file mode 100644 index 0000000000..0555d82c9f --- /dev/null +++ b/docs/subsystems/forge.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for forge.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/subsystems/forge.md +/forges: + en: 416233808b0653c3 + zh: 22fbac4b46e741ad +/forges/providers-and-change-requests: + en: 4ec7d1892b4067db + zh: 6bb70d21ae3d7a89 +/forges/status-polling: + en: 26ccfa2e342e3e67 + zh: fe6cc2a622757c48 +/forges/session-bindings: + en: 036a714f103fafd8 + zh: aa11325be880025d diff --git a/docs/subsystems/forge.md b/docs/subsystems/forge.md new file mode 100644 index 0000000000..8dc217841d --- /dev/null +++ b/docs/subsystems/forge.md @@ -0,0 +1,299 @@ +# Forges + +English | [中文](forge.zh.md) + +The experimental forge capability seam reads change requests, meaning pull requests and merge requests, from code hosts. [`dsh-experimental-forge`](../../packages/experimental/forge/README.md) is the Service Definition and provides `ctx.forge`; [`dsh-experimental-forge-github`](../../packages/experimental/forge-github/README.md) is the Service Provider for GitHub hosts; [`dsh-experimental-forge-session`](../../packages/experimental/forge-session/README.md) is the Consumer that binds change requests to Sessions and provides `ctx.forgeSession`. All three load only through explicit composition. + +## Providers and change requests + +A provider serves one forge family, such as `github`. It maps Git remote URLs to repositories and change request URLs to references, builds canonical change request URLs, finds open change requests by head branch, and reads status in batches. `changeRequestKey` derives one case-insensitive key from a reference, so watchers of differently spelled URLs share one polling loop. + +```ts type-equiv +/** One repository on a code host, identified the way its provider addresses it. */ +interface ForgeRepository { + /** Provider family that serves the repository, such as `github`. */ + readonly forge: string + /** Lowercase host name, such as `github.com`. */ + readonly host: string + /** Owner or namespace path as the host spells it. */ + readonly owner: string + /** Repository name as the host spells it. */ + readonly name: string +} +``` + +```ts type-equiv +/** One change request (pull request or merge request) in a repository. */ +interface ChangeRequestRef { + /** Repository that holds the change request. */ + readonly repository: ForgeRepository + /** Host-assigned change request number. */ + readonly number: number +} +``` + +```ts type-equiv +/** + * Host-specific access registered with `ctx.forge`. One provider serves one + * forge family; methods that take a signal must stop work when it aborts. + */ +interface ForgeProvider { + /** Forge family name stored in every repository this provider returns. */ + readonly forge: string + /** Map a Git remote URL to its repository, or undefined when this provider does not serve it. */ + repositoryFromRemote(url: string): ForgeRepository | undefined + /** Map a change request web URL to its reference, or undefined when this provider does not serve it. */ + changeRequestFromUrl(url: string): ChangeRequestRef | undefined + /** Build the canonical web URL of a change request this provider serves. */ + changeRequestUrl(ref: ChangeRequestRef): string + /** Read the status of several change requests, returning one result per requested key. */ + fetch( + refs: readonly ChangeRequestRef[], + signal: AbortSignal, + ): Promise> + /** Find open change requests whose head is `branch` in `repository`, excluding the default branch and forks. */ + findByHead(repository: ForgeRepository, branch: string, signal: AbortSignal): Promise +} +``` + +## Status polling + +`ctx.forge.watch` shares one polling loop among the watchers of a change request and delivers every result as an update. Each forge has at most one fetch in flight. The [registry README](../../packages/experimental/forge/README.md#use-this-package) owns the poll cadence, backoff, and timeout rules. + +```ts type-equiv +/** Status of one change request at the time its provider read it. */ +interface ChangeRequestSnapshot { + /** Change request the snapshot describes. */ + readonly ref: ChangeRequestRef + /** Web page of the change request. */ + readonly url: string + /** Title as the author wrote it. */ + readonly title: string + /** Lifecycle state. */ + readonly state: ChangeRequestState + /** Whether the author marked the change request as a draft. */ + readonly draft: boolean + /** Author login, or null when the host hides it. */ + readonly author: string | null + /** Branch that carries the proposed commits. */ + readonly headBranch: string + /** Commit object name at the head of the change request. */ + readonly headSha: string + /** Branch the change request merges into. */ + readonly baseBranch: string + /** Merge conflict status; `unknown` while the host computes it. */ + readonly mergeability: Mergeability + /** Aggregate review outcome. */ + readonly reviewDecision: ReviewDecision + /** Checks on the head commit in host order. */ + readonly checks: readonly ChangeRequestCheck[] + /** Combined check outcome. */ + readonly checksRollup: ChecksRollup + /** Whether the host reported more checks than `checks` contains. */ + readonly checksTruncated: boolean + /** Number of review threads that are not resolved. */ + readonly unresolvedThreads: number + /** ISO 8601 time of the host's last update to the change request. */ + readonly updatedAt: string +} +``` + +```ts type-equiv +/** Latest polling result delivered to watchers of one change request. */ +type ChangeRequestUpdate = { + /** Canonical identity of the watched change request. */ + readonly key: ChangeRequestKey + /** Epoch milliseconds when the result settled. */ + readonly fetchedAt: number +} & ({ + /** Status read from the host. */ + readonly snapshot: ChangeRequestSnapshot +} | { + /** Reason the status could not be read. */ + readonly failure: ForgeFailure +}) +``` + +```ts type-equiv +/** + * Receives every polling result for one watched change request. + * @param update - the settled result. + */ +type ChangeRequestListener = (update: ChangeRequestUpdate) => void +``` + +## Session bindings + +`ctx.forgeSession` derives each Session's bindings from its own log: `gh pr create` results bind the URLs they print, and `git push` results trigger head-branch discovery through `ctx.forge`. Track, dismiss, and discovery actions are `plugin:forge-session/binding` records, which stay outside the model-visible conversation. The [Session binding README](../../packages/experimental/forge-session/README.md#use-this-package) owns the recognition and discovery rules. + +```ts type-equiv +/** + * How a change request became bound to a Session: `created` when a + * `gh pr create` result printed its URL, `discovered` when a lookup after a + * `git push` found it for the pushed branch, `tracked` when a caller bound it + * by URL. + */ +type BindingOrigin = 'created' | 'discovered' | 'tracked' +``` + +```ts type-equiv +/** Payload of the `plugin:forge-session/binding` record. */ +interface BindingRecord { + /** `track` binds or restores, `dismiss` hides, `discover` binds a change request found for a pushed branch. */ + readonly action: 'track' | 'dismiss' | 'discover' + /** Web URL of the change request. */ + readonly url: string +} +``` + +```ts type-equiv +/** One change request bound to a Session and not dismissed, with its latest polling result. */ +interface SessionChangeRequest { + /** Web URL as first recorded. */ + readonly url: string + /** How the change request became bound. */ + readonly origin: BindingOrigin + /** Parsed reference, or null when no registered provider serves the URL. */ + readonly ref: ChangeRequestRef | null + /** Latest polling result, or null before the first poll and without a provider. */ + readonly update: ChangeRequestUpdate | null +} +``` + +```ts type-equiv +/** + * Receives the complete visible list after a binding change or a polling result. + * @param changeRequests - every visible binding of the watched Session, in first-recorded order. + */ +type SessionChangeRequestsListener = (changeRequests: readonly SessionChangeRequest[]) => void +``` + + + + + +## Cordis API + +Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md). + + + +### `ctx.forge` — `ForgeService` + +Registry of forge providers. Watchers of one change request share a single polling loop; each forge has at most one fetch in flight, which batches every due change request of that forge. + +```ts cordis-catalog +/** + * Register the provider of one forge family as an effect on the calling + * fiber. Watches of that forge receive an `unavailable` failure when the + * registration ends. + * @param provider - provider to serve `provider.forge`. + * @returns the disposer that unregisters the provider. + * @throws when another provider already serves the same forge. + */ +register(provider: ForgeProvider): () => void + +/** + * Map a Git remote URL to a repository through the first provider that serves it. + * @param url - fetch or push URL as Git reports it. + * @returns the repository, or undefined when no provider serves the URL. + */ +repositoryFromRemote(url: string): ForgeRepository | undefined + +/** + * Map a change request web URL through the first provider that serves it. + * @param url - web URL of a pull or merge request. + * @returns the change request, or undefined when no provider serves the URL. + */ +changeRequestFromUrl(url: string): ChangeRequestRef | undefined + +/** + * Build the canonical web URL of a change request. + * @param ref - change request served by a registered provider. + * @returns the provider's canonical URL. + * @throws when no provider serves the change request's forge. + */ +changeRequestUrl(ref: ChangeRequestRef): string + +/** + * Find open change requests whose head is `branch`, excluding the default branch and forks. + * @param repository - repository served by a registered provider. + * @param branch - head branch name on the host. + * @param signal - cancellation for the lookup. + * @returns matching change requests, newest first as the provider reports them. + * @throws when no provider serves the repository's forge or the lookup fails. + */ +async findByHead(repository: ForgeRepository, branch: string, signal: AbortSignal): Promise + +/** + * Watch one change request as an effect on the calling fiber. The first + * watcher starts polling at once; listeners receive every later result, + * and `latest` returns the current one. Polling stops when the last watcher + * leaves. Listeners must not throw; a thrown error is logged and skipped. + * @param ref - change request to poll. + * @param listener - receives each polling result. + * @returns the disposer that stops this watch. + */ +watch(ref: ChangeRequestRef, listener: ChangeRequestListener): () => void + +/** + * Read the latest polling result of a watched change request. + * @param ref - change request to read. + * @returns the latest result, or undefined before the first result or without a watch. + */ +latest(ref: ChangeRequestRef): ChangeRequestUpdate | undefined +``` + +Source: [`packages/experimental/forge/src/index.ts`](../../packages/experimental/forge/src/index.ts) + + + +### `ctx.forgeSession` — `ForgeSessionService` + +Session-scoped change request bindings. The `forgeChangeRequests` projection derives them from `gh pr create` and `git push` results and from `plugin:forge-session/binding` records. After each push result the service looks up open change requests whose head is a pushed branch and records the unbound ones as discovered. Watching a Session polls its visible change requests through `ctx.forge`. + +```ts cordis-catalog +/** + * List the change requests bound to a Session and not dismissed. + * @param session - Session whose bindings are read. + * @returns visible bindings in first-recorded order, each with its latest polling result. + */ +changeRequests(session: Session): SessionChangeRequest[] + +/** + * Bind a change request to a Session by URL, or restore a dismissed binding. + * Appends a `track` record with the provider's canonical URL unless the + * change request is already visible. + * @param session - Session that receives the binding. + * @param url - web URL of the change request. + * @throws when no registered provider recognizes the URL. + */ +track(session: Session, url: string): void + +/** + * Hide a visible binding. Appends a `dismiss` record; a URL that is not + * visibly bound leaves the log unchanged. Discovery never restores a + * dismissed binding; `track` does. + * @param session - Session that owns the binding. + * @param url - web URL of the change request. + */ +dismiss(session: Session, url: string): void + +/** + * Watch a Session's visible change requests as an effect on the calling + * fiber. Every visible change request that a provider recognizes is polled + * through `ctx.forge` while the Session has a watcher. Listeners receive the + * complete list after each later binding change or polling result; read the + * current list with {@link changeRequests}. Listeners must not throw; a + * thrown error is logged and skipped. + * @param session - Session to watch. + * @param listener - receives the complete visible list. + * @returns the disposer that stops this watch. + */ +watch(session: Session, listener: SessionChangeRequestsListener): () => void +``` + +Types: [Session](session.md) + +Source: [`packages/experimental/forge-session/src/index.ts`](../../packages/experimental/forge-session/src/index.ts) + diff --git a/docs/subsystems/forge.zh.md b/docs/subsystems/forge.zh.md new file mode 100644 index 0000000000..28b7d1d782 --- /dev/null +++ b/docs/subsystems/forge.zh.md @@ -0,0 +1,299 @@ +# Forges + +[English](forge.md) | 中文 + +实验性的 forge 能力 seam 从代码托管平台读取变更请求(change request,即 pull request 与 merge request)。[`dsh-experimental-forge`](../../packages/experimental/forge/README.zh.md) 是 Service Definition,提供 `ctx.forge`;[`dsh-experimental-forge-github`](../../packages/experimental/forge-github/README.zh.md) 是服务 GitHub 主机的 Service Provider;[`dsh-experimental-forge-session`](../../packages/experimental/forge-session/README.zh.md) 是把变更请求绑定到 Session 的 Consumer,提供 `ctx.forgeSession`。三者都只能通过显式组合加载。 + +## 提供方与变更请求 + +一个提供方服务一个 forge 家族,例如 `github`。它把 Git 远端 URL 映射为仓库,把变更请求 URL 映射为引用,构造规范的变更请求 URL,按 head 分支查找打开状态的变更请求,并批量读取状态。`changeRequestKey` 从引用派生一个不区分大小写的键,因此 URL 拼写不同的监听方共享同一个轮询循环。 + +```ts type-equiv +/** One repository on a code host, identified the way its provider addresses it. */ +interface ForgeRepository { + /** Provider family that serves the repository, such as `github`. */ + readonly forge: string + /** Lowercase host name, such as `github.com`. */ + readonly host: string + /** Owner or namespace path as the host spells it. */ + readonly owner: string + /** Repository name as the host spells it. */ + readonly name: string +} +``` + +```ts type-equiv +/** One change request (pull request or merge request) in a repository. */ +interface ChangeRequestRef { + /** Repository that holds the change request. */ + readonly repository: ForgeRepository + /** Host-assigned change request number. */ + readonly number: number +} +``` + +```ts type-equiv +/** + * Host-specific access registered with `ctx.forge`. One provider serves one + * forge family; methods that take a signal must stop work when it aborts. + */ +interface ForgeProvider { + /** Forge family name stored in every repository this provider returns. */ + readonly forge: string + /** Map a Git remote URL to its repository, or undefined when this provider does not serve it. */ + repositoryFromRemote(url: string): ForgeRepository | undefined + /** Map a change request web URL to its reference, or undefined when this provider does not serve it. */ + changeRequestFromUrl(url: string): ChangeRequestRef | undefined + /** Build the canonical web URL of a change request this provider serves. */ + changeRequestUrl(ref: ChangeRequestRef): string + /** Read the status of several change requests, returning one result per requested key. */ + fetch( + refs: readonly ChangeRequestRef[], + signal: AbortSignal, + ): Promise> + /** Find open change requests whose head is `branch` in `repository`, excluding the default branch and forks. */ + findByHead(repository: ForgeRepository, branch: string, signal: AbortSignal): Promise +} +``` + +## 状态轮询 + +`ctx.forge.watch` 让同一变更请求的所有监听方共享一个轮询循环,并把每次结果作为更新送达。每个 forge 同时最多只有一次读取在进行。轮询节奏、退避与超时规则见[注册表 README](../../packages/experimental/forge/README.zh.md#use-this-package)。 + +```ts type-equiv +/** Status of one change request at the time its provider read it. */ +interface ChangeRequestSnapshot { + /** Change request the snapshot describes. */ + readonly ref: ChangeRequestRef + /** Web page of the change request. */ + readonly url: string + /** Title as the author wrote it. */ + readonly title: string + /** Lifecycle state. */ + readonly state: ChangeRequestState + /** Whether the author marked the change request as a draft. */ + readonly draft: boolean + /** Author login, or null when the host hides it. */ + readonly author: string | null + /** Branch that carries the proposed commits. */ + readonly headBranch: string + /** Commit object name at the head of the change request. */ + readonly headSha: string + /** Branch the change request merges into. */ + readonly baseBranch: string + /** Merge conflict status; `unknown` while the host computes it. */ + readonly mergeability: Mergeability + /** Aggregate review outcome. */ + readonly reviewDecision: ReviewDecision + /** Checks on the head commit in host order. */ + readonly checks: readonly ChangeRequestCheck[] + /** Combined check outcome. */ + readonly checksRollup: ChecksRollup + /** Whether the host reported more checks than `checks` contains. */ + readonly checksTruncated: boolean + /** Number of review threads that are not resolved. */ + readonly unresolvedThreads: number + /** ISO 8601 time of the host's last update to the change request. */ + readonly updatedAt: string +} +``` + +```ts type-equiv +/** Latest polling result delivered to watchers of one change request. */ +type ChangeRequestUpdate = { + /** Canonical identity of the watched change request. */ + readonly key: ChangeRequestKey + /** Epoch milliseconds when the result settled. */ + readonly fetchedAt: number +} & ({ + /** Status read from the host. */ + readonly snapshot: ChangeRequestSnapshot +} | { + /** Reason the status could not be read. */ + readonly failure: ForgeFailure +}) +``` + +```ts type-equiv +/** + * Receives every polling result for one watched change request. + * @param update - the settled result. + */ +type ChangeRequestListener = (update: ChangeRequestUpdate) => void +``` + +## Session 绑定 + +`ctx.forgeSession` 从每个 Session 自己的日志派生其绑定:`gh pr create` 的结果绑定其输出的 URL,`git push` 的结果通过 `ctx.forge` 触发按 head 分支的发现。track、dismiss 与发现操作都是 `plugin:forge-session/binding` 插件记录,位于模型可见对话之外。识别与发现规则见 [Session 绑定 README](../../packages/experimental/forge-session/README.zh.md#use-this-package)。 + +```ts type-equiv +/** + * How a change request became bound to a Session: `created` when a + * `gh pr create` result printed its URL, `discovered` when a lookup after a + * `git push` found it for the pushed branch, `tracked` when a caller bound it + * by URL. + */ +type BindingOrigin = 'created' | 'discovered' | 'tracked' +``` + +```ts type-equiv +/** Payload of the `plugin:forge-session/binding` record. */ +interface BindingRecord { + /** `track` binds or restores, `dismiss` hides, `discover` binds a change request found for a pushed branch. */ + readonly action: 'track' | 'dismiss' | 'discover' + /** Web URL of the change request. */ + readonly url: string +} +``` + +```ts type-equiv +/** One change request bound to a Session and not dismissed, with its latest polling result. */ +interface SessionChangeRequest { + /** Web URL as first recorded. */ + readonly url: string + /** How the change request became bound. */ + readonly origin: BindingOrigin + /** Parsed reference, or null when no registered provider serves the URL. */ + readonly ref: ChangeRequestRef | null + /** Latest polling result, or null before the first poll and without a provider. */ + readonly update: ChangeRequestUpdate | null +} +``` + +```ts type-equiv +/** + * Receives the complete visible list after a binding change or a polling result. + * @param changeRequests - every visible binding of the watched Session, in first-recorded order. + */ +type SessionChangeRequestsListener = (changeRequests: readonly SessionChangeRequest[]) => void +``` + + + + + +## Cordis API + +Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.zh.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md). + + + +### `ctx.forge` — `ForgeService` + +Registry of forge providers. Watchers of one change request share a single polling loop; each forge has at most one fetch in flight, which batches every due change request of that forge. + +```ts cordis-catalog +/** + * Register the provider of one forge family as an effect on the calling + * fiber. Watches of that forge receive an `unavailable` failure when the + * registration ends. + * @param provider - provider to serve `provider.forge`. + * @returns the disposer that unregisters the provider. + * @throws when another provider already serves the same forge. + */ +register(provider: ForgeProvider): () => void + +/** + * Map a Git remote URL to a repository through the first provider that serves it. + * @param url - fetch or push URL as Git reports it. + * @returns the repository, or undefined when no provider serves the URL. + */ +repositoryFromRemote(url: string): ForgeRepository | undefined + +/** + * Map a change request web URL through the first provider that serves it. + * @param url - web URL of a pull or merge request. + * @returns the change request, or undefined when no provider serves the URL. + */ +changeRequestFromUrl(url: string): ChangeRequestRef | undefined + +/** + * Build the canonical web URL of a change request. + * @param ref - change request served by a registered provider. + * @returns the provider's canonical URL. + * @throws when no provider serves the change request's forge. + */ +changeRequestUrl(ref: ChangeRequestRef): string + +/** + * Find open change requests whose head is `branch`, excluding the default branch and forks. + * @param repository - repository served by a registered provider. + * @param branch - head branch name on the host. + * @param signal - cancellation for the lookup. + * @returns matching change requests, newest first as the provider reports them. + * @throws when no provider serves the repository's forge or the lookup fails. + */ +async findByHead(repository: ForgeRepository, branch: string, signal: AbortSignal): Promise + +/** + * Watch one change request as an effect on the calling fiber. The first + * watcher starts polling at once; listeners receive every later result, + * and `latest` returns the current one. Polling stops when the last watcher + * leaves. Listeners must not throw; a thrown error is logged and skipped. + * @param ref - change request to poll. + * @param listener - receives each polling result. + * @returns the disposer that stops this watch. + */ +watch(ref: ChangeRequestRef, listener: ChangeRequestListener): () => void + +/** + * Read the latest polling result of a watched change request. + * @param ref - change request to read. + * @returns the latest result, or undefined before the first result or without a watch. + */ +latest(ref: ChangeRequestRef): ChangeRequestUpdate | undefined +``` + +Source: [`packages/experimental/forge/src/index.ts`](../../packages/experimental/forge/src/index.ts) + + + +### `ctx.forgeSession` — `ForgeSessionService` + +Session-scoped change request bindings. The `forgeChangeRequests` projection derives them from `gh pr create` and `git push` results and from `plugin:forge-session/binding` records. After each push result the service looks up open change requests whose head is a pushed branch and records the unbound ones as discovered. Watching a Session polls its visible change requests through `ctx.forge`. + +```ts cordis-catalog +/** + * List the change requests bound to a Session and not dismissed. + * @param session - Session whose bindings are read. + * @returns visible bindings in first-recorded order, each with its latest polling result. + */ +changeRequests(session: Session): SessionChangeRequest[] + +/** + * Bind a change request to a Session by URL, or restore a dismissed binding. + * Appends a `track` record with the provider's canonical URL unless the + * change request is already visible. + * @param session - Session that receives the binding. + * @param url - web URL of the change request. + * @throws when no registered provider recognizes the URL. + */ +track(session: Session, url: string): void + +/** + * Hide a visible binding. Appends a `dismiss` record; a URL that is not + * visibly bound leaves the log unchanged. Discovery never restores a + * dismissed binding; `track` does. + * @param session - Session that owns the binding. + * @param url - web URL of the change request. + */ +dismiss(session: Session, url: string): void + +/** + * Watch a Session's visible change requests as an effect on the calling + * fiber. Every visible change request that a provider recognizes is polled + * through `ctx.forge` while the Session has a watcher. Listeners receive the + * complete list after each later binding change or polling result; read the + * current list with {@link changeRequests}. Listeners must not throw; a + * thrown error is logged and skipped. + * @param session - Session to watch. + * @param listener - receives the complete visible list. + * @returns the disposer that stops this watch. + */ +watch(session: Session, listener: SessionChangeRequestsListener): () => void +``` + +Types: [Session](session.zh.md) + +Source: [`packages/experimental/forge-session/src/index.ts`](../../packages/experimental/forge-session/src/index.ts) + diff --git a/package.json b/package.json index fd29dfb45f..a31492fa4f 100644 --- a/package.json +++ b/package.json @@ -294,6 +294,9 @@ "@deepseek-ai/dsh-cua-bundle": "workspace:*", "@deepseek-ai/dsh-experimental-subagent-oar": "workspace:*", "@deepseek-ai/dsh-experimental-tool-subagent-oar": "workspace:*", - "@deepseek-ai/dsh-experimental-cua-macos": "workspace:*" + "@deepseek-ai/dsh-experimental-cua-macos": "workspace:*", + "@deepseek-ai/dsh-experimental-forge": "workspace:*", + "@deepseek-ai/dsh-experimental-forge-github": "workspace:*", + "@deepseek-ai/dsh-experimental-forge-session": "workspace:*" } } diff --git a/packages/experimental/README.i18n.yaml b/packages/experimental/README.i18n.yaml index 2f9ac3db9b..051d8efc49 100644 --- a/packages/experimental/README.i18n.yaml +++ b/packages/experimental/README.i18n.yaml @@ -18,11 +18,11 @@ en: b55aeb927737b90d zh: 0b215fcb5c2c7908 /packages-experimental/packages: - en: b16e94185c9b8b24 - zh: 784faa79e027cd14 + en: eea6339298b79e08 + zh: de048fe6a49c8a04 /packages-experimental/related-documentation: - en: 1cb9d3448d44e48d - zh: cd326d70a746b896 + en: 3add3dbe9b625de7 + zh: cacba37c7834ff9d /packages-experimental/dev-note: en: 5de1d6304e00c58f zh: 8d8225361cfa29e5 diff --git a/packages/experimental/README.md b/packages/experimental/README.md index ba4fabaddf..bef3e07f40 100644 --- a/packages/experimental/README.md +++ b/packages/experimental/README.md @@ -86,6 +86,9 @@ Promotion requires a named product role, documented public behavior and limitati | [`webworker-packer`](webworker-packer/README.md) | Builds the gzip-compressed VFS image consumed by the browser worker preview | library and CLI — no ctx key | | [`webworker-runtime`](webworker-runtime/README.md) | Runs the harness plugin tree inside a dedicated browser worker | library and worker entry — no ctx key | | [`worktree`](worktree/README.md) | Creates a named Git worktree and changes the calling Session directory | `ctx.worktrees` | +| [`forge`](forge/README.md) | Code host provider registry with shared change request polling | `ctx.forge` | +| [`forge-github`](forge-github/README.md) | GitHub provider that reads pull requests through the host GitHub CLI | registers a provider on `ctx.forge` | +| [`forge-session`](forge-session/README.md) | Binds change requests to the Sessions that created, pushed, or tracked them | `ctx.forgeSession` | ----- @@ -94,6 +97,7 @@ Promotion requires a named product role, documented public behavior and limitati - [Experimental publication reference](../../scripts/experimental-package-policy.ts) — public defaults and private exceptions. - [Worktrees](../../docs/subsystems/worktrees.md) — explicit branch and checkout creation. +- [Forges](../../docs/subsystems/forge.md) — change request providers, status polling, and Session bindings. - [Computer use](../../docs/subsystems/computer-use.md) — desktop provider choices. - [Browser use](../../docs/subsystems/browser-use.md) — browser provider choices and Session ownership. - [Agent Teams subsystem](../../docs/subsystems/agent-team.md) — durable Team types and the `ctx.agentTeams` service API. diff --git a/packages/experimental/README.zh.md b/packages/experimental/README.zh.md index eb99c614fb..119c7cbef1 100644 --- a/packages/experimental/README.zh.md +++ b/packages/experimental/README.zh.md @@ -86,6 +86,9 @@ kind: "package-group" | [`webworker-packer`](webworker-packer/README.zh.md) | 构建浏览器 worker 预览所消费的 gzip 压缩虚拟文件系统(VFS)镜像 | 库与 CLI(命令行界面),不使用 ctx key | | [`webworker-runtime`](webworker-runtime/README.zh.md) | 在专用浏览器 worker 中运行 harness 插件树 | 库与 worker 入口,不使用 ctx key | | [`worktree`](worktree/README.zh.md) | 创建具名 Git 工作树并改变调用 Session 的当前目录 | `ctx.worktrees` | +| [`forge`](forge/README.zh.md) | 代码托管提供方注册表,共享变更请求轮询 | `ctx.forge` | +| [`forge-github`](forge-github/README.zh.md) | 通过宿主 GitHub CLI 读取 pull request 的 GitHub 提供方 | 在 `ctx.forge` 上注册提供方 | +| [`forge-session`](forge-session/README.zh.md) | 把变更请求绑定到创建、推送或跟踪它们的 Session | `ctx.forgeSession` | ----- @@ -94,6 +97,7 @@ kind: "package-group" - [实验包发布说明](../../scripts/experimental-package-policy.ts)——默认公开与私有例外。 - [工作树](../../docs/subsystems/worktrees.zh.md)——显式创建分支与检出目录。 +- [Forges](../../docs/subsystems/forge.zh.md)——变更请求提供方、状态轮询与 Session 绑定。 - [计算机操作](../../docs/subsystems/computer-use.zh.md)——桌面提供方选择。 - [浏览器操作](../../docs/subsystems/browser-use.zh.md)——浏览器提供方选择与 Session 所有权。 - [Agent Teams 子系统](../../docs/subsystems/agent-team.zh.md)——持久 Team 类型与 `ctx.agentTeams` 服务 API。 diff --git a/packages/experimental/forge-github/README.i18n.yaml b/packages/experimental/forge-github/README.i18n.yaml new file mode 100644 index 0000000000..aa17a574ec --- /dev/null +++ b/packages/experimental/forge-github/README.i18n.yaml @@ -0,0 +1,37 @@ +# Bilingual-pair consistency record for README.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/experimental/forge-github/README.md +/: + en: aa10564436254cf3 + zh: 879324735db16250 +/deepseek-ai-dsh-experimental-forge-github: + en: 1c2533aa62dd459c + zh: 3d26837df2bbf525 +/deepseek-ai-dsh-experimental-forge-github/summary: + en: 7017f5749108889c + zh: 31f456404aabd2e4 +/deepseek-ai-dsh-experimental-forge-github/table-of-contents: + en: d152484eb41ac6b4 + zh: 2f8a621b991260a1 +/deepseek-ai-dsh-experimental-forge-github/use-this-package: + en: b855a94e8936cded + zh: 3235478887a74764 +/deepseek-ai-dsh-experimental-forge-github/understand-the-implementation: + en: 194951c8b5093f7d + zh: 9c5ffaa9e531bd1f +/deepseek-ai-dsh-experimental-forge-github/further-exploration: + en: d377d5fd9d9f190e + zh: 920bcbfdb8c993f2 +/deepseek-ai-dsh-experimental-forge-github/model-experience: + en: 86d0ff768e7d9f5c + zh: b11b849ac4559df3 +/deepseek-ai-dsh-experimental-forge-github/model-experience/kv-cache-effect: + en: 26b946af4b742e17 + zh: 00b34f0721ac72f6 +/deepseek-ai-dsh-experimental-forge-github/known-limitations-and-deferred-work: + en: 441928a7cc59da3b + zh: 96496453ce349cad +/deepseek-ai-dsh-experimental-forge-github/known-limitations-and-deferred-work/dev-note: + en: e67f633dc6fe7773 + zh: 3bb0c20c93f16f98 diff --git a/packages/experimental/forge-github/README.md b/packages/experimental/forge-github/README.md new file mode 100644 index 0000000000..0e95b7844a --- /dev/null +++ b/packages/experimental/forge-github/README.md @@ -0,0 +1,100 @@ +--- +description: "Serve the experimental forge registry for GitHub hosts through the GitHub CLI on the host." +kind: "package-reference" +--- + +# @deepseek-ai/dsh-experimental-forge-github + +English | [中文](README.zh.md) + +## Summary + +Read GitHub and GitHub Enterprise pull requests for `ctx.forge` with the GitHub CLI that is already signed in on the host. One GraphQL query per host and poll reads state, checks, review decision, mergeability, and unresolved review threads for every due pull request. + +## Table of Contents + +- [Use this package](#use-this-package) +- [Understand the implementation](#understand-the-implementation) +- [Further Exploration](#further-exploration) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + +----- + + +## Use this package + +Mount the provider after the forge registry and list every GitHub Enterprise host explicitly. + +```yaml +- name: '@deepseek-ai/dsh-experimental-forge' +- name: '@deepseek-ai/dsh-experimental-forge-github' + config: + hosts: [github.com, ghe.example.com] +``` + +The provider runs `gh` as a host process, even when Sessions execute commands in another execution world. Sign in once per host with `gh auth login --hostname `. The child receives the host environment without `DSH_*` variables and without variables whose names contain `KEY`, `PASSWORD`, `SECRET`, or `TOKEN`, so `GH_TOKEN` and `GITHUB_TOKEN` are not forwarded and `gh` uses its own credential store. Prompts, update notices, pagers, and colors are disabled. `ghCommand`, `hosts`, `timeoutMs`, and `maxOutputBytes` are listed in the [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-experimental-forge-github). + +The provider serves a remote URL in `https`, `http`, `ssh`, or `git` form, or in scp form such as `git@github.com:owner/name.git`, when its host is configured. It serves pull request URLs of the form `https://///pull/` with any trailing path, query, or fragment, and builds that form without the suffix as the canonical URL. A head lookup returns up to 20 open pull requests whose head is the branch, newest first, excluding pull requests from forks; a lookup of the default branch returns none. + +Check runs map to `pending` until completed, then to `success`, `failure` (failure, timed out, startup failure, or action required), `cancelled`, `skipped`, or `neutral`. Commit status contexts map `SUCCESS` to `success`, `FAILURE` and `ERROR` to `failure`, and every other state to `pending`. A GraphQL `NOT_FOUND` error becomes a `not-found` failure. When `gh` prints no JSON, a diagnostic that mentions authentication or `gh auth login` becomes `unauthenticated` and any other diagnostic becomes `unavailable`. + +----- + + +## Understand the implementation + +
+Implementation internals — click to expand + +A fetch groups references by host and sends one `gh api graphql` query per host. The query inlines validated owner, name, and number values and gives each pull request an alias, so partial GraphQL errors fail only their own aliases. `gh` exits nonzero on partial errors but still prints the complete response, which the provider parses before it inspects the exit code. + +| Source | Responsibility | +|---|---| +| [`src/index.ts`](src/index.ts) | Configuration validation and provider registration | +| [`src/provider.ts`](src/provider.ts) | URL mapping, host grouping, and failure classification | +| [`src/graphql.ts`](src/graphql.ts) | Query construction and response validation and mapping | +| [`src/gh.ts`](src/gh.ts) | Host `gh` execution with environment scrubbing and limits | +| [`src/urls.ts`](src/urls.ts) | Remote URL and pull request URL parsing | + +
+ +----- + + +## Further Exploration + +- [Forge registry](../forge/README.md) — provider registration and shared polling. +- [Forge subsystem](../../../docs/subsystems/forge.md) — provider contract and change request types. +- [Subprocess environment](../../subprocess/subprocess/README.md) — the shared environment scrub. + +----- + + +## Model Experience + +None, as the provider reads pull request status for host-side watchers without contributing model context. + +#### KV Cache effect + +None. Provider output reaches only host-side watchers. + +## Known Limitations and Deferred Work + + + +- **GitHub CLI on the host** — `gh` must be installed and signed in for each configured host; tokens in environment variables are not forwarded. Hosts missing from `hosts` are never read. +- **First 100 checks and review threads** — `checksTruncated` marks a head commit with more checks; unresolved threads after the first 100 are not counted. +- **No fork discovery** — head lookups exclude pull requests whose head is in another repository. +- **Rate limits** — every poll costs one GraphQL query per host against the signed-in account's rate limit. + + +### Dev Note + +
+Working context for maintainers — click to expand + +None. + +
diff --git a/packages/experimental/forge-github/README.zh.md b/packages/experimental/forge-github/README.zh.md new file mode 100644 index 0000000000..a8e1aa7e75 --- /dev/null +++ b/packages/experimental/forge-github/README.zh.md @@ -0,0 +1,100 @@ +--- +description: "通过宿主上的 GitHub CLI 为实验性 forge 注册表服务 GitHub 主机。" +kind: "package-reference" +--- + +# @deepseek-ai/dsh-experimental-forge-github + +[English](README.md) | 中文 + +## 概述 + +借助宿主上已登录的 GitHub CLI,为 `ctx.forge` 读取 GitHub 与 GitHub Enterprise 的 pull request。每次轮询对每个主机只发一个 GraphQL 查询,读取所有到期 pull request 的状态、检查、评审结论、可合并性与未解决的评审讨论。 + +## 目录 + +- [使用本包](#use-this-package) +- [了解实现](#understand-the-implementation) +- [深入探索](#further-exploration) +- [模型体验](#model-experience) +- [已知限制与待办工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 使用本包 + +在 forge 注册表之后挂载提供方,并显式列出每个 GitHub Enterprise 主机。 + +```yaml +- name: '@deepseek-ai/dsh-experimental-forge' +- name: '@deepseek-ai/dsh-experimental-forge-github' + config: + hosts: [github.com, ghe.example.com] +``` + +即使 Session 在其他执行环境中运行命令,提供方也以宿主进程的形式运行 `gh`。每个主机执行一次 `gh auth login --hostname ` 完成登录。子进程继承的宿主环境去掉了 `DSH_*` 变量以及名称包含 `KEY`、`PASSWORD`、`SECRET` 或 `TOKEN` 的变量,因此 `GH_TOKEN` 与 `GITHUB_TOKEN` 不会被转发,`gh` 使用自己的凭据存储。交互提示、更新提醒、分页器与颜色输出均被关闭。`ghCommand`、`hosts`、`timeoutMs` 与 `maxOutputBytes` 见[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-experimental-forge-github)。 + +当主机在配置中时,提供方服务 `https`、`http`、`ssh`、`git` 形式的远端 URL,以及 `git@github.com:owner/name.git` 这类 scp 形式。它服务 `https://///pull/` 形式的 pull request URL,允许其后附带任意路径、查询或片段,并以去掉这些后缀的形式作为规范 URL。按 head 分支查找时,最多返回 20 个以该分支为 head 的打开状态 pull request,按创建时间从新到旧排列,不包含来自 fork 的 pull request;查找默认分支时返回空结果。 + +检查运行(check run)在完成前映射为 `pending`,完成后映射为 `success`、`failure`(失败、超时、启动失败或需要操作)、`cancelled`、`skipped` 或 `neutral`。提交状态上下文把 `SUCCESS` 映射为 `success`,把 `FAILURE` 与 `ERROR` 映射为 `failure`,其余状态均映射为 `pending`。GraphQL 的 `NOT_FOUND` 错误转为 `not-found` 失败。`gh` 没有输出 JSON 时,提到身份验证或 `gh auth login` 的诊断转为 `unauthenticated`,其他诊断转为 `unavailable`。 + +----- + + +## 了解实现 + +
+实现细节——点击展开 + +一次读取按主机对引用分组,每个主机发送一个 `gh api graphql` 查询。查询内联经过校验的 owner、name 与编号,并为每个 pull request 设置别名,因此局部 GraphQL 错误只影响对应的别名。出现局部错误时 `gh` 以非零状态退出,但仍会输出完整响应;提供方先解析该响应,再检查退出码。 + +| 源码 | 职责 | +|---|---| +| [`src/index.ts`](src/index.ts) | 配置校验与提供方注册 | +| [`src/provider.ts`](src/provider.ts) | URL 映射、按主机分组与失败分类 | +| [`src/graphql.ts`](src/graphql.ts) | 构造查询,并校验和映射响应 | +| [`src/gh.ts`](src/gh.ts) | 在宿主上执行 `gh`,清理环境变量并施加限制 | +| [`src/urls.ts`](src/urls.ts) | 解析远端 URL 与 pull request URL | + +
+ +----- + + +## 深入探索 + +- [Forge 注册表](../forge/README.zh.md)——提供方注册与共享轮询。 +- [Forge 子系统](../../../docs/subsystems/forge.zh.md)——提供方契约与变更请求类型。 +- [子进程环境](../../subprocess/subprocess/README.zh.md)——共享的环境变量清理规则。 + +----- + + +## 模型体验 + +无:提供方为宿主侧监听方读取 pull request 状态,不贡献模型上下文。 + +#### KV Cache 影响 + +无。提供方的输出只到达宿主侧监听方。 + +## 已知限制与待办工作 + + + +- **宿主需要 GitHub CLI**——必须安装 `gh` 并为每个已配置主机登录;环境变量中的令牌不会被转发。不在 `hosts` 中的主机永远不会被读取。 +- **前 100 个检查与评审讨论**——head 提交的检查超过上限时以 `checksTruncated` 标记;前 100 个之后的未解决讨论不计入。 +- **不发现 fork**——按 head 分支查找时,不包含 head 位于其他仓库的 pull request。 +- **速率限制**——每次轮询都会以已登录账号的速率配额为每个主机消耗一次 GraphQL 查询。 + + +### 开发备注 + +
+维护者的工作上下文——点击展开 + +无。 + +
diff --git a/packages/experimental/forge-github/package.json b/packages/experimental/forge-github/package.json new file mode 100644 index 0000000000..2ac35beb94 --- /dev/null +++ b/packages/experimental/forge-github/package.json @@ -0,0 +1,45 @@ +{ + "name": "@deepseek-ai/dsh-experimental-forge-github", + "description": "GitHub provider for the experimental forge registry, read through the GitHub CLI on the host", + "version": "0.2.1-rc.2", + "publishConfig": { + "access": "public" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/types/**/*.d.ts" + ], + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/experimental/forge-github" + }, + "dependencies": { + "@deepseek-ai/schemastery": "workspace:~", + "@deepseek-ai/dsh-timeout": "workspace:*", + "execa": "^10.0.1", + "zod": "^4.4.3" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-experimental-forge": "workspace:*", + "@deepseek-ai/dsh-subprocess": "workspace:*" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-experimental-forge": "workspace:*", + "@deepseek-ai/dsh-subprocess": "workspace:*" + } +} diff --git a/packages/experimental/forge-github/src/gh.ts b/packages/experimental/forge-github/src/gh.ts new file mode 100644 index 0000000000..02193f15ca --- /dev/null +++ b/packages/experimental/forge-github/src/gh.ts @@ -0,0 +1,56 @@ +/** Runs the GitHub CLI on the host with a scrubbed, non-interactive environment. */ + +import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess' +import { execa } from 'execa' + +/** Complete output of one `gh` invocation that ran to an exit code. */ +export interface GhOutput { + /** Process exit code. */ + readonly exitCode: number + /** Standard output without its final newline. */ + readonly stdout: string + /** Standard error without its final newline. */ + readonly stderr: string +} + +/** Runs `gh` with literal arguments; rejects when the process cannot run to an exit code. */ +export type GhRunner = (args: readonly string[], signal: AbortSignal) => Promise + +/** Resolved executable and limits for `gh`. */ +export interface GhConfig { + /** Executable name or absolute host path. */ + readonly ghCommand: string + /** Deadline in milliseconds for one invocation. */ + readonly timeoutMs: number + /** Maximum captured bytes per output stream. */ + readonly maxOutputBytes: number +} + +/** + * Build a runner for `gh` on the host. The child inherits the scrubbed parent + * environment, so variables whose names contain KEY, PASSWORD, SECRET, or + * TOKEN never reach it and `gh` authenticates from its own credential store. + * Prompts, update notices, pagers, and colors are disabled. + * @param config - executable and limits. + * @returns the runner. + */ +export function ghRunner(config: GhConfig): GhRunner { + return async (args, signal) => { + const result = await execa(config.ghCommand, args, { + env: { ...scrubbedParentEnv(), GH_PROMPT_DISABLED: '1', GH_NO_UPDATE_NOTIFIER: '1', GH_PAGER: '', NO_COLOR: '1', CLICOLOR: '0' }, + extendEnv: false, + stdin: 'ignore', + reject: false, + timeout: config.timeoutMs, + cancelSignal: signal, + maxBuffer: config.maxOutputBytes, + }) + const command = ['gh', ...args.slice(0, 1)].join(' ') + if (result.isCanceled) throw signal.reason + if (result.timedOut) throw new Error(`${command} timed out after ${config.timeoutMs}ms`) + if (result.isMaxBuffer) throw new Error(`${command} output exceeds maxOutputBytes`) + // A result without an exit code failed before the process ran; execa then always sets shortMessage. + if (result.exitCode === undefined) throw new Error(`gh could not start: ${String(result.shortMessage)}`) + return { exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr } + } +} diff --git a/packages/experimental/forge-github/src/graphql.ts b/packages/experimental/forge-github/src/graphql.ts new file mode 100644 index 0000000000..2f186eeb38 --- /dev/null +++ b/packages/experimental/forge-github/src/graphql.ts @@ -0,0 +1,281 @@ +/** GitHub GraphQL documents and their mapping into provider-neutral results. */ + +import { changeRequestKey } from '@deepseek-ai/dsh-experimental-forge' +import type { + ChangeRequestCheck, ChangeRequestKey, ChangeRequestRef, ChangeRequestSnapshot, CheckState, ChecksRollup, ForgeFailure, ForgeRepository, + Mergeability, ReviewDecision, +} from '@deepseek-ai/dsh-experimental-forge' +import { z } from 'zod' +import { isSegment } from './urls.ts' + +const PULL_REQUEST_FIELDS = `number url title state isDraft updatedAt + author { login } + headRefName headRefOid baseRefName mergeable reviewDecision + reviewThreads(first: 100) { nodes { isResolved } } + commits(last: 1) { nodes { commit { statusCheckRollup { state contexts(first: 100) { + pageInfo { hasNextPage } + nodes { + __typename + ... on CheckRun { name status conclusion detailsUrl checkSuite { workflowRun { workflow { name } } } } + ... on StatusContext { context state targetUrl } + } + } } } } }` + +/** Head-branch lookup; variables `owner`, `name`, and `branch` are passed as strings. */ +export const HEAD_QUERY = `query($owner: String!, $name: String!, $branch: String!) { + repository(owner: $owner, name: $name) { + defaultBranchRef { name } + pullRequests(headRefName: $branch, states: OPEN, first: 20, orderBy: { field: CREATED_AT, direction: DESC }) { nodes { number isCrossRepository } } + } +}` + +const checkRunSchema = z.object({ + __typename: z.literal('CheckRun'), + name: z.string(), + status: z.string(), + conclusion: z.string().nullable(), + detailsUrl: z.string().nullable(), + checkSuite: z.object({ + workflowRun: z.object({ workflow: z.object({ name: z.string() }) }).nullable(), + }).nullable(), +}) + +const statusContextSchema = z.object({ + __typename: z.literal('StatusContext'), + context: z.string(), + state: z.string(), + targetUrl: z.string().nullable(), +}) + +const pullRequestSchema = z.object({ + url: z.string(), + title: z.string(), + state: z.enum(['OPEN', 'CLOSED', 'MERGED']), + isDraft: z.boolean(), + updatedAt: z.string(), + author: z.object({ login: z.string() }).nullable(), + headRefName: z.string(), + headRefOid: z.string(), + baseRefName: z.string(), + mergeable: z.string(), + reviewDecision: z.string().nullable(), + reviewThreads: z.object({ nodes: z.array(z.object({ isResolved: z.boolean() })) }), + commits: z.object({ + nodes: z.array(z.object({ + commit: z.object({ + statusCheckRollup: z.object({ + state: z.string(), + contexts: z.object({ pageInfo: z.object({ hasNextPage: z.boolean() }), nodes: z.array(z.unknown()) }), + }).nullable(), + }), + })), + }), +}) + +const responseSchema = z.object({ + data: z.record(z.string(), z.record(z.string(), z.unknown()).nullable()).nullable().optional(), + errors: z.array(z.object({ + type: z.string().optional(), + path: z.array(z.union([z.string(), z.number()])).optional(), + message: z.string(), + })).optional(), +}) + +const headSchema = z.object({ + data: z.object({ + repository: z.object({ + defaultBranchRef: z.object({ name: z.string() }).nullable(), + pullRequests: z.object({ nodes: z.array(z.object({ number: z.number(), isCrossRepository: z.boolean() })) }), + }).nullable(), + }), +}) + +type PullRequestNode = z.infer + +/** One batched snapshot query and the change request behind each alias. */ +export interface SnapshotQuery { + /** GraphQL document with one aliased field per change request. */ + readonly document: string + /** Change requests keyed by alias path `r.p`. */ + readonly targets: ReadonlyMap +} + +/** + * Build one query that reads every change request in `refs`, grouping them by + * repository. Owners, names, and numbers are validated before they are inlined. + * @param refs - change requests on one host. + * @returns the document and its alias targets. + * @throws when an owner, name, or number cannot be inlined safely. + */ +export function snapshotQuery(refs: readonly ChangeRequestRef[]): SnapshotQuery { + const repositories = new Map }>() + for (const ref of refs) { + const { owner, name } = ref.repository + if (!isSegment(owner) || !isSegment(name)) throw new Error(`forge-github: invalid repository ${JSON.stringify(`${owner}/${name}`)}`) + if (!Number.isSafeInteger(ref.number) || ref.number <= 0) throw new Error(`forge-github: invalid pull request number ${ref.number}`) + const id = `${owner}/${name}`.toLowerCase() + let entry = repositories.get(id) + if (entry === undefined) { + entry = { repository: ref.repository, numbers: new Map() } + repositories.set(id, entry) + } + if (!entry.numbers.has(ref.number)) entry.numbers.set(ref.number, ref) + } + const targets = new Map() + const fields: string[] = [] + for (const [r, { repository, numbers }] of [...repositories.values()].entries()) { + const pulls: string[] = [] + for (const [p, [number, ref]] of [...numbers].entries()) { + pulls.push(`p${p}: pullRequest(number: ${number}) { ...ChangeRequestFields }`) + targets.set(`r${r}.p${p}`, ref) + } + fields.push(`r${r}: repository(owner: ${JSON.stringify(repository.owner)}, name: ${JSON.stringify(repository.name)}) { ${pulls.join(' ')} }`) + } + return { document: `query { ${fields.join(' ')} }\nfragment ChangeRequestFields on PullRequest { ${PULL_REQUEST_FIELDS} }`, targets } +} + +/** + * Map a snapshot response, including GitHub's partial-error form, into one + * result per alias target. Path-scoped errors apply to their repository or + * pull request; errors without a path apply to every unresolved target. + * @param body - parsed JSON printed by `gh api graphql`. + * @param query - the query that produced the response. + * @returns a snapshot or failure for every target. + */ +export function mapSnapshotResponse(body: unknown, query: SnapshotQuery): Map { + const results = new Map() + const parsed = responseSchema.safeParse(body) + for (const [alias, ref] of query.targets) { + const key = changeRequestKey(ref) + if (!parsed.success) { + results.set(key, { code: 'unavailable', message: 'unexpected GitHub response' }) + continue + } + const [repositoryAlias = '', pullAlias = ''] = alias.split('.') + const errors = parsed.data.errors ?? [] + const error = errors.find(entry => entry.path?.[0] === repositoryAlias && (entry.path.length === 1 || entry.path[1] === pullAlias)) + ?? errors.find(entry => entry.path === undefined) + const raw = parsed.data.data?.[repositoryAlias]?.[pullAlias] + const node = pullRequestSchema.safeParse(raw) + if (node.success) results.set(key, toSnapshot(ref, node.data)) + else if (error !== undefined) results.set(key, { code: error.type === 'NOT_FOUND' ? 'not-found' : 'unavailable', message: error.message }) + else if (raw === null || raw === undefined) results.set(key, { code: 'not-found', message: 'pull request not found' }) + else results.set(key, { code: 'unavailable', message: 'unexpected pull request fields' }) + } + return results +} + +/** + * Map a head-branch lookup of {@link HEAD_QUERY}, which selects open pull requests, into change requests. + * @param body - parsed JSON printed by `gh api graphql`. + * @param repository - repository that was queried. + * @param branch - head branch that was queried. + * @returns open non-fork change requests for `branch`, or none when it is the default branch. + * @throws when the response is malformed or the repository is not visible. + */ +export function mapHeadResponse(body: unknown, repository: ForgeRepository, branch: string): ChangeRequestRef[] { + const parsed = headSchema.safeParse(body) + if (!parsed.success) throw new Error('forge-github: unexpected GitHub response to the head branch lookup') + const found = parsed.data.data.repository + if (found === null) throw new Error(`forge-github: repository ${repository.owner}/${repository.name} is not visible`) + if (found.defaultBranchRef?.name === branch) return [] + return found.pullRequests.nodes.filter(node => !node.isCrossRepository).map(node => ({ repository, number: node.number })) +} + +function toSnapshot(ref: ChangeRequestRef, node: PullRequestNode): ChangeRequestSnapshot { + const rollup = node.commits.nodes[0]?.commit.statusCheckRollup ?? null + const checks: ChangeRequestCheck[] = [] + for (const context of rollup?.contexts.nodes ?? []) { + const run = checkRunSchema.safeParse(context) + if (run.success) { + checks.push({ + name: run.data.name, + state: checkRunState(run.data.status, run.data.conclusion), + url: run.data.detailsUrl, + workflow: run.data.checkSuite?.workflowRun?.workflow.name ?? null, + }) + continue + } + const status = statusContextSchema.safeParse(context) + if (status.success) { + checks.push({ name: status.data.context, state: statusContextState(status.data.state), url: status.data.targetUrl, workflow: null }) + } + } + return { + ref, + url: node.url, + title: node.title, + state: changeRequestState(node.state), + draft: node.isDraft, + author: node.author?.login ?? null, + headBranch: node.headRefName, + headSha: node.headRefOid, + baseBranch: node.baseRefName, + mergeability: mergeability(node.mergeable), + reviewDecision: reviewDecision(node.reviewDecision), + checks, + checksRollup: checksRollup(rollup?.state), + checksTruncated: rollup?.contexts.pageInfo.hasNextPage ?? false, + unresolvedThreads: node.reviewThreads.nodes.filter(thread => !thread.isResolved).length, + updatedAt: node.updatedAt, + } +} + +function changeRequestState(state: PullRequestNode['state']): ChangeRequestSnapshot['state'] { + switch (state) { + case 'OPEN': return 'open' + case 'CLOSED': return 'closed' + case 'MERGED': return 'merged' + } +} + +/** GitHub enum values outside the documented set read as the most conservative state. */ +function checkRunState(status: string, conclusion: string | null): CheckState { + if (status !== 'COMPLETED') return 'pending' + switch (conclusion) { + case 'SUCCESS': return 'success' + case 'FAILURE': + case 'TIMED_OUT': + case 'STARTUP_FAILURE': + case 'ACTION_REQUIRED': return 'failure' + case 'CANCELLED': return 'cancelled' + case 'SKIPPED': return 'skipped' + default: return 'neutral' + } +} + +function statusContextState(state: string): CheckState { + switch (state) { + case 'SUCCESS': return 'success' + case 'FAILURE': + case 'ERROR': return 'failure' + default: return 'pending' + } +} + +function checksRollup(state: string | undefined): ChecksRollup { + switch (state) { + case undefined: return 'none' + case 'SUCCESS': return 'success' + case 'FAILURE': + case 'ERROR': return 'failure' + default: return 'pending' + } +} + +function mergeability(value: string): Mergeability { + switch (value) { + case 'MERGEABLE': return 'mergeable' + case 'CONFLICTING': return 'conflicting' + default: return 'unknown' + } +} + +function reviewDecision(value: string | null): ReviewDecision { + switch (value) { + case 'APPROVED': return 'approved' + case 'CHANGES_REQUESTED': return 'changes-requested' + case 'REVIEW_REQUIRED': return 'review-required' + default: return 'none' + } +} diff --git a/packages/experimental/forge-github/src/index.ts b/packages/experimental/forge-github/src/index.ts new file mode 100644 index 0000000000..e13637a9a8 --- /dev/null +++ b/packages/experimental/forge-github/src/index.ts @@ -0,0 +1,48 @@ +/** Registers the GitHub provider of `ctx.forge`, which reads pull requests through the GitHub CLI on the host. */ + +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' +import type {} from '@deepseek-ai/dsh-experimental-forge' +import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' +import { ghRunner } from './gh.ts' +import { GitHubProvider } from './provider.ts' + +export const name = 'forge-github' +export const inject = ['forge'] + +/** GitHub CLI executable, served hosts, and invocation limits. */ +export interface Config { + /** GitHub CLI executable name or absolute host path. */ + ghCommand?: string + /** Host names this provider serves; list GitHub Enterprise hosts explicitly. */ + hosts?: string[] + /** Deadline in milliseconds for one `gh` invocation. */ + timeoutMs?: number + /** Maximum captured bytes per `gh` output stream. */ + maxOutputBytes?: number +} + +export const Config: z = z.object({ + ghCommand: z.string().default('gh'), + hosts: z.array(z.string()).default(['github.com']), + timeoutMs: z.number().default(30_000), + maxOutputBytes: z.number().default(8_388_608), +}) + +const HOST = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*(:\d+)?$/i + +export function apply(ctx: Context, config: Config): void { + const resolved = config as Required + if (resolved.ghCommand.trim().length === 0) throw new Error('forge-github: ghCommand must not be empty') + if (resolved.hosts.length === 0) throw new Error('forge-github: hosts must list at least one host') + for (const host of resolved.hosts) { + if (!HOST.test(host)) throw new Error(`forge-github: invalid host ${JSON.stringify(host)}`) + } + for (const key of ['timeoutMs', 'maxOutputBytes'] as const) { + const value = resolved[key] + if (!Number.isSafeInteger(value) || value <= 0 || value > MAX_TIMER_DELAY_MS) { + throw new Error(`forge-github: ${key} must be a positive integer no greater than ${MAX_TIMER_DELAY_MS}`) + } + } + ctx.forge.register(new GitHubProvider(ghRunner(resolved), resolved.hosts)) +} diff --git a/packages/experimental/forge-github/src/provider.ts b/packages/experimental/forge-github/src/provider.ts new file mode 100644 index 0000000000..b5607eb44d --- /dev/null +++ b/packages/experimental/forge-github/src/provider.ts @@ -0,0 +1,100 @@ +/** GitHub implementation of the forge provider contract. */ + +import { changeRequestKey } from '@deepseek-ai/dsh-experimental-forge' +import type { + ChangeRequestKey, ChangeRequestRef, ChangeRequestSnapshot, ForgeFailure, ForgeProvider, ForgeRepository, +} from '@deepseek-ai/dsh-experimental-forge' +import type { GhRunner } from './gh.ts' +import { HEAD_QUERY, mapHeadResponse, mapSnapshotResponse, snapshotQuery } from './graphql.ts' +import { parsePullRequestUrl, parseRemoteUrl } from './urls.ts' + +const AUTHENTICATION = /gh auth login|authentication|bad credentials|HTTP 401/i + +/** Reads GitHub and GitHub Enterprise pull requests through a `gh` runner. */ +export class GitHubProvider implements ForgeProvider { + readonly forge = 'github' + private readonly hosts: ReadonlySet + + /** + * @param gh - runner that executes `gh` on the host. + * @param hosts - host names this provider serves, such as `github.com` and Enterprise hosts. + */ + constructor(private readonly gh: GhRunner, hosts: readonly string[]) { + this.hosts = new Set(hosts.map(host => host.toLowerCase())) + } + + repositoryFromRemote(url: string): ForgeRepository | undefined { + const location = parseRemoteUrl(url) + if (location === undefined || !this.hosts.has(location.host)) return undefined + return { forge: this.forge, ...location } + } + + changeRequestFromUrl(url: string): ChangeRequestRef | undefined { + const parsed = parsePullRequestUrl(url) + if (parsed === undefined || !this.hosts.has(parsed.host)) return undefined + return { repository: { forge: this.forge, host: parsed.host, owner: parsed.owner, name: parsed.name }, number: parsed.number } + } + + changeRequestUrl(ref: ChangeRequestRef): string { + const { host, owner, name } = ref.repository + return `https://${host}/${owner}/${name}/pull/${ref.number}` + } + + async fetch( + refs: readonly ChangeRequestRef[], + signal: AbortSignal, + ): Promise> { + const byHost = new Map() + for (const ref of refs) { + const group = byHost.get(ref.repository.host) + if (group === undefined) byHost.set(ref.repository.host, [ref]) + else group.push(ref) + } + const results = new Map() + for (const [host, group] of byHost) { + for (const [key, result] of await this.fetchHost(host, group, signal)) results.set(key, result) + } + return results + } + + async findByHead(repository: ForgeRepository, branch: string, signal: AbortSignal): Promise { + const output = await this.gh([ + 'api', 'graphql', '--hostname', repository.host, + '-f', `query=${HEAD_QUERY}`, '-f', `owner=${repository.owner}`, '-f', `name=${repository.name}`, '-f', `branch=${branch}`, + ], signal) + const body = parseJson(output.stdout) + if (body === undefined) throw new Error(`forge-github: head branch lookup failed: ${firstLine(output.stderr) ?? `gh exited with ${output.exitCode}`}`) + return mapHeadResponse(body, repository, branch) + } + + private async fetchHost( + host: string, + refs: readonly ChangeRequestRef[], + signal: AbortSignal, + ): Promise> { + const query = snapshotQuery(refs) + const output = await this.gh(['api', 'graphql', '--hostname', host, '-f', `query=${query.document}`], signal) + const body = parseJson(output.stdout) + if (body !== undefined) return mapSnapshotResponse(body, query) + const failure: ForgeFailure = { + code: AUTHENTICATION.test(output.stderr) ? 'unauthenticated' : 'unavailable', + message: firstLine(output.stderr) ?? `gh exited with ${output.exitCode}`, + } + return new Map([...query.targets.values()].map(ref => [changeRequestKey(ref), failure] as const)) + } +} + +function parseJson(text: string): unknown { + let value: unknown + try { + value = JSON.parse(text) + } catch (error) { + void error // gh prints diagnostics instead of JSON when a request fails before GitHub answers. + return undefined + } + return value +} + +function firstLine(text: string): string | undefined { + return text.split('\n').map(line => line.trim()).find(line => line.length > 0) +} diff --git a/packages/experimental/forge-github/src/urls.ts b/packages/experimental/forge-github/src/urls.ts new file mode 100644 index 0000000000..8349f9b591 --- /dev/null +++ b/packages/experimental/forge-github/src/urls.ts @@ -0,0 +1,59 @@ +/** GitHub remote URL and pull request URL parsing. */ + +/** Characters GitHub permits in owner and repository names. */ +const SEGMENT = /^[A-Za-z0-9_.-]+$/ + +/** Host, owner, and repository name addressed by a URL. */ +export interface GitHubLocation { + /** Lowercase host name. */ + readonly host: string + /** Owner as the URL spells it. */ + readonly owner: string + /** Repository name without a `.git` suffix. */ + readonly name: string +} + +/** + * Check one owner or repository name segment. + * @param value - candidate segment. + * @returns whether GitHub permits the segment and it is not a dot path. + */ +export function isSegment(value: string): boolean { + return SEGMENT.test(value) && value !== '.' && value !== '..' +} + +/** + * Parse a Git remote URL. Accepts `http(s)://[user@]host[:port]/owner/name`, + * `ssh://[user@]host[:port]/owner/name`, `git://host/owner/name`, and the + * scp-like `[user@]host:owner/name`, each with an optional `.git` suffix or + * trailing slash. + * @param url - fetch or push URL as Git reports it. + * @returns the location, or undefined for other URL shapes. + */ +export function parseRemoteUrl(url: string): GitHubLocation | undefined { + const trimmed = url.trim() + const scheme = /^(?:https?|ssh|git):\/\/(?:[^@/]+@)?([^/:]+)(?::\d+)?\/(.+)$/i.exec(trimmed) + const scp = scheme === null ? /^(?:[^@/:]+@)?([^/:]+):(?!\/\/)(.+)$/.exec(trimmed) : null + const match = scheme ?? scp + if (match === null) return undefined + const [, host = '', path = ''] = match + const parts = path.replace(/\/+$/, '').replace(/\.git$/i, '').split('/') + if (parts.length !== 2) return undefined + const [owner = '', name = ''] = parts + if (!isSegment(owner) || !isSegment(name)) return undefined + return { host: host.toLowerCase(), owner, name } +} + +/** + * Parse a pull request web URL such as `https://github.com/owner/name/pull/12/files`. + * @param url - web URL of a pull request. + * @returns the location and number, or undefined for other URLs. + */ +export function parsePullRequestUrl(url: string): (GitHubLocation & { readonly number: number }) | undefined { + const match = /^https?:\/\/([^/]+)\/([^/]+)\/([^/]+)\/pull\/(\d+)(?:[/?#].*)?$/i.exec(url.trim()) + if (match === null) return undefined + const [, host = '', owner = '', name = '', digits = ''] = match + const number = Number(digits) + if (!isSegment(owner) || !isSegment(name) || !Number.isSafeInteger(number) || number <= 0) return undefined + return { host: host.toLowerCase(), owner, name, number } +} diff --git a/packages/experimental/forge-github/tests/fixtures.ts b/packages/experimental/forge-github/tests/fixtures.ts new file mode 100644 index 0000000000..2746474f40 --- /dev/null +++ b/packages/experimental/forge-github/tests/fixtures.ts @@ -0,0 +1,56 @@ +import type { ChangeRequestRef, ForgeRepository } from '@deepseek-ai/dsh-experimental-forge' + +/** Repository on github.com used across the provider tests. */ +export const demo: ForgeRepository = { forge: 'github', host: 'github.com', owner: 'octo', name: 'demo' } + +/** Build a pull request reference in `demo` or another repository. */ +export function pull(number: number, repository: ForgeRepository = demo): ChangeRequestRef { + return { repository, number } +} + +/** One CheckRun context node as GitHub returns it. */ +export function checkRun(name: string, status: string, conclusion: string | null, workflow: string | null = 'ci'): Record { + return { + __typename: 'CheckRun', + name, + status, + conclusion, + detailsUrl: `https://github.com/octo/demo/actions/runs/1/job/${name}`, + checkSuite: workflow === null ? { workflowRun: null } : { workflowRun: { workflow: { name: workflow } } }, + } +} + +/** One StatusContext node as GitHub returns it. */ +export function statusContext(context: string, state: string): Record { + return { __typename: 'StatusContext', context, state, targetUrl: null } +} + +/** A pull request node with the fields the snapshot query selects. */ +export function pullRequestNode(number: number, overrides: Record = {}, contexts: unknown[] = [], rollupState: string | null = 'SUCCESS'): Record { + return { + number, + url: `https://github.com/octo/demo/pull/${number}`, + title: `Change ${number}`, + state: 'OPEN', + isDraft: false, + updatedAt: '2026-10-09T01:02:03Z', + author: { login: 'octo' }, + headRefName: `topic-${number}`, + headRefOid: 'f'.repeat(40), + baseRefName: 'master', + mergeable: 'MERGEABLE', + reviewDecision: null, + reviewThreads: { nodes: [{ isResolved: true }, { isResolved: false }, { isResolved: false }] }, + commits: { + nodes: [{ + commit: { + statusCheckRollup: rollupState === null ? null : { + state: rollupState, + contexts: { pageInfo: { hasNextPage: false }, nodes: contexts }, + }, + }, + }], + }, + ...overrides, + } +} diff --git a/packages/experimental/forge-github/tests/gh.spec.ts b/packages/experimental/forge-github/tests/gh.spec.ts new file mode 100644 index 0000000000..b40bd0f14c --- /dev/null +++ b/packages/experimental/forge-github/tests/gh.spec.ts @@ -0,0 +1,80 @@ +import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { ghRunner } from '../src/gh.ts' + +let root = '' + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'dsh-forge-gh-')) +}) + +afterEach(async () => { + vi.unstubAllEnvs() + await rm(root, { recursive: true, force: true }) +}) + +/** A POSIX stand-in for gh whose first argument selects its behavior. */ +async function fakeGh(): Promise { + const path = join(root, 'gh') + await writeFile(path, [ + '#!/bin/sh', + 'case "$1" in', + ' echo) printf "%s|" "$@"; printf "\\nprompt=%s token=%s pager=[%s]\\n" "$GH_PROMPT_DISABLED" "${GH_TOKEN:-unset}" "$GH_PAGER" ;;', + ' fail) echo oops >&2; exit 3 ;;', + ' sleep) sleep 5 ;;', + ' big) head -c 5000 /dev/zero | tr "\\0" x ;;', + 'esac', + '', + ].join('\n')) + await chmod(path, 0o755) + return path +} + +describe.skipIf(process.platform === 'win32')('gh runner', () => { + it('runs gh with literal arguments and a scrubbed, non-interactive environment', async () => { + vi.stubEnv('GH_TOKEN', 'secret-token') + const run = ghRunner({ ghCommand: await fakeGh(), timeoutMs: 5_000, maxOutputBytes: 10_000 }) + + await expect(run(['echo', 'a b', '--flag'], new AbortController().signal)).resolves.toEqual({ + exitCode: 0, + stdout: 'echo|a b|--flag|\nprompt=1 token=unset pager=[]', + stderr: '', + }) + }) + + it('returns a nonzero exit code with its stderr', async () => { + const run = ghRunner({ ghCommand: await fakeGh(), timeoutMs: 5_000, maxOutputBytes: 10_000 }) + + await expect(run(['fail'], new AbortController().signal)).resolves.toEqual({ exitCode: 3, stdout: '', stderr: 'oops' }) + }) + + it('rejects when gh outlives the timeout', async () => { + const run = ghRunner({ ghCommand: await fakeGh(), timeoutMs: 100, maxOutputBytes: 10_000 }) + + await expect(run(['sleep'], new AbortController().signal)).rejects.toThrow('gh sleep timed out after 100ms') + }) + + it('rejects with the abort reason when cancelled', async () => { + const run = ghRunner({ ghCommand: await fakeGh(), timeoutMs: 5_000, maxOutputBytes: 10_000 }) + const controller = new AbortController() + setTimeout(() => { + controller.abort(new Error('stop requested')) + }, 50) + + await expect(run(['sleep'], controller.signal)).rejects.toThrow('stop requested') + }) + + it('rejects output beyond maxOutputBytes', async () => { + const run = ghRunner({ ghCommand: await fakeGh(), timeoutMs: 5_000, maxOutputBytes: 1_000 }) + + await expect(run(['big'], new AbortController().signal)).rejects.toThrow('gh big output exceeds maxOutputBytes') + }) + + it('reports an executable that cannot start', async () => { + const run = ghRunner({ ghCommand: join(root, 'missing-gh'), timeoutMs: 5_000, maxOutputBytes: 1_000 }) + + await expect(run([], new AbortController().signal)).rejects.toThrow(/^gh could not start: /) + }) +}) diff --git a/packages/experimental/forge-github/tests/graphql.spec.ts b/packages/experimental/forge-github/tests/graphql.spec.ts new file mode 100644 index 0000000000..3ec8c0a35f --- /dev/null +++ b/packages/experimental/forge-github/tests/graphql.spec.ts @@ -0,0 +1,203 @@ +import { changeRequestKey } from '@deepseek-ai/dsh-experimental-forge' +import type { ChangeRequestSnapshot } from '@deepseek-ai/dsh-experimental-forge' +import { describe, expect, it } from 'vitest' +import { mapHeadResponse, mapSnapshotResponse, snapshotQuery } from '../src/graphql.ts' +import { checkRun, demo, pull, pullRequestNode, statusContext } from './fixtures.ts' + +const other = { ...demo, name: 'other' } + +function single(node: unknown): ChangeRequestSnapshot { + const query = snapshotQuery([pull(1)]) + const result = mapSnapshotResponse({ data: { r0: { p0: node } } }, query).get(changeRequestKey(pull(1))) + if (result === undefined || 'code' in result) throw new Error(`expected a snapshot, received ${JSON.stringify(result)}`) + return result +} + +describe('snapshot query', () => { + it('groups change requests by repository and aliases each one', () => { + const upper = { ...demo, owner: 'Octo', name: 'Demo' } + const query = snapshotQuery([pull(1), pull(2, upper), pull(1, upper), pull(7, other)]) + + expect([...query.targets]).toEqual([['r0.p0', pull(1)], ['r0.p1', pull(2, upper)], ['r1.p0', pull(7, other)]]) + expect(query.document).toContain('r0: repository(owner: "octo", name: "demo") { p0: pullRequest(number: 1) { ...ChangeRequestFields } p1: pullRequest(number: 2) { ...ChangeRequestFields } }') + expect(query.document).toContain('r1: repository(owner: "octo", name: "other") { p0: pullRequest(number: 7) { ...ChangeRequestFields } }') + expect(query.document).toContain('fragment ChangeRequestFields on PullRequest {') + }) + + it('refuses owners, names, and numbers that cannot be inlined', () => { + expect(() => snapshotQuery([pull(1, { ...demo, owner: 'oc"to' })])).toThrow('forge-github: invalid repository "oc\\"to/demo"') + expect(() => snapshotQuery([pull(1.5)])).toThrow('forge-github: invalid pull request number 1.5') + expect(() => snapshotQuery([pull(0)])).toThrow('forge-github: invalid pull request number 0') + }) +}) + +describe('snapshot mapping', () => { + it('maps the pull request fields into a provider-neutral snapshot', () => { + expect(single(pullRequestNode(1, {}, [checkRun('lint', 'COMPLETED', 'SUCCESS')]))).toEqual({ + ref: pull(1), + url: 'https://github.com/octo/demo/pull/1', + title: 'Change 1', + state: 'open', + draft: false, + author: 'octo', + headBranch: 'topic-1', + headSha: 'f'.repeat(40), + baseBranch: 'master', + mergeability: 'mergeable', + reviewDecision: 'none', + checks: [{ name: 'lint', state: 'success', url: 'https://github.com/octo/demo/actions/runs/1/job/lint', workflow: 'ci' }], + checksRollup: 'success', + checksTruncated: false, + unresolvedThreads: 2, + updatedAt: '2026-10-09T01:02:03Z', + }) + }) + + it.each([ + ['OPEN', 'open'], + ['CLOSED', 'closed'], + ['MERGED', 'merged'], + ])('maps state %s to %s', (state, expected) => { + expect(single(pullRequestNode(1, { state })).state).toBe(expected) + }) + + it.each([ + ['MERGEABLE', 'mergeable'], + ['CONFLICTING', 'conflicting'], + ['UNKNOWN', 'unknown'], + ])('maps mergeable %s to %s', (mergeable, expected) => { + expect(single(pullRequestNode(1, { mergeable })).mergeability).toBe(expected) + }) + + it.each([ + ['APPROVED', 'approved'], + ['CHANGES_REQUESTED', 'changes-requested'], + ['REVIEW_REQUIRED', 'review-required'], + [null, 'none'], + ])('maps review decision %s to %s', (reviewDecision, expected) => { + expect(single(pullRequestNode(1, { reviewDecision })).reviewDecision).toBe(expected) + }) + + it.each([ + ['COMPLETED', 'SUCCESS', 'success'], + ['COMPLETED', 'FAILURE', 'failure'], + ['COMPLETED', 'TIMED_OUT', 'failure'], + ['COMPLETED', 'STARTUP_FAILURE', 'failure'], + ['COMPLETED', 'ACTION_REQUIRED', 'failure'], + ['COMPLETED', 'CANCELLED', 'cancelled'], + ['COMPLETED', 'SKIPPED', 'skipped'], + ['COMPLETED', 'NEUTRAL', 'neutral'], + ['COMPLETED', 'STALE', 'neutral'], + ['COMPLETED', null, 'neutral'], + ['IN_PROGRESS', null, 'pending'], + ['QUEUED', null, 'pending'], + ])('maps check run %s/%s to %s', (status, conclusion, expected) => { + expect(single(pullRequestNode(1, {}, [checkRun('build', status, conclusion)])).checks[0]?.state).toBe(expected) + }) + + it.each([ + ['SUCCESS', 'success'], + ['FAILURE', 'failure'], + ['ERROR', 'failure'], + ['PENDING', 'pending'], + ['EXPECTED', 'pending'], + ])('maps status context %s to %s', (state, expected) => { + expect(single(pullRequestNode(1, {}, [statusContext('deploy', state)])).checks).toEqual([{ name: 'deploy', state: expected, url: null, workflow: null }]) + }) + + it.each([ + ['SUCCESS', 'success'], + ['FAILURE', 'failure'], + ['ERROR', 'failure'], + ['PENDING', 'pending'], + ['EXPECTED', 'pending'], + [null, 'none'], + ])('maps rollup %s to %s', (state, expected) => { + expect(single(pullRequestNode(1, {}, [], state)).checksRollup).toBe(expected) + }) + + it('reads workflows only when GitHub reports one and skips unknown contexts', () => { + const snapshot = single(pullRequestNode(1, {}, [ + checkRun('a', 'COMPLETED', 'SUCCESS', null), + { ...checkRun('b', 'COMPLETED', 'SUCCESS'), checkSuite: null }, + { __typename: 'FutureContext', name: 'c' }, + ])) + + expect(snapshot.checks.map(check => [check.name, check.workflow])).toEqual([['a', null], ['b', null]]) + }) + + it('marks truncated checks, a hidden author, drafts, and a commit without rollup', () => { + const truncated = pullRequestNode(1, { + isDraft: true, + author: null, + commits: { nodes: [{ commit: { statusCheckRollup: { state: 'PENDING', contexts: { pageInfo: { hasNextPage: true }, nodes: [] } } } }] }, + }) + + expect(single(truncated)).toMatchObject({ draft: true, author: null, checksTruncated: true }) + expect(single(pullRequestNode(1, { commits: { nodes: [] } }))).toMatchObject({ checks: [], checksRollup: 'none', checksTruncated: false }) + }) + + it('maps path-scoped and global errors from the partial-error response', () => { + const query = snapshotQuery([pull(1), pull(2), pull(7, other)]) + const body = { + data: { r0: { p0: pullRequestNode(1), p1: null }, r1: null }, + errors: [ + { type: 'NOT_FOUND', path: ['r0', 'p1'], locations: [{ line: 1, column: 72 }], message: 'Could not resolve to a PullRequest with the number of 2.' }, + { type: 'FORBIDDEN', path: ['r1'], message: 'Resource not accessible by integration' }, + ], + } + + const results = mapSnapshotResponse(body, query) + + expect(results.get(changeRequestKey(pull(1)))).toMatchObject({ state: 'open' }) + expect(results.get(changeRequestKey(pull(2)))).toEqual({ code: 'not-found', message: 'Could not resolve to a PullRequest with the number of 2.' }) + expect(results.get(changeRequestKey(pull(7, other)))).toEqual({ code: 'unavailable', message: 'Resource not accessible by integration' }) + }) + + it('applies an error without a path to every unresolved change request', () => { + const query = snapshotQuery([pull(1), pull(2)]) + const results = mapSnapshotResponse({ data: null, errors: [{ message: 'API rate limit exceeded' }] }, query) + + expect([...results.values()]).toEqual([ + { code: 'unavailable', message: 'API rate limit exceeded' }, + { code: 'unavailable', message: 'API rate limit exceeded' }, + ]) + }) + + it('reports missing nodes, malformed nodes, and malformed bodies', () => { + const query = snapshotQuery([pull(1), pull(2)]) + + expect([...mapSnapshotResponse({ data: { r0: { p0: null, p1: { title: 7 } } } }, query).values()]).toEqual([ + { code: 'not-found', message: 'pull request not found' }, + { code: 'unavailable', message: 'unexpected pull request fields' }, + ]) + expect([...mapSnapshotResponse({}, query).values()]).toEqual([ + { code: 'not-found', message: 'pull request not found' }, + { code: 'not-found', message: 'pull request not found' }, + ]) + expect([...mapSnapshotResponse('not json', query).values()]).toEqual([ + { code: 'unavailable', message: 'unexpected GitHub response' }, + { code: 'unavailable', message: 'unexpected GitHub response' }, + ]) + }) +}) + +describe('head branch lookup mapping', () => { + const body = (defaultBranch: string | null, nodes: { number: number; isCrossRepository: boolean }[]) => ({ + data: { repository: { defaultBranchRef: defaultBranch === null ? null : { name: defaultBranch }, pullRequests: { nodes } } }, + }) + + it('returns same-repository pull requests for the branch', () => { + expect(mapHeadResponse(body('master', [{ number: 9, isCrossRepository: false }, { number: 8, isCrossRepository: true }]), demo, 'topic')).toEqual([pull(9)]) + expect(mapHeadResponse(body(null, [{ number: 9, isCrossRepository: false }]), demo, 'topic')).toEqual([pull(9)]) + }) + + it('returns nothing for the default branch', () => { + expect(mapHeadResponse(body('master', [{ number: 9, isCrossRepository: false }]), demo, 'master')).toEqual([]) + }) + + it('throws for an invisible repository or a malformed response', () => { + expect(() => mapHeadResponse({ data: { repository: null } }, demo, 'topic')).toThrow('forge-github: repository octo/demo is not visible') + expect(() => mapHeadResponse({ data: {} }, demo, 'topic')).toThrow('forge-github: unexpected GitHub response to the head branch lookup') + }) +}) diff --git a/packages/experimental/forge-github/tests/plugin.spec.ts b/packages/experimental/forge-github/tests/plugin.spec.ts new file mode 100644 index 0000000000..d58436268d --- /dev/null +++ b/packages/experimental/forge-github/tests/plugin.spec.ts @@ -0,0 +1,60 @@ +import { Context } from '@deepseek-ai/cordis' +import ForgeService from '@deepseek-ai/dsh-experimental-forge' +import { afterEach, describe, expect, it } from 'vitest' +import * as ForgeGitHub from '../src/index.ts' + +let ctx: Context | undefined + +afterEach(async () => { + await ctx?.fiber.dispose() + ctx = undefined +}) + +async function forge(): Promise { + ctx = new Context() + await ctx.plugin(ForgeService) + return ctx +} + +describe('forge-github plugin', () => { + it('registers the GitHub provider for the configured hosts', async () => { + const context = await forge() + await context.plugin(ForgeGitHub, { hosts: ['github.com', 'ghe.corp.example'] }) + + expect(context.forge.changeRequestFromUrl('https://github.com/octo/demo/pull/1')).toEqual({ + repository: { forge: 'github', host: 'github.com', owner: 'octo', name: 'demo' }, + number: 1, + }) + expect(context.forge.repositoryFromRemote('git@ghe.corp.example:team/svc.git')).toEqual({ forge: 'github', host: 'ghe.corp.example', owner: 'team', name: 'svc' }) + }) + + it('serves github.com by default', async () => { + const context = await forge() + await context.plugin(ForgeGitHub, {}) + + expect(context.forge.repositoryFromRemote('https://github.com/octo/demo.git')).toBeDefined() + expect(context.forge.repositoryFromRemote('https://ghe.corp.example/team/svc.git')).toBeUndefined() + }) + + it('unregisters the provider when its fiber is disposed', async () => { + const context = await forge() + const fiber = context.plugin(ForgeGitHub, {}) + await fiber + + await fiber.dispose() + + expect(context.forge.changeRequestFromUrl('https://github.com/octo/demo/pull/1')).toBeUndefined() + }) + + it.each([ + [{ ghCommand: ' ' }, 'forge-github: ghCommand must not be empty'], + [{ hosts: [] }, 'forge-github: hosts must list at least one host'], + [{ hosts: ['https://github.com'] }, 'forge-github: invalid host "https://github.com"'], + [{ timeoutMs: 0 }, 'forge-github: timeoutMs must be a positive integer no greater than 2147483647'], + [{ maxOutputBytes: 1.5 }, 'forge-github: maxOutputBytes must be a positive integer no greater than 2147483647'], + ])('rejects %j at load', async (config, message) => { + const context = await forge() + + await expect(context.plugin(ForgeGitHub, config)).rejects.toThrow(message) + }) +}) diff --git a/packages/experimental/forge-github/tests/provider.spec.ts b/packages/experimental/forge-github/tests/provider.spec.ts new file mode 100644 index 0000000000..86fa5db463 --- /dev/null +++ b/packages/experimental/forge-github/tests/provider.spec.ts @@ -0,0 +1,118 @@ +import { changeRequestKey } from '@deepseek-ai/dsh-experimental-forge' +import { describe, expect, it } from 'vitest' +import type { GhOutput, GhRunner } from '../src/gh.ts' +import { GitHubProvider } from '../src/provider.ts' +import { demo, pull, pullRequestNode } from './fixtures.ts' + +const enterprise = { forge: 'github', host: 'ghe.corp.example', owner: 'team', name: 'svc' } +const signal = new AbortController().signal + +function fakeGh(handler: (args: readonly string[]) => GhOutput | Promise): { run: GhRunner; calls: string[][] } { + const calls: string[][] = [] + return { + calls, + run: async (args) => { + calls.push([...args]) + return await handler(args) + }, + } +} + +function output(stdout: unknown, exitCode = 0, stderr = ''): GhOutput { + return { exitCode, stdout: typeof stdout === 'string' ? stdout : JSON.stringify(stdout), stderr } +} + +function provider(run: GhRunner): GitHubProvider { + return new GitHubProvider(run, ['github.com', 'GHE.corp.example']) +} + +describe('GitHub provider addressing', () => { + const subject = provider(fakeGh(() => output('')).run) + + it('serves remotes and pull request URLs on configured hosts only', () => { + expect(subject.repositoryFromRemote('git@github.com:octo/demo.git')).toEqual(demo) + expect(subject.repositoryFromRemote('https://ghe.corp.example/team/svc')).toEqual(enterprise) + expect(subject.repositoryFromRemote('https://gitlab.com/octo/demo')).toBeUndefined() + expect(subject.repositoryFromRemote('/local/repo')).toBeUndefined() + expect(subject.changeRequestFromUrl('https://github.com/octo/demo/pull/4')).toEqual(pull(4)) + expect(subject.changeRequestFromUrl('https://gitlab.com/octo/demo/pull/4')).toBeUndefined() + expect(subject.changeRequestFromUrl('https://github.com/octo/demo/issues/4')).toBeUndefined() + }) + + it('builds canonical pull request URLs', () => { + expect(subject.changeRequestUrl(pull(4, enterprise))).toBe('https://ghe.corp.example/team/svc/pull/4') + }) +}) + +describe('GitHub provider fetch', () => { + it('runs one GraphQL request per host and merges the results', async () => { + const gh = fakeGh((args) => { + const host = args[3] + return output({ data: { r0: { p0: pullRequestNode(host === 'github.com' ? 1 : 2) } } }) + }) + + const results = await provider(gh.run).fetch([pull(1), pull(2, enterprise)], signal) + + expect(gh.calls.map(call => call.slice(0, 5))).toEqual([ + ['api', 'graphql', '--hostname', 'github.com', '-f'], + ['api', 'graphql', '--hostname', 'ghe.corp.example', '-f'], + ]) + expect(gh.calls[0]?.[5]).toMatch(/^query=query \{ r0: repository\(owner: "octo", name: "demo"\)/) + expect(results.get(changeRequestKey(pull(1)))).toMatchObject({ title: 'Change 1' }) + expect(results.get(changeRequestKey(pull(2, enterprise)))).toMatchObject({ title: 'Change 2' }) + }) + + it('maps the JSON of a partial-error response that exits with 1', async () => { + const gh = fakeGh(() => output({ + data: { r0: { p0: null } }, + errors: [{ type: 'NOT_FOUND', path: ['r0', 'p0'], message: 'Could not resolve to a PullRequest with the number of 1.' }], + }, 1, 'gh: Could not resolve to a PullRequest with the number of 1.')) + + const results = await provider(gh.run).fetch([pull(1)], signal) + + expect(results.get(changeRequestKey(pull(1)))).toEqual({ code: 'not-found', message: 'Could not resolve to a PullRequest with the number of 1.' }) + }) + + it.each([ + ['To get started with GitHub CLI, please run: gh auth login', 'unauthenticated'], + ['HTTP 401: Bad credentials (https://api.github.com/graphql)', 'unauthenticated'], + ['error connecting to api.github.com', 'unavailable'], + ])('classifies a failure without JSON: %s', async (stderr, code) => { + const gh = fakeGh(() => output('', 1, `\n${stderr}\n`)) + + const results = await provider(gh.run).fetch([pull(1), pull(2)], signal) + + expect([...results.values()]).toEqual([{ code, message: stderr }, { code, message: stderr }]) + }) + + it('names the exit code when gh prints nothing', async () => { + const results = await provider(fakeGh(() => output('', 4)).run).fetch([pull(1)], signal) + + expect(results.get(changeRequestKey(pull(1)))).toEqual({ code: 'unavailable', message: 'gh exited with 4' }) + }) + + it('propagates a runner rejection', async () => { + const subject = provider(async () => { + throw new Error('aborted') + }) + + await expect(subject.fetch([pull(1)], signal)).rejects.toThrow('aborted') + }) +}) + +describe('GitHub provider head branch lookup', () => { + it('passes the repository and branch as GraphQL variables', async () => { + const gh = fakeGh(() => output({ data: { repository: { defaultBranchRef: { name: 'master' }, pullRequests: { nodes: [{ number: 5, isCrossRepository: false }] } } } })) + + await expect(provider(gh.run).findByHead(demo, 'topic', signal)).resolves.toEqual([pull(5)]) + expect(gh.calls[0]?.slice(0, 4)).toEqual(['api', 'graphql', '--hostname', 'github.com']) + expect(gh.calls[0]?.slice(6)).toEqual(['-f', 'owner=octo', '-f', 'name=demo', '-f', 'branch=topic']) + }) + + it('reports gh diagnostics when the lookup prints no JSON', async () => { + await expect(provider(fakeGh(() => output('', 1, 'HTTP 502')).run).findByHead(demo, 'topic', signal)) + .rejects.toThrow('forge-github: head branch lookup failed: HTTP 502') + await expect(provider(fakeGh(() => output('', 2)).run).findByHead(demo, 'topic', signal)) + .rejects.toThrow('forge-github: head branch lookup failed: gh exited with 2') + }) +}) diff --git a/packages/experimental/forge-github/tests/urls.spec.ts b/packages/experimental/forge-github/tests/urls.spec.ts new file mode 100644 index 0000000000..e202507e7a --- /dev/null +++ b/packages/experimental/forge-github/tests/urls.spec.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest' +import { isSegment, parsePullRequestUrl, parseRemoteUrl } from '../src/urls.ts' + +const demo = { host: 'github.com', owner: 'octo', name: 'demo' } + +describe('remote URLs', () => { + it.each([ + ['https://github.com/octo/demo.git', demo], + ['https://user:secret@GitHub.com/octo/demo', demo], + ['http://github.com/octo/demo/', demo], + ['ssh://git@github.com:22/octo/demo.git', demo], + ['git://github.com/octo/demo', demo], + ['git@github.com:octo/demo.git', demo], + ['github.com:octo/demo.git', demo], + [' git@ghe.corp.example:team/svc.GIT ', { host: 'ghe.corp.example', owner: 'team', name: 'svc' }], + ['https://github.com/octo/my.repo-name_1.git', { host: 'github.com', owner: 'octo', name: 'my.repo-name_1' }], + ])('parses %s', (url, expected) => { + expect(parseRemoteUrl(url)).toEqual(expected) + }) + + it.each([ + 'https://github.com/octo', + 'https://github.com/octo/demo/extra', + '/local/path/repo.git', + 'file:///tmp/repo.git', + 'C:/repo', + 'git@github.com:octo/../demo', + 'git@github.com:../demo', + 'https://github.com/oc to/demo', + 'not a url', + ])('rejects %s', (url) => { + expect(parseRemoteUrl(url)).toBeUndefined() + }) + + it('rejects dot segments as owner or name', () => { + expect(isSegment('.')).toBe(false) + expect(isSegment('..')).toBe(false) + expect(isSegment('demo.js')).toBe(true) + }) +}) + +describe('pull request URLs', () => { + it.each([ + ['https://github.com/octo/demo/pull/12', { ...demo, number: 12 }], + ['https://GITHUB.com/octo/demo/pull/12/files?diff=split#top', { ...demo, number: 12 }], + ['http://ghe.corp.example/team/svc/pull/3', { host: 'ghe.corp.example', owner: 'team', name: 'svc', number: 3 }], + ])('parses %s', (url, expected) => { + expect(parsePullRequestUrl(url)).toEqual(expected) + }) + + it.each([ + 'https://github.com/octo/demo/issues/12', + 'https://github.com/octo/demo/pull/0', + 'https://github.com/octo/demo/pull/99999999999999999999', + 'https://github.com/o o/demo/pull/1', + 'https://github.com/octo/demo/pulls/1', + ])('rejects %s', (url) => { + expect(parsePullRequestUrl(url)).toBeUndefined() + }) +}) diff --git a/packages/experimental/forge-github/tsconfig.json b/packages/experimental/forge-github/tsconfig.json new file mode 100644 index 0000000000..cd14864f4d --- /dev/null +++ b/packages/experimental/forge-github/tsconfig.json @@ -0,0 +1,30 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../forge" + }, + { + "path": "../../subprocess/subprocess" + }, + { + "path": "../../util/timeout" + } + ] +} diff --git a/packages/experimental/forge-session/README.i18n.yaml b/packages/experimental/forge-session/README.i18n.yaml new file mode 100644 index 0000000000..382eab490b --- /dev/null +++ b/packages/experimental/forge-session/README.i18n.yaml @@ -0,0 +1,37 @@ +# Bilingual-pair consistency record for README.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/experimental/forge-session/README.md +/: + en: 04fd3e698332ce87 + zh: 283b365b1f909e0b +/deepseek-ai-dsh-experimental-forge-session: + en: b8c7e5c40e3c21a4 + zh: 2360132d865b9d08 +/deepseek-ai-dsh-experimental-forge-session/summary: + en: 7f14cf7961681399 + zh: d1aa66296dd2c77e +/deepseek-ai-dsh-experimental-forge-session/table-of-contents: + en: d152484eb41ac6b4 + zh: 2f8a621b991260a1 +/deepseek-ai-dsh-experimental-forge-session/use-this-package: + en: 23898b4f5a21b3b5 + zh: 6d1251b9b862fad8 +/deepseek-ai-dsh-experimental-forge-session/understand-the-implementation: + en: ccc7ad69f5837528 + zh: 369c08bf4f7e6e4d +/deepseek-ai-dsh-experimental-forge-session/further-exploration: + en: afd5ea387dc53920 + zh: 1f2d48bdd1abeef7 +/deepseek-ai-dsh-experimental-forge-session/model-experience: + en: 04635389a588fa80 + zh: 29654fdca5b9f066 +/deepseek-ai-dsh-experimental-forge-session/model-experience/kv-cache-effect: + en: 1bdaeb7d102c23d8 + zh: 37a3f0bd58f68363 +/deepseek-ai-dsh-experimental-forge-session/known-limitations-and-deferred-work: + en: 3a8277e1f874da4a + zh: 987b0e6030583112 +/deepseek-ai-dsh-experimental-forge-session/known-limitations-and-deferred-work/dev-note: + en: e67f633dc6fe7773 + zh: 3bb0c20c93f16f98 diff --git a/packages/experimental/forge-session/README.md b/packages/experimental/forge-session/README.md new file mode 100644 index 0000000000..61a8d5f870 --- /dev/null +++ b/packages/experimental/forge-session/README.md @@ -0,0 +1,101 @@ +--- +description: "Bind change requests to the Session that created, pushed, or tracked them, and watch their status." +kind: "package-reference" +--- + +# @deepseek-ai/dsh-experimental-forge-session + +English | [中文](README.zh.md) + +## Summary + +List the pull requests a Session is working on without a separate registry. A host-only projection derives bindings from the Session's own `gh pr create` and `git push` shell results and from binding records. After each push, the service looks up open change requests for the pushed branches and records the unbound ones. Watching a Session polls its bound change requests through `ctx.forge`. + +## Table of Contents + +- [Use this package](#use-this-package) +- [Understand the implementation](#understand-the-implementation) +- [Further Exploration](#further-exploration) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + +----- + + +## Use this package + +Mount the service with the forge registry, a provider, and the Session projection registry. + +```yaml +- name: '@deepseek-ai/dsh-session-projection' +- name: '@deepseek-ai/dsh-experimental-forge' +- name: '@deepseek-ai/dsh-experimental-forge-github' +- name: '@deepseek-ai/dsh-experimental-forge-session' +``` + +`ctx.forgeSession.changeRequests(session)` returns the visible bindings in first-recorded order. Each entry carries the URL, its `origin` (`created`, `discovered`, or `tracked`), the provider reference or `null` when no provider serves the URL, and the latest polling result or `null`. `watch(session, listener)` is an effect on the calling fiber: while a Session has a watcher, every visible binding that a provider serves is polled, and listeners receive the complete list after each later binding change or polling result. `track(session, url)` binds a URL under the provider's canonical form, or restores a dismissed binding, and throws when no provider recognizes the URL. `dismiss(session, url)` hides a visible binding; other URLs leave the log unchanged. + +A `bash` or `pwsh` call binds evidence only from its first result, and only when the tool execution itself succeeded; the command's exit status does not matter. A command containing `gh pr create` or `gh pr new` binds every `/pull/` URL in its text output. A command containing `git push`, optionally with leading `-C`, `-c`, or long Git options, records each branch listed under a `To ` line as created, updated, forced, or already up to date; rejected, deleted, tag, and non-branch refs are skipped. For each pushed branch whose remote a provider serves, the service runs `ctx.forge.findByHead` within `discoveryTimeoutMs` and appends a `discover` record for every returned change request that is not bound, including dismissed ones. Every push repeats the lookup; failures are logged as warnings. URLs that differ only in scheme, letter case, query, fragment, or trailing slashes name the same binding. + +Binding actions are `plugin:forge-session/binding` records listed in the [experimental persistence catalog](../../../docs/experimental-persistence-catalog.md); fork and resume carry them with the shell events, so a fork starts with its parent's bindings. `discoveryTimeoutMs` is listed in the [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-experimental-forge-session). + +----- + + +## Understand the implementation + +
+Implementation internals — click to expand + +The `forgeChangeRequests` projection has no client view. It keeps shell calls awaiting their first result, every binding with its dismissal state, and each pushed branch with the sequence number of the latest result that reported it. Pending calls are dropped at `turn/end`. The service reacts to a `tool/result` event by discovering exactly the pushes whose sequence number equals the event's, and refreshes watchers when the bindings array changes. Disposing the service aborts lookups and drops their results. + +| Source | Responsibility | +|---|---| +| [`src/index.ts`](src/index.ts) | Discovery, track and dismiss records, and Session watches | +| [`src/projection.ts`](src/projection.ts) | Fold of shell calls, results, and binding records | +| [`src/commands.ts`](src/commands.ts) | Command classification and output parsing | +| [`src/types.ts`](src/types.ts) | Binding record, projection state, and entry types | + +
+ +----- + + +## Further Exploration + +- [Forge subsystem](../../../docs/subsystems/forge.md) — change request types and service reference. +- [Forge registry](../forge/README.md) — providers and shared polling. +- [Session projections](../../session/session-projection/README.md) — projection registration and checkpoints. +- [Binding decision](../../../.agents/notes/implemented/feature/2026-10-09-session-change-request-bindings.md) — why bindings derive from shell results and status stays on the host. + +----- + + +## Model Experience + +None, as bindings, discovery records, and polled status stay on the host; binding records never enter model requests. + +#### KV Cache effect + +None. Binding records are ignorable plugin records outside the model-visible conversation. + +## Known Limitations and Deferred Work + + + +- **Textual command recognition** — only `bash` and `pwsh` commands containing `gh pr create`, `gh pr new`, or `git push` count, and only `/pull/` URLs bind. Scripts, aliases, `gh api` calls, other CLIs such as `glab`, and `git push --porcelain` output are not recognized; `track` binds such change requests. +- **Push-triggered discovery** — a change request opened elsewhere after the latest push appears only after another push or a `track`. +- **Host API only** — no client view or model tool reads the bindings yet. +- **Per-Session bindings** — subagent Sessions keep their own bindings; a parent Session does not aggregate them. +- **Silent lookup failures** — lookup failures reach the host log only; neither the Session nor its watchers see them. + + +### Dev Note + +
+Working context for maintainers — click to expand + +None. + +
diff --git a/packages/experimental/forge-session/README.zh.md b/packages/experimental/forge-session/README.zh.md new file mode 100644 index 0000000000..679ec50c96 --- /dev/null +++ b/packages/experimental/forge-session/README.zh.md @@ -0,0 +1,101 @@ +--- +description: "把变更请求绑定到创建、推送或跟踪它们的 Session,并监听其状态。" +kind: "package-reference" +--- + +# @deepseek-ai/dsh-experimental-forge-session + +[English](README.md) | 中文 + +## 概述 + +无需单独的登记簿,即可列出一个 Session 正在处理的 pull request。一个仅宿主侧的投影从 Session 自己的 `gh pr create` 与 `git push` shell 结果以及绑定记录派生绑定。每次推送后,服务为被推送的分支查找打开状态的变更请求(change request,即 pull request 或 merge request),并记录尚未绑定的那些。监听一个 Session 时,会通过 `ctx.forge` 轮询其已绑定的变更请求。 + +## 目录 + +- [使用本包](#use-this-package) +- [了解实现](#understand-the-implementation) +- [深入探索](#further-exploration) +- [模型体验](#model-experience) +- [已知限制与待办工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 使用本包 + +将服务与 forge 注册表、一个提供方以及 Session 投影注册表一起挂载。 + +```yaml +- name: '@deepseek-ai/dsh-session-projection' +- name: '@deepseek-ai/dsh-experimental-forge' +- name: '@deepseek-ai/dsh-experimental-forge-github' +- name: '@deepseek-ai/dsh-experimental-forge-session' +``` + +`ctx.forgeSession.changeRequests(session)` 按首次记录的顺序返回可见的绑定。每一项包含 URL、其 `origin`(`created`、`discovered` 或 `tracked`)、提供方引用(没有提供方服务该 URL 时为 `null`)以及最近一次轮询结果(尚无结果时为 `null`)。`watch(session, listener)` 是调用方 fiber 上的 effect:Session 有监听方期间,提供方服务的每个可见绑定都会被轮询;此后每次绑定变化或轮询结果到达时,监听方都会收到完整列表。`track(session, url)` 以提供方的规范形式绑定 URL,或恢复已隐藏的绑定;没有提供方识别该 URL 时抛错。`dismiss(session, url)` 隐藏一个可见绑定;其他 URL 不会改变日志。 + +`bash` 或 `pwsh` 调用只从其第一个结果中提取证据,且仅当工具执行本身成功时才提取;命令的退出状态不影响结果。包含 `gh pr create` 或 `gh pr new` 的命令会绑定其文本输出中的每个 `/pull/` URL。包含 `git push` 的命令(可带前置的 `-C`、`-c` 或 Git 长选项)会记录 `To ` 行下列出的每个已创建、已更新、已强制推送或已是最新的分支;被拒绝、已删除、标签以及非分支的引用会被跳过。对远端由提供方服务的每个被推送分支,服务在 `discoveryTimeoutMs` 内调用 `ctx.forge.findByHead`,并为返回的每个尚未绑定(包括已隐藏)的变更请求追加一条 `discover` 记录。每次推送都会重新查找;失败记录为警告日志。仅在协议、大小写、查询、片段或末尾斜杠上不同的 URL 指向同一个绑定。 + +绑定操作是 `plugin:forge-session/binding` 插件记录,列于[实验性持久化目录](../../../docs/experimental-persistence-catalog.zh.md);fork 与恢复会把它们与 shell 事件一同带上,因此 fork 出的 Session 从父 Session 的绑定开始。`discoveryTimeoutMs` 见[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-experimental-forge-session)。 + +----- + + +## 了解实现 + +
+实现细节——点击展开 + +`forgeChangeRequests` 投影没有客户端视图。它保存等待第一个结果的 shell 调用、每个绑定及其隐藏状态,以及每个被推送分支和最近一次报告它的结果的序号。`turn/end` 会丢弃仍在等待的调用。服务收到 `tool/result` 事件时,只对序号等于该事件序号的推送执行发现,并在绑定数组变化时刷新监听方。销毁服务会中止查找并丢弃其结果。 + +| 源码 | 职责 | +|---|---| +| [`src/index.ts`](src/index.ts) | 发现、track 与 dismiss 记录以及 Session 监听 | +| [`src/projection.ts`](src/projection.ts) | 折叠 shell 调用、结果与绑定记录 | +| [`src/commands.ts`](src/commands.ts) | 命令分类与输出解析 | +| [`src/types.ts`](src/types.ts) | 绑定记录、投影状态与条目类型 | + +
+ +----- + + +## 深入探索 + +- [Forge 子系统](../../../docs/subsystems/forge.zh.md)——变更请求类型与服务参考。 +- [Forge 注册表](../forge/README.zh.md)——提供方与共享轮询。 +- [Session 投影](../../session/session-projection/README.zh.md)——投影注册与检查点。 +- [绑定决策](../../../.agents/notes/implemented/feature/2026-10-09-session-change-request-bindings.zh.md)——为何绑定从 shell 结果派生、状态只留在宿主侧。 + +----- + + +## 模型体验 + +无:绑定、发现记录与轮询到的状态都留在宿主侧;绑定记录不会进入模型请求。 + +#### KV Cache 影响 + +无。绑定记录是位于模型可见对话之外的可忽略插件记录。 + +## 已知限制与待办工作 + + + +- **按文本识别命令**——只有包含 `gh pr create`、`gh pr new` 或 `git push` 的 `bash` 与 `pwsh` 命令计入,也只有 `/pull/` URL 会被绑定。脚本、别名、`gh api` 调用、`glab` 等其他 CLI 以及 `git push --porcelain` 输出都无法识别;这类变更请求需用 `track` 绑定。 +- **仅由推送触发发现**——最近一次推送之后在别处打开的变更请求,要等到下一次推送或 `track` 才会出现。 +- **只有宿主 API**——目前还没有客户端视图或模型工具读取这些绑定。 +- **按 Session 绑定**——subagent 的 Session 保有自己的绑定;父 Session 不会汇总它们。 +- **查找失败不可见**——查找失败只写入宿主日志;Session 与其监听方都看不到。 + + +### 开发备注 + +
+维护者的工作上下文——点击展开 + +无。 + +
diff --git a/packages/experimental/forge-session/package.json b/packages/experimental/forge-session/package.json new file mode 100644 index 0000000000..24541e2c7b --- /dev/null +++ b/packages/experimental/forge-session/package.json @@ -0,0 +1,61 @@ +{ + "name": "@deepseek-ai/dsh-experimental-forge-session", + "description": "Binds change requests to the Session that created, pushed, or tracked them", + "version": "0.2.1-rc.2", + "publishConfig": { + "access": "public" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/types/**/*.d.ts" + ], + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/experimental/forge-session" + }, + "dependencies": { + "@deepseek-ai/schemastery": "workspace:~", + "@deepseek-ai/dsh-timeout": "workspace:*", + "@deepseek-ai/dsh-util-values": "workspace:*", + "zod": "^4.4.3" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-experimental-forge": "workspace:*", + "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/dsh-session-projection": "workspace:*" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/cordis-plugin-include": "workspace:~", + "@deepseek-ai/cordis-plugin-loader": "workspace:~", + "@deepseek-ai/dsh-agent": "workspace:*", + "@deepseek-ai/dsh-agent-loop": "workspace:*", + "@deepseek-ai/dsh-bash-local": "workspace:*", + "@deepseek-ai/dsh-experimental-forge": "workspace:*", + "@deepseek-ai/dsh-experimental-forge-github": "workspace:*", + "@deepseek-ai/dsh-fs-local": "workspace:*", + "@deepseek-ai/dsh-llm": "workspace:*", + "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/dsh-session-projection": "workspace:*", + "@deepseek-ai/dsh-shell-env": "workspace:*", + "@deepseek-ai/dsh-subprocess-local": "workspace:*", + "@deepseek-ai/dsh-system-prompt": "workspace:*", + "@deepseek-ai/dsh-tool-bash": "workspace:*", + "@deepseek-ai/dsh-tools": "workspace:*", + "@deepseek-ai/dsh-working-directory": "workspace:*" + } +} diff --git a/packages/experimental/forge-session/src/commands.ts b/packages/experimental/forge-session/src/commands.ts new file mode 100644 index 0000000000..e95eedba87 --- /dev/null +++ b/packages/experimental/forge-session/src/commands.ts @@ -0,0 +1,103 @@ +/** Recognition of shell commands and output that bind change requests to a Session. */ + +import type { CommandKind } from './types.ts' + +/** One branch a `git push` output reported as updated. */ +export interface PushedRef { + /** Remote as `git push` printed it after `To`. */ + readonly remote: string + /** Branch name on the remote. */ + readonly branch: string +} + +const CREATE_COMMAND = /\bgh\s+pr\s+(?:create|new)\b/ +const PUSH_COMMAND = /\bgit(?:\s+(?:-[Cc]\s+\S+|--[\w-]+(?:=\S+)?))*\s+push\b/ +const CHANGE_REQUEST_URL = /\bhttps?:\/\/[^\s"'<>]+?\/pull\/\d+(?![\w-])/g +const PUSH_TARGET_LINE = /^To (\S+)$/ +const PUSH_REF_LINE = /^ [ +*=] (\[[^\]]+\]|\S+) +\S+ -> (\S+)(?: \(.*\))?$/ + +/** + * Read the command string of a `bash` or `pwsh` tool call. + * @param argumentsJson - raw tool-call arguments as the model produced them. + * @returns the command, or undefined when the arguments are not JSON or carry no string `command`. + */ +export function commandOf(argumentsJson: string): string | undefined { + let parsed: unknown + try { + parsed = JSON.parse(argumentsJson) + } catch (error) { + void error // Malformed arguments never ran a command. + return undefined + } + if (typeof parsed !== 'object' || parsed === null) return undefined + const command: unknown = (parsed as { command?: unknown }).command + return typeof command === 'string' ? command : undefined +} + +/** + * Classify the binding evidence a command's output can carry. `gh pr create` + * (or its alias `gh pr new`) prints the change request URL; `git push`, with + * or without leading `-C`, `-c`, and long options, prints the updated refs. + * The match is textual, so quoted mentions also classify; the result parsers + * then find no evidence in their output. + * @param command - shell command text. + * @returns the evidence kind, or undefined when the command has neither form. + */ +export function commandKind(command: string): CommandKind | undefined { + const create = CREATE_COMMAND.test(command) + const push = PUSH_COMMAND.test(command) + if (create) return push ? 'both' : 'create' + return push ? 'push' : undefined +} + +/** + * Find GitHub pull request URLs in command output. + * @param text - rendered shell result. + * @returns distinct URLs in order of first appearance. + */ +export function changeRequestUrls(text: string): string[] { + return [...new Set(text.match(CHANGE_REQUEST_URL))] +} + +/** + * Find branches that `git push` output reports as created, updated, forced, + * or already up to date. Each ref line belongs to the nearest preceding + * `To ` line. Rejected and deleted refs, tags, and refs outside + * `refs/heads/` are excluded. + * @param text - rendered shell result, stdout and stderr. + * @returns distinct pushed branches in order of first appearance. + */ +export function pushedBranches(text: string): PushedRef[] { + const pushed: PushedRef[] = [] + let remote: string | undefined + for (const line of text.split(/\r?\n/)) { + const target = PUSH_TARGET_LINE.exec(line) + if (target !== null) { + [, remote = ''] = target + continue + } + const ref = PUSH_REF_LINE.exec(line) + if (remote === undefined || ref === null) continue + const [, summary = '', destination = ''] = ref + if (summary === '[new tag]') continue + const branch = destination.startsWith('refs/heads/') ? destination.slice('refs/heads/'.length) : destination + if (branch.startsWith('refs/')) continue + if (!pushed.some(entry => entry.remote === remote && entry.branch === branch)) pushed.push({ remote, branch }) + } + return pushed +} + +/** + * Compare two change request URLs ignoring scheme, letter case, query, + * fragment, and trailing slashes. + * @param left - one URL. + * @param right - the other URL. + * @returns whether both name the same web page. + */ +export function sameChangeRequestUrl(left: string, right: string): boolean { + return urlIdentity(left) === urlIdentity(right) +} + +function urlIdentity(url: string): string { + return url.replace(/^https?:\/\//i, '').replace(/[?#].*$/s, '').replace(/\/+$/, '').toLowerCase() +} diff --git a/packages/experimental/forge-session/src/index.ts b/packages/experimental/forge-session/src/index.ts new file mode 100644 index 0000000000..b3e968a364 --- /dev/null +++ b/packages/experimental/forge-session/src/index.ts @@ -0,0 +1,223 @@ +/** Binds change requests to the Session that created, pushed, or tracked them, and watches the bound ones. */ + +import { Context, Service } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' +import { changeRequestKey } from '@deepseek-ai/dsh-experimental-forge' +import type { ChangeRequestKey, ChangeRequestRef, ForgeRepository } from '@deepseek-ai/dsh-experimental-forge' +import { appendPluginRecord, pluginRecordOf } from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import type { ProjectionHandle } from '@deepseek-ai/dsh-session-projection' +import { deadline, MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' +import { sameChangeRequestUrl } from './commands.ts' +import { BINDING_RECORD, forgeChangeRequestsProjection } from './projection.ts' +import type { BindingState, Config, ForgeChangeRequestsState, PushedBranch, SessionChangeRequest, SessionChangeRequestsListener } from './types.ts' + +export type * from './types.ts' + +declare module '@deepseek-ai/cordis' { + interface Context { + forgeSession: ForgeSessionService + } +} + +/** Watchers of one Session and the change request watches they share. */ +interface SessionWatch { + readonly listeners: Set + /** Projection bindings that `forgeWatches` and the last notification reflect. */ + bindings: readonly BindingState[] + readonly forgeWatches: Map void> +} + +/** + * Session-scoped change request bindings. The `forgeChangeRequests` + * projection derives them from `gh pr create` and `git push` results and from + * `plugin:forge-session/binding` records. After each push result the service + * looks up open change requests whose head is a pushed branch and records the + * unbound ones as discovered. Watching a Session polls its visible change + * requests through `ctx.forge`. + */ +export class ForgeSessionService extends Service { + static inject = ['forge', 'sessionProjections'] + + static Config: z = z.object({ + discoveryTimeoutMs: z.number().default(30_000), + }) + + private readonly home: Context + private readonly discoveryTimeoutMs: number + private readonly watches = new WeakMap() + private readonly discoveries = new Set>() + private readonly lifetime = new AbortController() + private projection!: ProjectionHandle + + constructor(ctx: Context, config: Config) { + super(ctx, 'forgeSession') + const { discoveryTimeoutMs } = config as Required + if (!Number.isSafeInteger(discoveryTimeoutMs) || discoveryTimeoutMs <= 0 || discoveryTimeoutMs > MAX_TIMER_DELAY_MS) { + throw new Error(`forge-session: discoveryTimeoutMs must be a positive integer no greater than ${MAX_TIMER_DELAY_MS}`) + } + this.home = ctx + this.discoveryTimeoutMs = discoveryTimeoutMs + ctx.effect(() => async () => { + this.lifetime.abort(new Error('forge-session service disposed')) + await Promise.allSettled(this.discoveries) + }, 'forge-session: stop discovery') + } + + /** + * List the change requests bound to a Session and not dismissed. + * @param session - Session whose bindings are read. + * @returns visible bindings in first-recorded order, each with its latest polling result. + */ + changeRequests(session: Session): SessionChangeRequest[] { + return this.entries(this.state(session).bindings) + } + + /** + * Bind a change request to a Session by URL, or restore a dismissed binding. + * Appends a `track` record with the provider's canonical URL unless the + * change request is already visible. + * @param session - Session that receives the binding. + * @param url - web URL of the change request. + * @throws when no registered provider recognizes the URL. + */ + track(session: Session, url: string): void { + const ref = this.home.forge.changeRequestFromUrl(url) + if (ref === undefined) throw new Error(`forge-session: no forge provider recognizes ${JSON.stringify(url)}`) + const canonical = this.home.forge.changeRequestUrl(ref) + const existing = this.state(session).bindings.find(binding => sameChangeRequestUrl(binding.url, canonical)) + if (existing === undefined || existing.dismissed) appendPluginRecord(session, BINDING_RECORD, { action: 'track', url: canonical }) + } + + /** + * Hide a visible binding. Appends a `dismiss` record; a URL that is not + * visibly bound leaves the log unchanged. Discovery never restores a + * dismissed binding; `track` does. + * @param session - Session that owns the binding. + * @param url - web URL of the change request. + */ + dismiss(session: Session, url: string): void { + const existing = this.state(session).bindings.find(binding => !binding.dismissed && sameChangeRequestUrl(binding.url, url)) + if (existing !== undefined) appendPluginRecord(session, BINDING_RECORD, { action: 'dismiss', url: existing.url }) + } + + /** + * Watch a Session's visible change requests as an effect on the calling + * fiber. Every visible change request that a provider recognizes is polled + * through `ctx.forge` while the Session has a watcher. Listeners receive the + * complete list after each later binding change or polling result; read the + * current list with {@link changeRequests}. Listeners must not throw; a + * thrown error is logged and skipped. + * @param session - Session to watch. + * @param listener - receives the complete visible list. + * @returns the disposer that stops this watch. + */ + watch(session: Session, listener: SessionChangeRequestsListener): () => void { + const dispose = this.ctx.effect(() => { + let watch = this.watches.get(session) + if (watch === undefined) { + watch = { listeners: new Set(), bindings: this.state(session).bindings, forgeWatches: new Map() } + this.watches.set(session, watch) + this.sync(watch) + } + const owned = watch + owned.listeners.add(listener) + return () => { + owned.listeners.delete(listener) + if (owned.listeners.size > 0) return + this.watches.delete(session) + for (const unwatch of owned.forgeWatches.values()) unwatch() + owned.forgeWatches.clear() + } + }, 'forgeSession.watch()') + return () => void dispose() + } + + protected async [Service.init](): Promise { + this.projection = await this.home.sessionProjections.register(forgeChangeRequestsProjection) + // Events react against the projection, so the listener starts once its reader is ready. + this.home.on('session/event', (session: Session, event: SessionEvent) => { + this.react(session, event) + }) + } + + private state(session: Session): ForgeChangeRequestsState { + return this.projection.stateOf(session) + } + + private entries(bindings: readonly BindingState[]): SessionChangeRequest[] { + const entries: SessionChangeRequest[] = [] + for (const binding of bindings) { + if (binding.dismissed) continue + const ref = this.home.forge.changeRequestFromUrl(binding.url) ?? null + const update = ref === null ? null : this.home.forge.latest(ref) ?? null + entries.push({ url: binding.url, origin: binding.origin, ref, update }) + } + return entries + } + + /** Discover change requests for this result's pushes and refresh watchers after binding changes. */ + private react(session: Session, event: SessionEvent): void { + if (event.type !== 'tool/result' && pluginRecordOf(event)?.type !== BINDING_RECORD) return + const state = this.state(session) + for (const push of state.pushes) { + if (push.seq === event.seq) this.discover(session, push) + } + const watch = this.watches.get(session) + if (watch === undefined || watch.bindings === state.bindings) return + watch.bindings = state.bindings + this.sync(watch) + this.notify(watch) + } + + /** Poll exactly the visible change requests that a provider recognizes. */ + private sync(watch: SessionWatch): void { + const wanted = new Map() + for (const entry of this.entries(watch.bindings)) { + if (entry.ref !== null) wanted.set(changeRequestKey(entry.ref), entry.ref) + } + for (const [key, unwatch] of watch.forgeWatches) { + if (wanted.has(key)) continue + unwatch() + watch.forgeWatches.delete(key) + } + for (const [key, ref] of wanted) { + if (!watch.forgeWatches.has(key)) watch.forgeWatches.set(key, this.home.forge.watch(ref, () => { this.notify(watch) })) + } + } + + private notify(watch: SessionWatch): void { + const changeRequests = this.entries(watch.bindings) + for (const listener of [...watch.listeners]) { + try { + listener(changeRequests) + } catch (error) { + this.home.logger.warn(`forge-session: change request listener failed: ${String(error)}`) + } + } + } + + private discover(session: Session, push: PushedBranch): void { + const repository = this.home.forge.repositoryFromRemote(push.remote) + if (repository === undefined) return + const task = this.lookup(session, repository, push.branch).catch((error: unknown) => { + if (this.lifetime.signal.aborted) return + this.home.logger.warn(`forge-session: change request lookup for ${push.branch} failed: ${error instanceof Error ? error.message : String(error)}`) + }) + this.discoveries.add(task) + void task.then(() => this.discoveries.delete(task)) + } + + private async lookup(session: Session, repository: ForgeRepository, branch: string): Promise { + using timeout = deadline(this.lifetime.signal, this.discoveryTimeoutMs, 'FORGE_DISCOVERY_TIMEOUT') + const refs = await this.home.forge.findByHead(repository, branch, timeout.signal) + if (this.lifetime.signal.aborted) return + for (const ref of refs) { + const url = this.home.forge.changeRequestUrl(ref) + if (this.state(session).bindings.some(binding => sameChangeRequestUrl(binding.url, url))) continue + appendPluginRecord(session, BINDING_RECORD, { action: 'discover', url }) + } + } +} + +export default ForgeSessionService diff --git a/packages/experimental/forge-session/src/projection.ts b/packages/experimental/forge-session/src/projection.ts new file mode 100644 index 0000000000..3242ceea5f --- /dev/null +++ b/packages/experimental/forge-session/src/projection.ts @@ -0,0 +1,133 @@ +/** Host-only projection that folds shell results and binding records into one Session's change request bindings. */ + +import { z } from 'zod' +import { pluginRecordOf, SessionSeq } from '@deepseek-ai/dsh-session' +import type { SessionEvent, SessionEventMap } from '@deepseek-ai/dsh-session' +import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' +import { assertNever } from '@deepseek-ai/dsh-util-values' +import { changeRequestUrls, commandKind, commandOf, pushedBranches, sameChangeRequestUrl } from './commands.ts' +import type { PushedRef } from './commands.ts' +import type { BindingOrigin, BindingRecord, BindingState, ForgeChangeRequestsState, PushedBranch } from './types.ts' + +/** Plugin record type of binding actions. */ +export const BINDING_RECORD = 'plugin:forge-session/binding' + +/** Tools whose calls run shell commands with a string `command` argument. */ +const SHELL_TOOLS: ReadonlySet = new Set(['bash', 'pwsh']) + +const EMPTY: ForgeChangeRequestsState = Object.freeze({ + pending: Object.freeze([]), + bindings: Object.freeze([]), + pushes: Object.freeze([]), +}) + +const bindingRecordSchema: z.ZodType = z.object({ + action: z.enum(['track', 'dismiss', 'discover']), + url: z.string().regex(/^https?:\/\/\S+$/), +}) + +const stateSchema: z.ZodType = z.object({ + pending: z.array(z.object({ callId: z.string(), kind: z.enum(['create', 'push', 'both']) }).strict()), + bindings: z.array(z.object({ + url: z.string(), + origin: z.enum(['created', 'discovered', 'tracked']), + dismissed: z.boolean(), + }).strict()), + pushes: z.array(z.object({ + remote: z.string(), + branch: z.string(), + seq: z.number().int().nonnegative().transform(value => SessionSeq(value)), + }).strict()), +}).strict() + +/** + * The `forgeChangeRequests` unit. A `bash` or `pwsh` call whose command is + * `gh pr create` or `git push` stays pending until its first result; a + * successful result binds the pull request URLs it prints and records the + * branches it reports as pushed. Failed tool executions bind nothing; a + * nonzero exit status does not matter, because the printed URL or ref line + * is the evidence. `turn/end` drops calls that never received a result. + * Binding records bind, restore, and hide URLs. + */ +export const forgeChangeRequestsProjection = { + key: 'forgeChangeRequests', + stateVersion: 1, + stateSchema, + init: () => EMPTY, + apply, +} satisfies ProjectionDefinition<'forgeChangeRequests', ForgeChangeRequestsState> + +function apply(state: ForgeChangeRequestsState, event: SessionEvent): ForgeChangeRequestsState { + switch (event.type) { + case 'tool/call': + return applyCall(state, event.data) + case 'tool/result': + return applyResult(state, event.data, event.seq) + case 'turn/end': + return state.pending.length === 0 ? state : { ...state, pending: EMPTY.pending } + // Every other event type, including plugin records, which sit outside SessionEventMap. + default: + return applyRecord(state, event) + } +} + +function applyCall(state: ForgeChangeRequestsState, call: SessionEventMap['tool/call']): ForgeChangeRequestsState { + if (!SHELL_TOOLS.has(call.name)) return state + const command = commandOf(call.arguments) + const kind = command === undefined ? undefined : commandKind(command) + if (kind === undefined) return state + return { ...state, pending: [...state.pending, { callId: call.callId, kind }] } +} + +function applyResult(state: ForgeChangeRequestsState, result: SessionEventMap['tool/result'], seq: SessionSeq): ForgeChangeRequestsState { + const callId = result.message.toolCallId + const pending = state.pending.find(entry => entry.callId === callId) + if (pending === undefined) return state + const rest = state.pending.filter(entry => entry !== pending) + if (result.message.isError === true) return { ...state, pending: rest } + const text = result.message.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('\n') + return { + pending: rest, + bindings: pending.kind === 'push' ? state.bindings : bind(state.bindings, changeRequestUrls(text), 'created'), + pushes: pending.kind === 'create' ? state.pushes : recordPushes(state.pushes, pushedBranches(text), seq), + } +} + +function applyRecord(state: ForgeChangeRequestsState, event: SessionEvent): ForgeChangeRequestsState { + const record = pluginRecordOf(event) + if (record?.type !== BINDING_RECORD) return state + const parsed = bindingRecordSchema.safeParse(record.data) + if (!parsed.success) return state + const { action, url } = parsed.data + const index = state.bindings.findIndex(binding => sameChangeRequestUrl(binding.url, url)) + const existing = state.bindings[index] + switch (action) { + case 'discover': + return existing === undefined ? { ...state, bindings: bind(state.bindings, [url], 'discovered') } : state + case 'track': + if (existing === undefined) return { ...state, bindings: bind(state.bindings, [url], 'tracked') } + return existing.dismissed ? { ...state, bindings: state.bindings.with(index, { ...existing, dismissed: false }) } : state + case 'dismiss': + return existing !== undefined && !existing.dismissed + ? { ...state, bindings: state.bindings.with(index, { ...existing, dismissed: true }) } + : state + /* v8 ignore next -- closed BindingRecord action union exhaustiveness guard */ + default: return assertNever(action, 'BindingRecord.action') + } +} + +/** Append each URL not already bound; returns the input array when nothing is new. */ +function bind(bindings: readonly BindingState[], urls: readonly string[], origin: BindingOrigin): readonly BindingState[] { + let next = bindings + for (const url of urls) { + if (!next.some(binding => sameChangeRequestUrl(binding.url, url))) next = [...next, { url, origin, dismissed: false }] + } + return next +} + +/** Move each reported branch to the end with the reporting event's sequence number. */ +function recordPushes(pushes: readonly PushedBranch[], pushed: readonly PushedRef[], seq: SessionSeq): readonly PushedBranch[] { + if (pushed.length === 0) return pushes + const kept = pushes.filter(entry => !pushed.some(ref => ref.remote === entry.remote && ref.branch === entry.branch)) + return [...kept, ...pushed.map(ref => ({ remote: ref.remote, branch: ref.branch, seq }))] +} diff --git a/packages/experimental/forge-session/src/types.ts b/packages/experimental/forge-session/src/types.ts new file mode 100644 index 0000000000..b9c6083cb9 --- /dev/null +++ b/packages/experimental/forge-session/src/types.ts @@ -0,0 +1,92 @@ +/** Session binding vocabulary, the binding plugin record, and the host-only projection state. */ + +import type { ChangeRequestRef, ChangeRequestUpdate } from '@deepseek-ai/dsh-experimental-forge' +import type { SessionSeq } from '@deepseek-ai/dsh-session' + +/** + * How a change request became bound to a Session: `created` when a + * `gh pr create` result printed its URL, `discovered` when a lookup after a + * `git push` found it for the pushed branch, `tracked` when a caller bound it + * by URL. + */ +export type BindingOrigin = 'created' | 'discovered' | 'tracked' + +/** Which binding evidence a shell command can print: change request URLs, pushed refs, or both. */ +export type CommandKind = 'create' | 'push' | 'both' + +/** Payload of the `plugin:forge-session/binding` record. */ +export interface BindingRecord { + /** `track` binds or restores, `dismiss` hides, `discover` binds a change request found for a pushed branch. */ + readonly action: 'track' | 'dismiss' | 'discover' + /** Web URL of the change request. */ + readonly url: string +} + +/** One shell call whose result the projection still awaits. */ +export interface PendingCommand { + /** Tool call id that pairs the call with its result. */ + readonly callId: string + /** Evidence the result may carry. */ + readonly kind: CommandKind +} + +/** One change request remembered by the projection, including hidden ones. */ +export interface BindingState { + /** Web URL as first recorded. */ + readonly url: string + /** How the change request became bound. */ + readonly origin: BindingOrigin + /** Whether a dismissal hides the binding. */ + readonly dismissed: boolean +} + +/** One branch that a `git push` result reported as updated on a remote. */ +export interface PushedBranch { + /** Remote as `git push` printed it after `To`. */ + readonly remote: string + /** Branch name on the remote. */ + readonly branch: string + /** Sequence number of the latest `tool/result` event that reported this branch. */ + readonly seq: SessionSeq +} + +/** Host-only projection state folded from shell results and binding records. */ +export interface ForgeChangeRequestsState { + /** Shell calls awaiting their result, in call order. */ + readonly pending: readonly PendingCommand[] + /** Bound change requests in first-recorded order. */ + readonly bindings: readonly BindingState[] + /** Pushed branches, one entry per remote and branch, ordered by latest push. */ + readonly pushes: readonly PushedBranch[] +} + +/** One change request bound to a Session and not dismissed, with its latest polling result. */ +export interface SessionChangeRequest { + /** Web URL as first recorded. */ + readonly url: string + /** How the change request became bound. */ + readonly origin: BindingOrigin + /** Parsed reference, or null when no registered provider serves the URL. */ + readonly ref: ChangeRequestRef | null + /** Latest polling result, or null before the first poll and without a provider. */ + readonly update: ChangeRequestUpdate | null +} + +/** + * Receives the complete visible list after a binding change or a polling result. + * @param changeRequests - every visible binding of the watched Session, in first-recorded order. + */ +export type SessionChangeRequestsListener = (changeRequests: readonly SessionChangeRequest[]) => void + +/** Deployment settings of `forgeSession`. */ +export interface Config { + /** Deadline in milliseconds for one change request lookup after a push. */ + discoveryTimeoutMs?: number +} + +declare module '@deepseek-ai/dsh-session/types' { + interface PluginRecordMap { + /** A caller or discovery action that binds, restores, or hides one change request. */ + 'plugin:forge-session/binding': BindingRecord + } +} diff --git a/packages/experimental/forge-session/tests/commands.spec.ts b/packages/experimental/forge-session/tests/commands.spec.ts new file mode 100644 index 0000000000..b4918541f6 --- /dev/null +++ b/packages/experimental/forge-session/tests/commands.spec.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from 'vitest' +import { changeRequestUrls, commandKind, commandOf, pushedBranches, sameChangeRequestUrl } from '../src/commands.ts' + +describe('commandOf', () => { + it('reads the string command argument', () => { + expect(commandOf('{"command":"git push","description":"push"}')).toBe('git push') + }) + + it.each([ + ['malformed JSON', '{"command":'], + ['a JSON string', '"git push"'], + ['JSON null', 'null'], + ['a missing command', '{"cmd":"git push"}'], + ['a non-string command', '{"command":["git","push"]}'], + ])('returns undefined for %s', (_label, raw) => { + expect(commandOf(raw)).toBeUndefined() + }) +}) + +describe('commandKind', () => { + it.each([ + ['gh pr create --fill', 'create'], + ['gh pr new -t title -b body', 'create'], + ['git push', 'push'], + ['git push -u origin HEAD', 'push'], + ['git -C ../checkout push origin feat', 'push'], + ['git -c credential.helper= push', 'push'], + ['git --no-pager push --force-with-lease', 'push'], + ['cd repo && git push && gh pr create --fill', 'both'], + ])('classifies %j as %s', (command, kind) => { + expect(commandKind(command)).toBe(kind) + }) + + it.each([ + 'git commit -m "push the fix"', + 'git stash push -m wip', + 'gh pr view 12', + 'gh pr list --head feat', + 'git-push origin', + ])('ignores %j', (command) => { + expect(commandKind(command)).toBeUndefined() + }) +}) + +describe('changeRequestUrls', () => { + it('finds distinct pull request URLs in order', () => { + const text = [ + 'https://github.com/octo/demo/pull/12', + 'see (https://github.com/octo/demo/pull/3/files) and https://github.com/octo/demo/pull/12', + '[link](http://ghe.corp.example/team/svc/pull/7)', + ].join('\n') + + expect(changeRequestUrls(text)).toEqual([ + 'https://github.com/octo/demo/pull/12', + 'https://github.com/octo/demo/pull/3', + 'http://ghe.corp.example/team/svc/pull/7', + ]) + }) + + it('ignores compare links and malformed numbers', () => { + expect(changeRequestUrls('https://github.com/octo/demo/pull/new/feat https://github.com/octo/demo/pull/12abc')).toEqual([]) + }) +}) + +describe('pushedBranches', () => { + it('reads created, updated, forced, and current branches under their remote', () => { + const text = [ + 'To github.com:octo/demo.git', + ' * [new branch] feat -> feat', + ' 1a2b3c4..5d6e7f8 main -> main', + ' + 1a2b3c4...5d6e7f8 topic -> topic (forced update)', + ' = [up to date] stable -> stable', + ' * [new branch] HEAD -> refs/heads/review', + ].join('\n') + + expect(pushedBranches(text)).toEqual([ + { remote: 'github.com:octo/demo.git', branch: 'feat' }, + { remote: 'github.com:octo/demo.git', branch: 'main' }, + { remote: 'github.com:octo/demo.git', branch: 'topic' }, + { remote: 'github.com:octo/demo.git', branch: 'stable' }, + { remote: 'github.com:octo/demo.git', branch: 'review' }, + ]) + }) + + it('excludes rejected, deleted, tag, and non-branch refs', () => { + const text = [ + 'To https://github.com/octo/demo.git', + ' ! [rejected] feat -> feat (fetch first)', + ' ! [remote rejected] main -> main (protected branch hook declined)', + ' - [deleted] old', + ' * [new tag] v1.0 -> v1.0', + ' * [new reference] HEAD -> refs/for/main', + 'error: failed to push some refs to \'https://github.com/octo/demo.git\'', + ].join('\n') + + expect(pushedBranches(text)).toEqual([]) + }) + + it('assigns each ref line to the nearest preceding remote and ignores ref lines before any remote', () => { + const text = [ + ' * [new branch] orphan -> orphan', + 'To fake.test:octo/one.git\r', + ' * [new branch] feat -> feat\r', + 'To fake.test:octo/two.git', + ' * [new branch] feat -> feat', + ' * [new branch] feat -> feat', + ].join('\n') + + expect(pushedBranches(text)).toEqual([ + { remote: 'fake.test:octo/one.git', branch: 'feat' }, + { remote: 'fake.test:octo/two.git', branch: 'feat' }, + ]) + }) +}) + +describe('sameChangeRequestUrl', () => { + it('ignores scheme, letter case, query, fragment, and trailing slashes', () => { + expect(sameChangeRequestUrl('https://github.com/Octo/Demo/pull/1/', 'http://github.com/octo/demo/pull/1?tab=checks#top')).toBe(true) + }) + + it('distinguishes different numbers and repositories', () => { + expect(sameChangeRequestUrl('https://github.com/octo/demo/pull/1', 'https://github.com/octo/demo/pull/10')).toBe(false) + expect(sameChangeRequestUrl('https://github.com/octo/demo/pull/1', 'https://github.com/octo/other/pull/1')).toBe(false) + }) +}) diff --git a/packages/experimental/forge-session/tests/fixtures.ts b/packages/experimental/forge-session/tests/fixtures.ts new file mode 100644 index 0000000000..bfdc21719e --- /dev/null +++ b/packages/experimental/forge-session/tests/fixtures.ts @@ -0,0 +1,171 @@ +import { Context } from '@deepseek-ai/cordis' +import ForgeService, { changeRequestKey } from '@deepseek-ai/dsh-experimental-forge' +import type { + ChangeRequestKey, ChangeRequestRef, ChangeRequestSnapshot, ForgeFailure, ForgeProvider, ForgeRepository, +} from '@deepseek-ai/dsh-experimental-forge' +import { createAssistantMessage, createToolResultMessage, ToolCallId } from '@deepseek-ai/dsh-llm' +import SessionStore from '@deepseek-ai/dsh-session' +import type { Session } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import ForgeSessionService from '../src/index.ts' +import type { Config } from '../src/index.ts' + +/** Repository served by {@link FakeProvider}. */ +export const demo: ForgeRepository = { forge: 'fake', host: 'fake.test', owner: 'octo', name: 'demo' } + +/** Pull request reference in `demo`. */ +export function pull(number: number): ChangeRequestRef { + return { repository: demo, number } +} + +/** Canonical URL of a `demo` pull request. */ +export function pullUrl(number: number): string { + return `https://fake.test/octo/demo/pull/${number}` +} + +/** Open pull request snapshot with settled checks. */ +export function snapshot(ref: ChangeRequestRef, overrides: Partial = {}): ChangeRequestSnapshot { + return { + ref, + url: pullUrl(ref.number), + title: `Change ${ref.number}`, + state: 'open', + draft: false, + author: 'octo', + headBranch: 'feat', + headSha: 'a'.repeat(40), + baseBranch: 'main', + mergeability: 'mergeable', + reviewDecision: 'none', + checks: [], + checksRollup: 'success', + checksTruncated: false, + unresolvedThreads: 0, + updatedAt: '2026-10-09T00:00:00Z', + ...overrides, + } +} + +/** Provider for `fake.test` remotes (`fake.test:owner/name.git`) and `https://fake.test/owner/name/pull/N` URLs. */ +export class FakeProvider implements ForgeProvider { + readonly forge = 'fake' + /** Change requests returned by head lookups, keyed by branch. */ + readonly heads = new Map() + /** Every head lookup in call order. */ + readonly lookups: { readonly repository: ForgeRepository; readonly branch: string; readonly signal: AbortSignal }[] = [] + /** Every fetch batch in call order. */ + readonly fetches: (readonly number[])[] = [] + /** Replaces the head lookup when set. */ + findImpl: ((branch: string, signal: AbortSignal) => Promise) | undefined + + repositoryFromRemote(url: string): ForgeRepository | undefined { + const match = /^fake\.test:([^/]+)\/([^/]+?)(?:\.git)?$/.exec(url) + return match === null ? undefined : { ...demo, owner: match[1] ?? '', name: match[2] ?? '' } + } + + changeRequestFromUrl(url: string): ChangeRequestRef | undefined { + const match = /^https?:\/\/fake\.test\/([^/]+)\/([^/]+)\/pull\/(\d+)/i.exec(url) + return match === null ? undefined : { repository: { ...demo, owner: match[1] ?? '', name: match[2] ?? '' }, number: Number(match[3]) } + } + + changeRequestUrl(ref: ChangeRequestRef): string { + return `https://fake.test/${ref.repository.owner}/${ref.repository.name}/pull/${ref.number}` + } + + fetch(refs: readonly ChangeRequestRef[]): Promise> { + this.fetches.push(refs.map(ref => ref.number)) + return Promise.resolve(new Map(refs.map(ref => [changeRequestKey(ref), snapshot(ref)]))) + } + + findByHead(repository: ForgeRepository, branch: string, signal: AbortSignal): Promise { + this.lookups.push({ repository, branch, signal }) + if (this.findImpl !== undefined) return this.findImpl(branch, signal) + return Promise.resolve(this.heads.get(branch) ?? []) + } +} + +/** Live services and one store-created Session. */ +export interface Bench { + readonly ctx: Context + readonly provider: FakeProvider + readonly session: Session + /** Fiber of the `forgeSession` plugin. */ + readonly service: ReturnType +} + +/** + * Mount the Session store, projections, `forge` with a {@link FakeProvider}, and `forgeSession`. + * @param config - `forgeSession` configuration. + * @returns the context, the provider, one live Session, and the service fiber. + */ +export async function bench(config: Config = {}): Promise { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(ForgeService, { activeIntervalMs: 1_000, idleIntervalMs: 60_000, maxBackoffMs: 60_000, fetchTimeoutMs: 1_000 }) + const provider = new FakeProvider() + await ctx.plugin({ inject: ['forge'], apply(child: Context) { child.forge.register(provider) } }) + const service = ctx.plugin(ForgeSessionService, config) + await service + return { ctx, provider, session: ctx.sessions.create(), service } +} + +/** One shell call and the text its result carries. */ +export interface ShellCall { + readonly callId: string + readonly command: string + readonly output: string + readonly tool?: string + readonly isError?: boolean +} + +const turns = new WeakMap() + +/** + * Append one completed turn in which the model runs `calls` in a single step. + * @param session - live Session receiving the events. + * @param calls - shell calls with their rendered results. + */ +export function shellTurn(session: Session, calls: readonly ShellCall[]): void { + const turn = (turns.get(session) ?? 0) + 1 + turns.set(session, turn) + const step = 1 + const callArguments = (call: ShellCall) => JSON.stringify({ command: call.command }) + session.append('turn/start', { turn }) + session.append('step/start', { turn, step }) + session.append('assistant/message', { + turn, + step, + stream: [], + message: createAssistantMessage({ + content: calls.map(call => ({ type: 'tool-call' as const, id: ToolCallId(call.callId), name: call.tool ?? 'bash', arguments: callArguments(call) })), + source: { provider: 'fixture', model: 'fixture' }, + }), + }, { surfaceOp: 'append' }) + for (const call of calls) { + session.append('tool/call', { turn, step, callId: ToolCallId(call.callId), name: call.tool ?? 'bash', arguments: callArguments(call) }) + } + for (const call of calls) { + session.append('tool/result', { + turn, + step, + message: createToolResultMessage({ callId: ToolCallId(call.callId), content: [{ type: 'text', text: call.output }], isError: call.isError ?? false }), + }, { surfaceOp: 'append' }) + } + session.append('step/end', { turn, step }) + session.append('turn/end', { turn, reason: { kind: 'completed' } }) +} + +/** Rendered `bash` result of a `git push` that created `branch` on the `fake.test:octo/demo.git` remote. */ +export function pushOutput(branch: string, remote = 'fake.test:octo/demo.git'): string { + return [ + `branch '${branch}' set up to track 'origin/${branch}'.`, + '[stderr]', + 'remote: ', + `remote: Create a pull request for '${branch}' on Fake by visiting:`, + `remote: https://fake.test/octo/demo/pull/new/${branch}`, + 'remote: ', + `To ${remote}`, + ` * [new branch] ${branch} -> ${branch}`, + ].join('\n') +} diff --git a/packages/experimental/forge-session/tests/loader-composition.spec.ts b/packages/experimental/forge-session/tests/loader-composition.spec.ts new file mode 100644 index 0000000000..97c3312cb8 --- /dev/null +++ b/packages/experimental/forge-session/tests/loader-composition.spec.ts @@ -0,0 +1,215 @@ +/** Real Loader and AgentLoop composition; only the model and the GitHub CLI are fixtures. */ + +import { chmod, mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import Include from '@deepseek-ai/cordis-plugin-include' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import LocalBashExecutor from '@deepseek-ai/dsh-bash-local' +import ForgeService from '@deepseek-ai/dsh-experimental-forge' +import * as ForgeGitHub from '@deepseek-ai/dsh-experimental-forge-github' +import LocalFileSystem from '@deepseek-ai/dsh-fs-local' +import LlmRuntime, { createUserMessage, LlmAdapter, ToolCallId } from '@deepseek-ai/dsh-llm' +import type { GenerateOptions, LlmResolvedModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm' +import SessionStore, { pluginRecordOf, SessionId } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import * as ShellEnv from '@deepseek-ai/dsh-shell-env' +import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import * as ToolBash from '@deepseek-ai/dsh-tool-bash' +import ToolRuntime from '@deepseek-ai/dsh-tools' +import WorkingDirectoryService from '@deepseek-ai/dsh-working-directory' +import ForgeSessionService from '../src/index.ts' +import type { SessionChangeRequest, SessionChangeRequestsListener } from '../src/index.ts' + +/** GitHub CLI fixture: `pr create` prints PR 8; `api graphql` answers the head lookup and snapshot queries. */ +const FAKE_GH = String.raw` +import { appendFileSync } from 'node:fs' +const args = process.argv.slice(2) +appendFileSync(new URL('./gh.log', import.meta.url), JSON.stringify(args) + '\n') +const reply = (data) => { process.stdout.write(JSON.stringify({ data })); process.exit(0) } +if (args[0] === 'pr' && args[1] === 'create') { + process.stdout.write('https://github.com/octo/demo/pull/8\n') + process.stderr.write('\nCreating pull request for other into main in octo/demo\n\n') + process.exit(0) +} +if (args[0] !== 'api' || args[1] !== 'graphql') { + process.stderr.write('unexpected gh invocation\n') + process.exit(2) +} +const fields = Object.fromEntries(args.flatMap((arg, index) => args[index - 1] === '-f' ? [[arg.slice(0, arg.indexOf('=')), arg.slice(arg.indexOf('=') + 1)]] : [])) +if ('branch' in fields) { + reply({ repository: { defaultBranchRef: { name: 'main' }, pullRequests: { nodes: fields.branch === 'feat' ? [{ number: 7, isCrossRepository: false }] : [] } } }) +} +const node = (number) => ({ + number, + url: 'https://github.com/octo/demo/pull/' + number, + title: 'Change ' + number, + state: 'OPEN', + isDraft: false, + updatedAt: '2026-10-09T00:00:00Z', + author: { login: 'octo' }, + headRefName: number === 7 ? 'feat' : 'other', + headRefOid: 'a'.repeat(40), + baseRefName: 'main', + mergeable: 'MERGEABLE', + reviewDecision: null, + reviewThreads: { nodes: [] }, + commits: { nodes: [{ commit: { statusCheckRollup: { + state: number === 7 ? 'PENDING' : 'SUCCESS', + contexts: { pageInfo: { hasNextPage: false }, nodes: [{ + __typename: 'CheckRun', name: 'test', status: number === 7 ? 'IN_PROGRESS' : 'COMPLETED', conclusion: number === 7 ? null : 'SUCCESS', + detailsUrl: null, checkSuite: { workflowRun: { workflow: { name: 'ci' } } }, + }] }, + } } }] }, +}) +const data = {} +let repository +for (const match of fields.query.matchAll(/(r\d+): repository\(|(p\d+): pullRequest\(number: (\d+)\)/g)) { + if (match[1] !== undefined) data[repository = match[1]] = {} + else data[repository][match[2]] = node(Number(match[3])) +} +reply(data) +` + +/** `git push` fixture printing the refs a new branch push reports. */ +const FAKE_GIT = `#!/bin/sh +printf 'To github.com:octo/demo.git\\n * [new branch] feat -> feat\\n' >&2 +` + +class ScriptedModel extends LlmAdapter { + readonly requests: GenerateOptions[] = [] + + constructor(private readonly commands: readonly string[]) { + super() + } + + override resolveModel(provider: string, model: string): Promise { + return Promise.resolve({ provider, id: model, name: model, inputModalities: ['text'] }) + } + + async * stream(options: GenerateOptions): AsyncIterable { + this.requests.push(options) + const command = this.commands[this.requests.length - 1] + if (command !== undefined) { + const call = { type: 'tool-call' as const, id: ToolCallId(`shell-${this.requests.length}`), name: 'bash', arguments: JSON.stringify({ command, description: 'Run the scripted command' }) } + yield { type: 'block-start', index: 0, blockType: 'tool-call' } + yield { type: 'block-end', index: 0, block: call } + yield { type: 'finish', reason: { kind: 'tool-calls' } } + return + } + yield { type: 'block-start', index: 0, blockType: 'text' } + yield { type: 'block-end', index: 0, block: { type: 'text', text: 'Pushed and opened the pull request.' } } + yield { type: 'finish', reason: { kind: 'stop' } } + } +} + +let context: Context | undefined +let root: string | undefined + +afterEach(async () => { + await context?.fiber.dispose() + context = undefined + if (root !== undefined) await rm(root, { recursive: true, force: true }) + root = undefined +}) + +const suite = process.platform === 'win32' ? describe.skip : describe + +suite('forge-session through a real cordis.yml Loader composition', () => { + it('binds the pull requests that bash pushes discover and creates, and polls them through the GitHub provider', async () => { + root = await realpath(await mkdtemp(join(tmpdir(), 'dsh-forge-session-loader-'))) + const bin = join(root, 'bin') + await mkdir(bin) + await writeFile(join(root, 'fake-gh.mjs'), FAKE_GH) + await writeFile(join(bin, 'gh'), `#!/bin/sh\nexec ${JSON.stringify(process.execPath)} ${JSON.stringify(join(root, 'fake-gh.mjs'))} "$@"\n`) + await writeFile(join(bin, 'git'), FAKE_GIT) + await chmod(join(bin, 'gh'), 0o755) + await chmod(join(bin, 'git'), 0o755) + + const modules = new Map([ + ['@deepseek-ai/dsh-llm', LlmRuntime], + ['@deepseek-ai/dsh-session', SessionStore], + ['@deepseek-ai/dsh-session-projection', SessionProjectionRegistry], + ['@deepseek-ai/dsh-system-prompt', SystemPrompt], + ['@deepseek-ai/dsh-fs-local', LocalFileSystem], + ['@deepseek-ai/dsh-working-directory', WorkingDirectoryService], + ['@deepseek-ai/dsh-tools', ToolRuntime], + ['@deepseek-ai/dsh-agent', AgentRegistry], + ['@deepseek-ai/dsh-agent-loop', AgentLoop], + ['@deepseek-ai/dsh-subprocess-local', LocalSubprocessRuntime], + ['@deepseek-ai/dsh-shell-env', ShellEnv], + ['@deepseek-ai/dsh-bash-local', LocalBashExecutor], + ['@deepseek-ai/dsh-tool-bash', ToolBash], + ['@deepseek-ai/dsh-experimental-forge', ForgeService], + ['@deepseek-ai/dsh-experimental-forge-github', ForgeGitHub], + ['@deepseek-ai/dsh-experimental-forge-session', ForgeSessionService], + ]) + const configs: Record = { + '@deepseek-ai/dsh-agent-loop': ['agents: []'], + '@deepseek-ai/dsh-shell-env': [`dshHome: ${JSON.stringify(join(root, 'dsh-home'))}`], + '@deepseek-ai/dsh-bash-local': ['timeoutMs: 20000'], + '@deepseek-ai/dsh-experimental-forge': ['activeIntervalMs: 600000', 'idleIntervalMs: 600000'], + '@deepseek-ai/dsh-experimental-forge-github': [`ghCommand: ${JSON.stringify(join(bin, 'gh'))}`], + '@deepseek-ai/dsh-experimental-forge-session': ['discoveryTimeoutMs: 20000'], + } + const configPath = join(root, 'cordis.yml') + await writeFile(configPath, [...modules.keys()].flatMap(name => [ + `- name: 'cordis:${name}'`, + ...name in configs ? [' config:', ...(configs[name] ?? []).map(line => ` ${line}`)] : [], + ]).join('\n') + '\n') + + const ctx = context = new Context() + ctx.baseUrl = pathToFileURL(root).href + '/' + await ctx.plugin(Loader) + ctx.loader.builtins.include = Include + for (const [specifier, plugin] of modules) ctx.loader.builtins[specifier] = plugin + await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(configPath).href } }) + await ctx.loader.await() + for (const entry of ctx.loader.entries()) await entry.fiber?.await() + + const path = `PATH=${JSON.stringify(`${bin}:`)}"$PATH"` + const model = new ScriptedModel([`${path} git push -u origin feat`, `${path} gh pr create --fill --head other`]) + ctx.llm.registerAdapter(['fixture'], model) + const agent = await ctx.agentLoop.create(SessionId('forge-session-loader'), { provider: 'fixture', model: 'fixture' }, { cwd: root }) + const idle: PromiseWithResolvers = Promise.withResolvers() + const stop = ctx.on('agent/status', ({ agent: subject, status }) => { + if (subject === agent && status === 'idle') idle.resolve() + }) + agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Push feat and open a pull request.' }], source: { kind: 'user' } })) + await idle.promise + stop() + + expect(model.requests).toHaveLength(3) + const github = (number: number) => ({ repository: { forge: 'github', host: 'github.com', owner: 'octo', name: 'demo' }, number }) + const records = () => agent.session.snapshotEvents().flatMap((event) => { + const record = pluginRecordOf(event) + return record === undefined ? [] : [record.data] + }) + await vi.waitFor(() => { expect(records()).toEqual([ + { action: 'discover', url: 'https://github.com/octo/demo/pull/7' }, + ]) }) + const byUrl = (entries: readonly SessionChangeRequest[]) => [...entries].sort((left, right) => left.url.localeCompare(right.url)) + expect(byUrl(ctx.forgeSession.changeRequests(agent.session))).toEqual([ + { url: 'https://github.com/octo/demo/pull/7', origin: 'discovered', ref: github(7), update: null }, + { url: 'https://github.com/octo/demo/pull/8', origin: 'created', ref: github(8), update: null }, + ]) + const lookups = (await readFile(join(root, 'gh.log'), 'utf8')).trim().split('\n').map(line => JSON.parse(line) as string[]) + expect(lookups.find(args => args.includes('branch=feat'))).toEqual(expect.arrayContaining(['--hostname', 'github.com', 'owner=octo', 'name=demo'])) + + const listener = vi.fn() + ctx.forgeSession.watch(agent.session, listener) + await vi.waitFor(() => { expect(byUrl(listener.mock.lastCall?.[0] ?? []).map(entry => entry.update !== null && 'snapshot' in entry.update + ? [entry.update.snapshot.title, entry.update.snapshot.checksRollup] + : null)).toEqual([['Change 7', 'pending'], ['Change 8', 'success']]) }) + + const entry = [...ctx.loader.entries()].find(candidate => candidate.options.name === 'cordis:@deepseek-ai/dsh-experimental-forge-session') + await entry?.fiber?.dispose() + expect(ctx.sessionProjections.checkpoint(agent.session)).not.toHaveProperty('forgeChangeRequests') + }, 60_000) +}) diff --git a/packages/experimental/forge-session/tests/projection.spec.ts b/packages/experimental/forge-session/tests/projection.spec.ts new file mode 100644 index 0000000000..d8c7e12caf --- /dev/null +++ b/packages/experimental/forge-session/tests/projection.spec.ts @@ -0,0 +1,193 @@ +import { describe, expect, it } from 'vitest' +import { createAssistantMessage, createToolResultMessage, ToolCallId } from '@deepseek-ai/dsh-llm' +import { appendPluginRecord, Session, SessionId, SessionSeq } from '@deepseek-ai/dsh-session' +import { forgeChangeRequestsProjection } from '../src/projection.ts' +import type { ForgeChangeRequestsState } from '../src/index.ts' +import { pullUrl, pushOutput, shellTurn } from './fixtures.ts' + +const { apply, init, stateSchema } = forgeChangeRequestsProjection + +function fold(session: Session): ForgeChangeRequestsState { + return session.snapshotEvents().reduce( + (state, event) => apply(state, event), + init(), + ) +} + +function created(number: number): string { + return `${pullUrl(number)}\n[stderr]\nCreating pull request for feat into main in octo/demo\n` +} + +describe('forgeChangeRequests projection', () => { + it('starts empty and keeps the state reference for unrelated events', () => { + const session = Session.create(SessionId('unrelated')) + shellTurn(session, [ + { callId: 'list', command: 'ls', output: 'README.md' }, + { callId: 'read', command: 'gh pr create', output: created(1), tool: 'read' }, + ]) + appendPluginRecord(session, 'plugin:forge-session/binding', { action: 'bogus', url: pullUrl(2) } as never) + appendPluginRecord(session, 'plugin:forge-session/binding', { action: 'track', url: 'not a url' }) + + const state = fold(session) + + expect(state).toEqual({ pending: [], bindings: [], pushes: [] }) + for (const event of session.snapshotEvents()) expect(apply(state, event)).toBe(state) + }) + + it('binds every pull request URL a create result prints, once', () => { + const session = Session.create(SessionId('create')) + shellTurn(session, [{ callId: 'create', command: 'gh pr create --fill', output: `${created(1)}${pullUrl(1)}/files\n${pullUrl(2)}` }]) + shellTurn(session, [{ callId: 'again', command: 'gh pr create --fill', output: created(1).replace('https', 'http') }]) + + expect(fold(session).bindings).toEqual([ + { url: pullUrl(1), origin: 'created', dismissed: false }, + { url: pullUrl(2), origin: 'created', dismissed: false }, + ]) + }) + + it('binds from pwsh calls and from results with a nonzero exit marker', () => { + const session = Session.create(SessionId('pwsh')) + shellTurn(session, [{ + callId: 'exists', + command: 'gh pr create --fill', + output: `[stderr]\na pull request for branch "feat" into branch "main" already exists:\n${pullUrl(4)}\n[exit code: 1]`, + tool: 'pwsh', + }]) + + expect(fold(session).bindings).toEqual([{ url: pullUrl(4), origin: 'created', dismissed: false }]) + }) + + it('binds nothing from failed tool executions or from output of other commands', () => { + const session = Session.create(SessionId('failed')) + shellTurn(session, [ + { callId: 'failed', command: 'gh pr create --fill', output: created(1), isError: true }, + { callId: 'view', command: 'gh pr view 2', output: pullUrl(2) }, + ]) + + expect(fold(session)).toEqual({ pending: [], bindings: [], pushes: [] }) + }) + + it('reads evidence only from text blocks and ignores shell calls without a command', () => { + const session = Session.create(SessionId('blocks')) + shellTurn(session, [{ callId: 'create', command: 'gh pr create --fill', output: 'placeholder' }]) + const events = session.snapshotEvents() + const index = events.findIndex(event => event.type === 'tool/result') + const result = events[index] + const call = events.find(event => event.type === 'tool/call') + if (result?.type !== 'tool/result' || call?.type !== 'tool/call') throw new Error('missing shell call') + const empty = init() + const before = events.slice(0, index).reduce((state, event) => apply(state, event), empty) + const mixed = { + ...result, + data: { + ...result.data, + message: createToolResultMessage({ + callId: ToolCallId('create'), + content: [{ type: 'reasoning', text: pullUrl(9) }, { type: 'text', text: pullUrl(6) }], + isError: false, + }), + }, + } + + expect(apply(before, mixed).bindings).toEqual([{ url: pullUrl(6), origin: 'created', dismissed: false }]) + expect(apply(empty, { ...call, data: { ...call.data, arguments: '{"cmd":"gh pr create"}' } })).toBe(empty) + }) + + it('records pushed branches with the reporting result sequence number and moves a repeated push last', () => { + const session = Session.create(SessionId('push')) + shellTurn(session, [{ callId: 'one', command: 'git push -u origin feat', output: pushOutput('feat') }]) + shellTurn(session, [{ callId: 'two', command: 'git push -u origin other', output: pushOutput('other') }]) + shellTurn(session, [{ callId: 'three', command: 'git push', output: pushOutput('feat') }]) + const results = session.snapshotEvents().filter(event => event.type === 'tool/result').map(event => event.seq) + + expect(fold(session).pushes).toEqual([ + { remote: 'fake.test:octo/demo.git', branch: 'other', seq: results[1] }, + { remote: 'fake.test:octo/demo.git', branch: 'feat', seq: results[2] }, + ]) + }) + + it('reads both kinds of evidence from one command and nothing from push output of a create command', () => { + const session = Session.create(SessionId('both')) + shellTurn(session, [ + { callId: 'both', command: 'git push -u origin feat && gh pr create --fill', output: `${pushOutput('feat')}\n${pullUrl(3)}` }, + { callId: 'create', command: 'gh pr create --fill', output: pushOutput('ignored') }, + { callId: 'push', command: 'git push', output: pullUrl(9) }, + ]) + const state = fold(session) + + expect(state.bindings).toEqual([{ url: pullUrl(3), origin: 'created', dismissed: false }]) + expect(state.pushes.map(push => push.branch)).toEqual(['feat']) + }) + + it('folds only the first result of a call and drops calls without results at turn end', () => { + const session = Session.create(SessionId('pending')) + shellTurn(session, [{ callId: 'create', command: 'gh pr create --fill', output: created(1) }]) + const result = session.snapshotEvents().find(event => event.type === 'tool/result') + if (result?.type !== 'tool/result') throw new Error('missing result') + const replayed = { + ...result, + seq: SessionSeq(result.seq + 100), + data: { ...result.data, message: createToolResultMessage({ callId: ToolCallId('create'), content: [{ type: 'text', text: pullUrl(2) }], isError: false }) }, + } + const state = fold(session) + expect(apply(state, replayed)).toBe(state) + + session.append('turn/start', { turn: 2 }) + session.append('step/start', { turn: 2, step: 1 }) + session.append('assistant/message', { + turn: 2, + step: 1, + stream: [], + message: createAssistantMessage({ + content: [{ type: 'tool-call', id: ToolCallId('orphan'), name: 'bash', arguments: '{"command":"git push"}' }], + source: { provider: 'fixture', model: 'fixture' }, + }), + }, { surfaceOp: 'append' }) + session.append('tool/call', { turn: 2, step: 1, callId: ToolCallId('orphan'), name: 'bash', arguments: '{"command":"git push"}' }) + const open = fold(session) + expect(open.pending).toEqual([{ callId: 'orphan', kind: 'push' }]) + + session.append('step/end', { turn: 2, step: 1 }) + session.append('turn/end', { turn: 2, reason: { kind: 'completed' } }) + const [stepEnd, turnEnd] = session.snapshotEvents().slice(-2) + if (stepEnd === undefined || turnEnd === undefined) throw new Error('missing turn end') + const closed = apply(apply(open, stepEnd), turnEnd) + expect(closed.pending).toEqual([]) + expect(closed.bindings).toBe(open.bindings) + expect(closed.pushes).toBe(open.pushes) + }) + + it('applies discover, track, and dismiss records by URL identity', () => { + const session = Session.create(SessionId('records')) + shellTurn(session, [{ callId: 'create', command: 'gh pr create --fill', output: created(1) }]) + const record = (action: 'track' | 'dismiss' | 'discover', url: string) => { + appendPluginRecord(session, 'plugin:forge-session/binding', { action, url }) + return fold(session) + } + + const discovered = record('discover', pullUrl(2)) + expect(record('discover', pullUrl(2).toUpperCase().replace('HTTPS', 'https'))).toEqual(discovered) + expect(record('track', pullUrl(1))).toEqual(discovered) + expect(record('dismiss', `${pullUrl(1)}/`).bindings[0]).toEqual({ url: pullUrl(1), origin: 'created', dismissed: true }) + expect(record('dismiss', pullUrl(1)).bindings[0]?.dismissed).toBe(true) + expect(record('discover', pullUrl(1)).bindings[0]?.dismissed).toBe(true) + expect(record('track', pullUrl(1)).bindings[0]).toEqual({ url: pullUrl(1), origin: 'created', dismissed: false }) + expect(record('dismiss', pullUrl(7)).bindings).toHaveLength(2) + expect(record('track', pullUrl(5)).bindings).toEqual([ + { url: pullUrl(1), origin: 'created', dismissed: false }, + { url: pullUrl(2), origin: 'discovered', dismissed: false }, + { url: pullUrl(5), origin: 'tracked', dismissed: false }, + ]) + }) + + it('round-trips state through JSON and rejects malformed state', () => { + const session = Session.create(SessionId('schema')) + shellTurn(session, [{ callId: 'push', command: 'git push', output: pushOutput('feat') }]) + appendPluginRecord(session, 'plugin:forge-session/binding', { action: 'track', url: pullUrl(1) }) + const state = fold(session) + + expect(stateSchema.parse(JSON.parse(JSON.stringify(state)))).toEqual(state) + expect(() => stateSchema.parse({ ...state, pushes: [{ remote: 'r', branch: 'b', seq: -1 }] })).toThrow() + expect(() => stateSchema.parse({ ...state, extra: true })).toThrow() + }) +}) diff --git a/packages/experimental/forge-session/tests/service.spec.ts b/packages/experimental/forge-session/tests/service.spec.ts new file mode 100644 index 0000000000..c244665749 --- /dev/null +++ b/packages/experimental/forge-session/tests/service.spec.ts @@ -0,0 +1,298 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { Mock } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import ForgeService from '@deepseek-ai/dsh-experimental-forge' +import SessionStore, { pluginRecordOf } from '@deepseek-ai/dsh-session' +import type { Session } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import ForgeSessionService from '../src/index.ts' +import type { Config, SessionChangeRequest, SessionChangeRequestsListener } from '../src/index.ts' +import { bench, demo, pull, pullUrl, pushOutput, shellTurn, snapshot } from './fixtures.ts' +import type { Bench } from './fixtures.ts' + +const contexts: Context[] = [] + +afterEach(async () => { + for (const ctx of contexts.splice(0)) await ctx.fiber.dispose() +}) + +async function open(config: Config = {}): Promise { + const opened = await bench(config) + contexts.push(opened.ctx) + return opened +} + +function records(session: Session): unknown[] { + return session.snapshotEvents().flatMap((event) => { + const record = pluginRecordOf(event) + return record === undefined ? [] : [record.data] + }) +} + +function create(session: Session, callId: string, url: string): void { + shellTurn(session, [{ callId, command: 'gh pr create --fill', output: `${url}\n` }]) +} + +function push(session: Session, callId: string, branch: string, remote?: string): void { + shellTurn(session, [{ callId, command: `git push -u origin ${branch}`, output: pushOutput(branch, remote) }]) +} + +async function settle(): Promise { + await new Promise(resolve => setImmediate(resolve)) +} + +describe('forgeSession bindings', () => { + it('lists created bindings with their reference and no polling result', async () => { + const { ctx, session } = await open() + create(session, 'create', pullUrl(1)) + + expect(ctx.forgeSession.changeRequests(session)).toEqual([{ url: pullUrl(1), origin: 'created', ref: pull(1), update: null }]) + }) + + it('lists a URL that no provider serves without a reference', async () => { + const { ctx, session } = await open() + create(session, 'create', 'https://github.com/octo/demo/pull/5') + + expect(ctx.forgeSession.changeRequests(session)).toEqual([{ url: 'https://github.com/octo/demo/pull/5', origin: 'created', ref: null, update: null }]) + }) + + it('keeps bindings per Session', async () => { + const { ctx, session } = await open() + const other = ctx.sessions.create() + create(session, 'create', pullUrl(1)) + + expect(ctx.forgeSession.changeRequests(other)).toEqual([]) + }) +}) + +describe('forgeSession discovery', () => { + it('records change requests found for a pushed branch', async () => { + const { ctx, provider, session } = await open() + provider.heads.set('feat', [pull(7)]) + push(session, 'push', 'feat') + + await vi.waitFor(() => { expect(records(session)).toEqual([{ action: 'discover', url: pullUrl(7) }]) }) + expect(provider.lookups.map(lookup => [lookup.repository, lookup.branch])).toEqual([[demo, 'feat']]) + expect(ctx.forgeSession.changeRequests(session)).toEqual([{ url: pullUrl(7), origin: 'discovered', ref: pull(7), update: null }]) + }) + + it('skips remotes that no provider serves and change requests that are already bound', async () => { + const { provider, session } = await open() + provider.heads.set('feat', [pull(7)]) + push(session, 'elsewhere', 'feat', 'github.com:octo/demo.git') + await settle() + expect(provider.lookups).toEqual([]) + + shellTurn(session, [{ callId: 'both', command: 'git push -u origin feat && gh pr create --fill', output: `${pushOutput('feat')}\n${pullUrl(7)}` }]) + await vi.waitFor(() => { expect(provider.lookups).toHaveLength(1) }) + await settle() + expect(records(session)).toEqual([]) + }) + + it('looks up again on every push of a branch', async () => { + const { provider, session } = await open() + push(session, 'first', 'feat') + await vi.waitFor(() => { expect(provider.lookups).toHaveLength(1) }) + provider.heads.set('feat', [pull(8)]) + push(session, 'second', 'feat') + + await vi.waitFor(() => { expect(records(session)).toEqual([{ action: 'discover', url: pullUrl(8) }]) }) + }) + + it('never restores a dismissed binding', async () => { + const { ctx, provider, session } = await open() + create(session, 'create', pullUrl(7)) + ctx.forgeSession.dismiss(session, pullUrl(7)) + provider.heads.set('feat', [pull(7)]) + push(session, 'push', 'feat') + await vi.waitFor(() => { expect(provider.lookups).toHaveLength(1) }) + await settle() + + expect(records(session)).toEqual([{ action: 'dismiss', url: pullUrl(7) }]) + expect(ctx.forgeSession.changeRequests(session)).toEqual([]) + }) + + it.each([ + [new Error('gh is not signed in'), 'gh is not signed in'], + ['plain failure', 'plain failure'], + ])('logs a failed lookup (%s)', async (error, message) => { + const { ctx, provider, session } = await open() + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) + // oxlint-disable-next-line typescript/prefer-promise-reject-errors -- covers providers that reject with a non-Error reason. + provider.findImpl = () => Promise.reject(error) + push(session, 'push', 'feat') + + await vi.waitFor(() => { expect(warn).toHaveBeenCalledWith(`forge-session: change request lookup for feat failed: ${message}`) }) + expect(records(session)).toEqual([]) + }) + + it('times out a lookup', async () => { + const { ctx, provider, session } = await open({ discoveryTimeoutMs: 20 }) + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) + provider.findImpl = (_branch, signal) => new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => { reject(signal.reason as Error) }) + }) + push(session, 'push', 'feat') + + await vi.waitFor(() => { expect(warn).toHaveBeenCalledWith('forge-session: change request lookup for feat failed: FORGE_DISCOVERY_TIMEOUT after 20ms') }) + }) + + it.each(['resolves', 'rejects'])('drops a lookup that %s after the service stops', async (outcome) => { + const { ctx, provider, session, service } = await open() + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) + provider.findImpl = (_branch, signal) => new Promise((resolve, reject) => { + signal.addEventListener('abort', () => { + if (outcome === 'resolves') resolve([pull(7)]) + else reject(signal.reason as Error) + }) + }) + push(session, 'push', 'feat') + await vi.waitFor(() => { expect(provider.lookups).toHaveLength(1) }) + + await service.dispose() + + expect(provider.lookups[0]?.signal.aborted).toBe(true) + expect(records(session)).toEqual([]) + expect(warn).not.toHaveBeenCalled() + }) +}) + +describe('forgeSession track and dismiss', () => { + it('tracks a URL once under its canonical form', async () => { + const { ctx, session } = await open() + ctx.forgeSession.track(session, 'http://FAKE.test/octo/demo/pull/3/files') + ctx.forgeSession.track(session, pullUrl(3)) + + expect(records(session)).toEqual([{ action: 'track', url: pullUrl(3) }]) + expect(ctx.forgeSession.changeRequests(session)).toEqual([{ url: pullUrl(3), origin: 'tracked', ref: pull(3), update: null }]) + }) + + it('rejects a URL that no provider recognizes', async () => { + const { ctx, session } = await open() + + expect(() => { ctx.forgeSession.track(session, 'https://github.com/octo/demo/pull/1') }).toThrow('forge-session: no forge provider recognizes "https://github.com/octo/demo/pull/1"') + expect(records(session)).toEqual([]) + }) + + it('dismisses a visible binding once and restores it through track', async () => { + const { ctx, session } = await open() + create(session, 'create', pullUrl(1)) + ctx.forgeSession.dismiss(session, `${pullUrl(1)}?tab=checks`) + ctx.forgeSession.dismiss(session, pullUrl(1)) + ctx.forgeSession.dismiss(session, pullUrl(9)) + expect(ctx.forgeSession.changeRequests(session)).toEqual([]) + + ctx.forgeSession.track(session, pullUrl(1)) + + expect(records(session)).toEqual([{ action: 'dismiss', url: pullUrl(1) }, { action: 'track', url: pullUrl(1) }]) + expect(ctx.forgeSession.changeRequests(session)).toEqual([{ url: pullUrl(1), origin: 'created', ref: pull(1), update: null }]) + }) +}) + +describe('forgeSession watch', () => { + function lastList(listener: Mock): readonly SessionChangeRequest[] { + return listener.mock.lastCall?.[0] ?? [] + } + + it('polls visible change requests and notifies after results and binding changes', async () => { + const { ctx, provider, session } = await open() + create(session, 'create', pullUrl(1)) + create(session, 'unserved', 'https://github.com/octo/demo/pull/5') + const listener = vi.fn() + ctx.forgeSession.watch(session, listener) + + await vi.waitFor(() => { expect(lastList(listener)[0]?.update).toMatchObject({ snapshot: snapshot(pull(1)) }) }) + expect(lastList(listener)[1]).toEqual({ url: 'https://github.com/octo/demo/pull/5', origin: 'created', ref: null, update: null }) + expect(provider.fetches).toEqual([[1]]) + + create(session, 'second', pullUrl(2)) + expect(lastList(listener).map(entry => [entry.url, entry.update])).toEqual([ + [pullUrl(1), expect.objectContaining({ snapshot: snapshot(pull(1)) })], + ['https://github.com/octo/demo/pull/5', null], + [pullUrl(2), null], + ]) + await vi.waitFor(() => { expect(lastList(listener)[2]?.update).toMatchObject({ snapshot: snapshot(pull(2)) }) }) + + ctx.forgeSession.dismiss(session, pullUrl(1)) + expect(lastList(listener).map(entry => entry.url)).toEqual(['https://github.com/octo/demo/pull/5', pullUrl(2)]) + expect(ctx.forge.latest(pull(1))).toBeUndefined() + expect(ctx.forge.latest(pull(2))).toBeDefined() + expect(ctx.forgeSession.changeRequests(session)).toEqual(lastList(listener)) + }) + + it('does not notify for events that leave the bindings unchanged', async () => { + const { ctx, session } = await open() + create(session, 'create', pullUrl(1)) + const listener = vi.fn() + ctx.forgeSession.watch(session, listener) + await vi.waitFor(() => { expect(listener).toHaveBeenCalledTimes(1) }) + + shellTurn(session, [{ callId: 'list', command: 'ls', output: 'README.md' }]) + ctx.forgeSession.track(session, pullUrl(1)) + + expect(listener).toHaveBeenCalledTimes(1) + }) + + it('stops polling when the last watcher leaves, including through fiber disposal', async () => { + const { ctx, session } = await open() + create(session, 'create', pullUrl(1)) + const stop = ctx.forgeSession.watch(session, () => {}) + const fiber = ctx.plugin({ + inject: ['forgeSession'], + apply(child: Context) { + child.forgeSession.watch(session, () => {}) + }, + }) + await fiber + await vi.waitFor(() => { expect(ctx.forge.latest(pull(1))).toBeDefined() }) + + stop() + expect(ctx.forge.latest(pull(1))).toBeDefined() + await fiber.dispose() + expect(ctx.forge.latest(pull(1))).toBeUndefined() + + create(session, 'second', pullUrl(2)) + await settle() + expect(ctx.forge.latest(pull(2))).toBeUndefined() + }) + + it('logs a listener failure and still notifies the others', async () => { + const { ctx, session } = await open() + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) + const listener = vi.fn() + ctx.forgeSession.watch(session, () => { + throw new Error('boom') + }) + ctx.forgeSession.watch(session, listener) + + create(session, 'create', pullUrl(1)) + + expect(warn).toHaveBeenCalledWith('forge-session: change request listener failed: Error: boom') + expect(listener).toHaveBeenCalledTimes(1) + }) +}) + +describe('forgeSession lifecycle', () => { + it('unregisters its projection and stops discovery when the service fiber is disposed', async () => { + const { ctx, provider, session, service } = await open() + create(session, 'create', pullUrl(1)) + expect(ctx.sessionProjections.checkpoint(session).forgeChangeRequests?.val).toMatchObject({ bindings: [{ url: pullUrl(1) }] }) + + await service.dispose() + push(session, 'push', 'feat') + await settle() + + expect(ctx.sessionProjections.checkpoint(session)).not.toHaveProperty('forgeChangeRequests') + expect(provider.lookups).toEqual([]) + }) + + it.each([0, 1.5, 2_147_483_648])('rejects discoveryTimeoutMs %s at load', async (discoveryTimeoutMs) => { + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(ForgeService, {}) + + await expect(ctx.plugin(ForgeSessionService, { discoveryTimeoutMs })).rejects.toThrow('forge-session: discoveryTimeoutMs must be a positive integer no greater than 2147483647') + }) +}) diff --git a/packages/experimental/forge-session/tsconfig.json b/packages/experimental/forge-session/tsconfig.json new file mode 100644 index 0000000000..4ff0bd48d4 --- /dev/null +++ b/packages/experimental/forge-session/tsconfig.json @@ -0,0 +1,36 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../forge" + }, + { + "path": "../../core/session" + }, + { + "path": "../../session/session-projection" + }, + { + "path": "../../util/timeout" + }, + { + "path": "../../util/values" + } + ] +} diff --git a/packages/experimental/forge/README.i18n.yaml b/packages/experimental/forge/README.i18n.yaml new file mode 100644 index 0000000000..2faa7a070f --- /dev/null +++ b/packages/experimental/forge/README.i18n.yaml @@ -0,0 +1,37 @@ +# Bilingual-pair consistency record for README.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/experimental/forge/README.md +/: + en: 58d73ee11cb9ecde + zh: 04213727345a58a2 +/deepseek-ai-dsh-experimental-forge: + en: cf24b0ee1e2732e8 + zh: bb4e76d2b69b84d8 +/deepseek-ai-dsh-experimental-forge/summary: + en: f516c1e7fd1461da + zh: 59f18eafedc85452 +/deepseek-ai-dsh-experimental-forge/table-of-contents: + en: d152484eb41ac6b4 + zh: 2f8a621b991260a1 +/deepseek-ai-dsh-experimental-forge/use-this-package: + en: bbb605854ab9590d + zh: 5b7426a09b125569 +/deepseek-ai-dsh-experimental-forge/understand-the-implementation: + en: e193cb547c47069b + zh: faa517522402ea41 +/deepseek-ai-dsh-experimental-forge/further-exploration: + en: bb13125144d413d5 + zh: a6832dad7a093bfe +/deepseek-ai-dsh-experimental-forge/model-experience: + en: a62b9af05181f74f + zh: 64b8b0aa18c64c57 +/deepseek-ai-dsh-experimental-forge/model-experience/kv-cache-effect: + en: 1675c9fcae96936c + zh: 5cd2a82aeca3160a +/deepseek-ai-dsh-experimental-forge/known-limitations-and-deferred-work: + en: ed230a2099a46e2f + zh: 01bcb32d2c70a592 +/deepseek-ai-dsh-experimental-forge/known-limitations-and-deferred-work/dev-note: + en: e67f633dc6fe7773 + zh: 3bb0c20c93f16f98 diff --git a/packages/experimental/forge/README.md b/packages/experimental/forge/README.md new file mode 100644 index 0000000000..0336a19e85 --- /dev/null +++ b/packages/experimental/forge/README.md @@ -0,0 +1,94 @@ +--- +description: "Register code host providers and poll change request status through one provider-neutral service." +kind: "package-reference" +--- + +# @deepseek-ai/dsh-experimental-forge + +English | [中文](README.zh.md) + +## Summary + +Read pull requests and merge requests without coupling consumers to one code host. A provider maps remote and change request URLs to provider-neutral references, finds change requests by head branch, and reads their status in batches. Watchers of one change request share a single polling loop whose cadence follows check, mergeability, and lifecycle state. + +## Table of Contents + +- [Use this package](#use-this-package) +- [Understand the implementation](#understand-the-implementation) +- [Further Exploration](#further-exploration) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + +----- + + +## Use this package + +Mount the registry with at least one provider package, such as the GitHub provider. + +```yaml +- name: '@deepseek-ai/dsh-experimental-forge' +- name: '@deepseek-ai/dsh-experimental-forge-github' +``` + +`ctx.forge.register(provider)` is an effect on the calling fiber and returns its disposer. One provider serves one forge family; registering a second provider for the same family throws. `repositoryFromRemote(url)` and `changeRequestFromUrl(url)` ask providers in registration order and return the first match. `changeRequestUrl(ref)` and `findByHead(repository, branch, signal)` delegate to the provider of the reference's forge and throw when none is registered. + +`ctx.forge.watch(ref, listener)` is also an effect. The first watcher of a change request polls it at once; `latest(ref)` returns the most recent result. Each forge has at most one fetch in flight, and that fetch includes every due change request of the forge. An open change request is polled every `activeIntervalMs` while checks are pending or mergeability is unknown, and every `idleIntervalMs` otherwise. Closed and merged change requests are not polled again. A failed read repeats after `activeIntervalMs` doubled for each consecutive failure, up to `maxBackoffMs`. A fetch that exceeds `fetchTimeoutMs` settles as an `unavailable` failure whose message names `FORGE_FETCH_TIMEOUT`. Unregistering a provider settles its watched change requests as `unavailable`. Polling stops when the last watcher leaves. The [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-experimental-forge) lists the defaults. + +----- + + +## Understand the implementation + +
+Implementation internals — click to expand + +One timer covers all watched change requests. When it fires, the service groups due change requests by forge and starts one batched provider fetch per idle forge. A listener that throws is logged and skipped. Disposing the service aborts in-flight fetches and waits for them to settle. + +| Source | Responsibility | +|---|---| +| [`src/index.ts`](src/index.ts) | Provider registration, URL mapping, watch lifetime, and batched polling | +| [`src/schedule.ts`](src/schedule.ts) | Delay before the next poll of one change request | +| [`src/keys.ts`](src/keys.ts) | Case-insensitive canonical identity of a change request | +| [`src/types.ts`](src/types.ts) | Repository, change request, status, and provider types | + +
+ +----- + + +## Further Exploration + +- [Forge subsystem](../../../docs/subsystems/forge.md) — provider contract, change request types, and service reference. +- [GitHub provider](../forge-github/README.md) — the provider for GitHub hosts. +- [Session bindings](../forge-session/README.md) — the consumer that binds change requests to Sessions. +- [Binding decision](../../../.agents/notes/implemented/feature/2026-10-09-session-change-request-bindings.md) — why bindings derive from shell results and status stays on the host. + +----- + + +## Model Experience + +None, as providers and watchers exchange code host status without contributing model context. + +#### KV Cache effect + +None. No model request contains data from this package. + +## Known Limitations and Deferred Work + + + +- **Polling only** — status reaches watchers on the next scheduled poll; there is no webhook intake, manual refresh, or provider readiness probe. Missing credentials appear as an `unauthenticated` failure after a fetch. +- **Process-local results** — latest results live in memory and are lost on restart; the next watcher polls again. + + +### Dev Note + +
+Working context for maintainers — click to expand + +None. + +
diff --git a/packages/experimental/forge/README.zh.md b/packages/experimental/forge/README.zh.md new file mode 100644 index 0000000000..532b17860d --- /dev/null +++ b/packages/experimental/forge/README.zh.md @@ -0,0 +1,94 @@ +--- +description: "通过一个提供方无关的服务注册代码托管提供方并轮询变更请求状态。" +kind: "package-reference" +--- + +# @deepseek-ai/dsh-experimental-forge + +[English](README.md) | 中文 + +## 概述 + +读取 pull request 与 merge request,而不把消费方绑定到某一个代码托管平台。提供方把远端 URL 和变更请求(change request,即 pull request 或 merge request)URL 映射为提供方无关的引用,按 head 分支查找变更请求,并批量读取其状态。同一变更请求的所有监听方共享一个轮询循环,轮询节奏随检查、可合并性与生命周期状态变化。 + +## 目录 + +- [使用本包](#use-this-package) +- [了解实现](#understand-the-implementation) +- [深入探索](#further-exploration) +- [模型体验](#model-experience) +- [已知限制与待办工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 使用本包 + +将注册表与至少一个提供方包一起挂载,例如 GitHub 提供方。 + +```yaml +- name: '@deepseek-ai/dsh-experimental-forge' +- name: '@deepseek-ai/dsh-experimental-forge-github' +``` + +`ctx.forge.register(provider)` 是调用方 fiber 上的 effect,返回对应的 disposer。一个提供方服务一个 forge 家族;为同一家族注册第二个提供方会抛错。`repositoryFromRemote(url)` 与 `changeRequestFromUrl(url)` 按注册顺序询问提供方并返回第一个匹配结果。`changeRequestUrl(ref)` 与 `findByHead(repository, branch, signal)` 委托给引用所属 forge 的提供方;没有注册提供方时抛错。 + +`ctx.forge.watch(ref, listener)` 同样是 effect。变更请求的第一个监听方会立即触发轮询;`latest(ref)` 返回最近一次结果。每个 forge 同时最多只有一次读取在进行,这次读取包含该 forge 所有到期的变更请求。打开状态的变更请求在检查未完成或可合并性未知时每 `activeIntervalMs` 轮询一次,否则每 `idleIntervalMs` 轮询一次。已关闭和已合并的变更请求不再轮询。读取失败后,重试间隔为 `activeIntervalMs` 按连续失败次数翻倍,最长 `maxBackoffMs`。读取超过 `fetchTimeoutMs` 时,以消息中包含 `FORGE_FETCH_TIMEOUT` 的 `unavailable` 失败结束。注销提供方会让其被监听的变更请求以 `unavailable` 结束。最后一个监听方离开后轮询停止。默认值见[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-experimental-forge)。 + +----- + + +## 了解实现 + +
+实现细节——点击展开 + +一个定时器覆盖所有被监听的变更请求。定时器触发时,服务按 forge 对到期的变更请求分组,并为每个空闲的 forge 发起一次批量读取。抛错的监听方会被记录日志并跳过。销毁服务会中止进行中的读取,并等待它们结束。 + +| 源码 | 职责 | +|---|---| +| [`src/index.ts`](src/index.ts) | 提供方注册、URL 映射、监听生命周期与批量轮询 | +| [`src/schedule.ts`](src/schedule.ts) | 计算单个变更请求距下次轮询的延迟 | +| [`src/keys.ts`](src/keys.ts) | 变更请求不区分大小写的规范标识 | +| [`src/types.ts`](src/types.ts) | 仓库、变更请求、状态与提供方类型 | + +
+ +----- + + +## 深入探索 + +- [Forge 子系统](../../../docs/subsystems/forge.zh.md)——提供方契约、变更请求类型与服务参考。 +- [GitHub 提供方](../forge-github/README.zh.md)——服务 GitHub 主机的提供方。 +- [Session 绑定](../forge-session/README.zh.md)——把变更请求绑定到 Session 的消费方。 +- [绑定决策](../../../.agents/notes/implemented/feature/2026-10-09-session-change-request-bindings.zh.md)——为何绑定从 shell 结果派生、状态只留在宿主侧。 + +----- + + +## 模型体验 + +无:提供方与监听方只交换代码托管平台的状态,不贡献模型上下文。 + +#### KV Cache 影响 + +无。任何模型请求都不包含本包的数据。 + +## 已知限制与待办工作 + + + +- **仅轮询**——状态在下一次计划轮询时才送达监听方;没有 webhook 接入、手动刷新或提供方就绪探测。凭据缺失会在读取后表现为 `unauthenticated` 失败。 +- **结果只在进程内**——最近结果保存在内存中,重启后丢失;下一个监听方会重新轮询。 + + +### 开发备注 + +
+维护者的工作上下文——点击展开 + +无。 + +
diff --git a/packages/experimental/forge/package.json b/packages/experimental/forge/package.json new file mode 100644 index 0000000000..f8460ed806 --- /dev/null +++ b/packages/experimental/forge/package.json @@ -0,0 +1,40 @@ +{ + "name": "@deepseek-ai/dsh-experimental-forge", + "description": "Provider-neutral change request registry with shared status polling", + "version": "0.2.1-rc.2", + "publishConfig": { + "access": "public" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/types/**/*.d.ts" + ], + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/experimental/forge" + }, + "dependencies": { + "@deepseek-ai/schemastery": "workspace:~", + "@deepseek-ai/dsh-brand": "workspace:*", + "@deepseek-ai/dsh-timeout": "workspace:*" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:~" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:~" + } +} diff --git a/packages/experimental/forge/src/index.ts b/packages/experimental/forge/src/index.ts new file mode 100644 index 0000000000..9ae7b38d9d --- /dev/null +++ b/packages/experimental/forge/src/index.ts @@ -0,0 +1,278 @@ +/** Registry of code-host providers with provider-neutral change requests and shared status polling. */ + +import { Context, Service } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' +import { deadline, MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' +import { changeRequestKey } from './keys.ts' +import { nextPollDelay } from './schedule.ts' +import type { + ChangeRequestKey, ChangeRequestListener, ChangeRequestRef, ChangeRequestSnapshot, ChangeRequestUpdate, Config, + ForgeFailure, ForgeProvider, ForgeRepository, +} from './types.ts' + +export { changeRequestKey } from './keys.ts' +export type * from './types.ts' + +declare module '@deepseek-ai/cordis' { + interface Context { + forge: ForgeService + } +} + +type FetchResults = ReadonlyMap + +interface Watch { + readonly key: ChangeRequestKey + readonly ref: ChangeRequestRef + readonly listeners: Set + latest: ChangeRequestUpdate | undefined + /** Epoch milliseconds of the next poll; undefined while in flight or after a terminal state. */ + dueAt: number | undefined + failures: number +} + +/** + * Registry of forge providers. Watchers of one change request share a single + * polling loop; each forge has at most one fetch in flight, which batches + * every due change request of that forge. + */ +export class ForgeService extends Service { + static Config: z = z.object({ + activeIntervalMs: z.number().default(30_000), + idleIntervalMs: z.number().default(120_000), + maxBackoffMs: z.number().default(600_000), + fetchTimeoutMs: z.number().default(30_000), + }) + + private readonly config: Required + private readonly providers = new Map() + private readonly watches = new Map() + private readonly fetching = new Set() + private readonly pending = new Set>() + private readonly lifetime = new AbortController() + private timer: ReturnType | undefined + + constructor(ctx: Context, config: Config) { + super(ctx, 'forge') + this.config = config as Required + for (const key of ['activeIntervalMs', 'idleIntervalMs', 'maxBackoffMs', 'fetchTimeoutMs'] as const) { + const value = this.config[key] + if (!Number.isSafeInteger(value) || value <= 0 || value > MAX_TIMER_DELAY_MS) { + throw new Error(`forge: ${key} must be a positive integer no greater than ${MAX_TIMER_DELAY_MS}`) + } + } + ctx.effect(() => async () => { + this.lifetime.abort(new Error('forge service disposed')) + clearTimeout(this.timer) + this.timer = undefined + await Promise.allSettled(this.pending) + }, 'forge: stop polling') + } + + /** + * Register the provider of one forge family as an effect on the calling + * fiber. Watches of that forge receive an `unavailable` failure when the + * registration ends. + * @param provider - provider to serve `provider.forge`. + * @returns the disposer that unregisters the provider. + * @throws when another provider already serves the same forge. + */ + register(provider: ForgeProvider): () => void { + const dispose = this.ctx.effect(() => { + if (this.providers.has(provider.forge)) throw new Error(`forge: a provider for "${provider.forge}" is already registered`) + this.providers.set(provider.forge, provider) + return () => { + this.providers.delete(provider.forge) + for (const watch of this.watches.values()) { + if (watch.ref.repository.forge === provider.forge) this.settle(watch, this.missing(provider.forge)) + } + this.schedule() + } + }, `forge.register(${provider.forge})`) + return () => void dispose() + } + + /** + * Map a Git remote URL to a repository through the first provider that serves it. + * @param url - fetch or push URL as Git reports it. + * @returns the repository, or undefined when no provider serves the URL. + */ + repositoryFromRemote(url: string): ForgeRepository | undefined { + for (const provider of this.providers.values()) { + const repository = provider.repositoryFromRemote(url) + if (repository !== undefined) return repository + } + return undefined + } + + /** + * Map a change request web URL through the first provider that serves it. + * @param url - web URL of a pull or merge request. + * @returns the change request, or undefined when no provider serves the URL. + */ + changeRequestFromUrl(url: string): ChangeRequestRef | undefined { + for (const provider of this.providers.values()) { + const ref = provider.changeRequestFromUrl(url) + if (ref !== undefined) return ref + } + return undefined + } + + /** + * Build the canonical web URL of a change request. + * @param ref - change request served by a registered provider. + * @returns the provider's canonical URL. + * @throws when no provider serves the change request's forge. + */ + changeRequestUrl(ref: ChangeRequestRef): string { + return this.provider(ref.repository.forge).changeRequestUrl(ref) + } + + /** + * Find open change requests whose head is `branch`, excluding the default branch and forks. + * @param repository - repository served by a registered provider. + * @param branch - head branch name on the host. + * @param signal - cancellation for the lookup. + * @returns matching change requests, newest first as the provider reports them. + * @throws when no provider serves the repository's forge or the lookup fails. + */ + async findByHead(repository: ForgeRepository, branch: string, signal: AbortSignal): Promise { + return await this.provider(repository.forge).findByHead(repository, branch, signal) + } + + /** + * Watch one change request as an effect on the calling fiber. The first + * watcher starts polling at once; listeners receive every later result, + * and `latest` returns the current one. Polling stops when the last watcher + * leaves. Listeners must not throw; a thrown error is logged and skipped. + * @param ref - change request to poll. + * @param listener - receives each polling result. + * @returns the disposer that stops this watch. + */ + watch(ref: ChangeRequestRef, listener: ChangeRequestListener): () => void { + const key = changeRequestKey(ref) + const dispose = this.ctx.effect(() => { + let watch = this.watches.get(key) + if (watch === undefined) { + watch = { key, ref, listeners: new Set(), latest: undefined, dueAt: Date.now(), failures: 0 } + this.watches.set(key, watch) + this.schedule() + } + const owned = watch + owned.listeners.add(listener) + return () => { + owned.listeners.delete(listener) + if (owned.listeners.size === 0 && this.watches.get(key) === owned) { + this.watches.delete(key) + this.schedule() + } + } + }, 'forge.watch()') + return () => void dispose() + } + + /** + * Read the latest polling result of a watched change request. + * @param ref - change request to read. + * @returns the latest result, or undefined before the first result or without a watch. + */ + latest(ref: ChangeRequestRef): ChangeRequestUpdate | undefined { + return this.watches.get(changeRequestKey(ref))?.latest + } + + private provider(forge: string): ForgeProvider { + const provider = this.providers.get(forge) + if (provider === undefined) throw new Error(`forge: ${this.missing(forge).message}`) + return provider + } + + private missing(forge: string): ForgeFailure { + return { code: 'unavailable', message: `no provider is registered for "${forge}"` } + } + + private schedule(): void { + clearTimeout(this.timer) + this.timer = undefined + if (this.lifetime.signal.aborted) return + let next: number | undefined + for (const watch of this.watches.values()) { + if (watch.dueAt === undefined || this.fetching.has(watch.ref.repository.forge)) continue + next = next === undefined ? watch.dueAt : Math.min(next, watch.dueAt) + } + if (next === undefined) return + this.timer = setTimeout(() => { + this.timer = undefined + this.poll() + }, Math.max(0, next - Date.now())) + } + + private poll(): void { + const now = Date.now() + const due = new Map() + for (const watch of this.watches.values()) { + const forge = watch.ref.repository.forge + if (watch.dueAt === undefined || watch.dueAt > now || this.fetching.has(forge)) continue + const batch = due.get(forge) + if (batch === undefined) due.set(forge, [watch]) + else batch.push(watch) + } + for (const [forge, batch] of due) { + const task = this.fetch(forge, batch) + this.pending.add(task) + void task.then(() => this.pending.delete(task)) + } + this.schedule() + } + + /** Fetch one forge's batch; never rejects. */ + private async fetch(forge: string, batch: readonly Watch[]): Promise { + this.fetching.add(forge) + for (const watch of batch) watch.dueAt = undefined + let results: FetchResults | undefined + let failure: ForgeFailure | undefined + try { + const provider = this.providers.get(forge) + if (provider === undefined) { + failure = this.missing(forge) + } else { + using timeout = deadline(this.lifetime.signal, this.config.fetchTimeoutMs, 'FORGE_FETCH_TIMEOUT') + results = await provider.fetch(batch.map(watch => watch.ref), timeout.signal) + } + } catch (error) { + failure = { code: 'unavailable', message: error instanceof Error ? error.message : String(error) } + } finally { + this.fetching.delete(forge) + } + if (this.lifetime.signal.aborted) return + for (const watch of batch) { + if (this.watches.get(watch.key) !== watch) continue + const result = failure ?? results?.get(watch.key) ?? { code: 'unavailable', message: `provider returned no result for ${watch.key}` } + this.settle(watch, result) + } + this.schedule() + } + + private settle(watch: Watch, result: ChangeRequestSnapshot | ForgeFailure): void { + const fetchedAt = Date.now() + let update: ChangeRequestUpdate + if ('code' in result) { + watch.failures++ + update = { key: watch.key, fetchedAt, failure: result } + } else { + watch.failures = 0 + update = { key: watch.key, fetchedAt, snapshot: result } + } + watch.latest = update + const delay = nextPollDelay(result, watch.failures, this.config) + watch.dueAt = delay === undefined ? undefined : fetchedAt + delay + for (const listener of [...watch.listeners]) { + try { + listener(update) + } catch (error) { + this.ctx.logger.warn(`forge: change request listener failed: ${String(error)}`) + } + } + } +} + +export default ForgeService diff --git a/packages/experimental/forge/src/keys.ts b/packages/experimental/forge/src/keys.ts new file mode 100644 index 0000000000..0722fd4322 --- /dev/null +++ b/packages/experimental/forge/src/keys.ts @@ -0,0 +1,15 @@ +/** Canonical identities for change requests. */ + +import { brandString } from '@deepseek-ai/dsh-brand' +import type { ChangeRequestKey, ChangeRequestRef } from './types.ts' + +/** + * Build the canonical identity of a change request. Host, owner, and name + * compare case-insensitively, as they do on the hosts this vocabulary serves. + * @param ref - change request to identify. + * @returns `://#` in lowercase. + */ +export function changeRequestKey(ref: ChangeRequestRef): ChangeRequestKey { + const { forge, host, owner, name } = ref.repository + return brandString(`${forge}:${host}/${owner}/${name}#${ref.number}`.toLowerCase()) +} diff --git a/packages/experimental/forge/src/schedule.ts b/packages/experimental/forge/src/schedule.ts new file mode 100644 index 0000000000..3faac8050f --- /dev/null +++ b/packages/experimental/forge/src/schedule.ts @@ -0,0 +1,32 @@ +/** Poll cadence for watched change requests. */ + +import type { ChangeRequestSnapshot, ForgeFailure } from './types.ts' + +/** Resolved intervals that pace polling. */ +export interface PollIntervals { + /** Delay while checks run or mergeability is unknown. */ + readonly activeIntervalMs: number + /** Delay for an open change request whose checks have settled. */ + readonly idleIntervalMs: number + /** Largest delay after consecutive failures. */ + readonly maxBackoffMs: number +} + +/** + * Choose the delay before the next poll of one change request. Failures back + * off exponentially from the active interval; merged and closed change + * requests stop polling. + * @param result - latest snapshot or failure. + * @param failures - consecutive failures, counting `result` when it is one. + * @param intervals - configured cadence. + * @returns the delay in milliseconds, or undefined after a terminal state. + */ +export function nextPollDelay( + result: ChangeRequestSnapshot | ForgeFailure, + failures: number, + intervals: PollIntervals, +): number | undefined { + if ('code' in result) return Math.min(intervals.maxBackoffMs, intervals.activeIntervalMs * 2 ** failures) + if (result.state !== 'open') return undefined + return result.checksRollup === 'pending' || result.mergeability === 'unknown' ? intervals.activeIntervalMs : intervals.idleIntervalMs +} diff --git a/packages/experimental/forge/src/types.ts b/packages/experimental/forge/src/types.ts new file mode 100644 index 0000000000..0bee3ce64b --- /dev/null +++ b/packages/experimental/forge/src/types.ts @@ -0,0 +1,151 @@ +/** Provider-neutral vocabulary for code hosts, their change requests, and provider contracts. */ + +import type { Branded } from '@deepseek-ai/dsh-brand' + +/** One repository on a code host, identified the way its provider addresses it. */ +export interface ForgeRepository { + /** Provider family that serves the repository, such as `github`. */ + readonly forge: string + /** Lowercase host name, such as `github.com`. */ + readonly host: string + /** Owner or namespace path as the host spells it. */ + readonly owner: string + /** Repository name as the host spells it. */ + readonly name: string +} + +/** One change request (pull request or merge request) in a repository. */ +export interface ChangeRequestRef { + /** Repository that holds the change request. */ + readonly repository: ForgeRepository + /** Host-assigned change request number. */ + readonly number: number +} + +/** Canonical identity of a change request across providers; built by `changeRequestKey`. */ +export type ChangeRequestKey = Branded<'ChangeRequestKey'> + +/** Lifecycle state of a change request. */ +export type ChangeRequestState = 'open' | 'closed' | 'merged' + +/** Normalized outcome of one status check. */ +export type CheckState = 'pending' | 'success' | 'failure' | 'neutral' | 'skipped' | 'cancelled' + +/** One status check reported for the head commit of a change request. */ +export interface ChangeRequestCheck { + /** Check name as the host displays it. */ + readonly name: string + /** Normalized check outcome. */ + readonly state: CheckState + /** Web page for the check, or null when the host reports none. */ + readonly url: string | null + /** Workflow that produced the check, or null when the host reports none. */ + readonly workflow: string | null +} + +/** Combined outcome of all checks on the head commit. */ +export type ChecksRollup = 'none' | 'pending' | 'success' | 'failure' + +/** Whether the change request can merge into its base without conflicts. */ +export type Mergeability = 'mergeable' | 'conflicting' | 'unknown' + +/** Aggregate review outcome required by the host's merge rules. */ +export type ReviewDecision = 'approved' | 'changes-requested' | 'review-required' | 'none' + +/** Status of one change request at the time its provider read it. */ +export interface ChangeRequestSnapshot { + /** Change request the snapshot describes. */ + readonly ref: ChangeRequestRef + /** Web page of the change request. */ + readonly url: string + /** Title as the author wrote it. */ + readonly title: string + /** Lifecycle state. */ + readonly state: ChangeRequestState + /** Whether the author marked the change request as a draft. */ + readonly draft: boolean + /** Author login, or null when the host hides it. */ + readonly author: string | null + /** Branch that carries the proposed commits. */ + readonly headBranch: string + /** Commit object name at the head of the change request. */ + readonly headSha: string + /** Branch the change request merges into. */ + readonly baseBranch: string + /** Merge conflict status; `unknown` while the host computes it. */ + readonly mergeability: Mergeability + /** Aggregate review outcome. */ + readonly reviewDecision: ReviewDecision + /** Checks on the head commit in host order. */ + readonly checks: readonly ChangeRequestCheck[] + /** Combined check outcome. */ + readonly checksRollup: ChecksRollup + /** Whether the host reported more checks than `checks` contains. */ + readonly checksTruncated: boolean + /** Number of review threads that are not resolved. */ + readonly unresolvedThreads: number + /** ISO 8601 time of the host's last update to the change request. */ + readonly updatedAt: string +} + +/** Why a provider could not read a change request. */ +export interface ForgeFailure { + /** `not-found`: the host does not expose it; `unauthenticated`: credentials are missing or rejected; `unavailable`: any other failure. */ + readonly code: 'not-found' | 'unauthenticated' | 'unavailable' + /** Human-readable cause. */ + readonly message: string +} + +/** Latest polling result delivered to watchers of one change request. */ +export type ChangeRequestUpdate = { + /** Canonical identity of the watched change request. */ + readonly key: ChangeRequestKey + /** Epoch milliseconds when the result settled. */ + readonly fetchedAt: number +} & ({ + /** Status read from the host. */ + readonly snapshot: ChangeRequestSnapshot +} | { + /** Reason the status could not be read. */ + readonly failure: ForgeFailure +}) + +/** + * Host-specific access registered with `ctx.forge`. One provider serves one + * forge family; methods that take a signal must stop work when it aborts. + */ +export interface ForgeProvider { + /** Forge family name stored in every repository this provider returns. */ + readonly forge: string + /** Map a Git remote URL to its repository, or undefined when this provider does not serve it. */ + repositoryFromRemote(url: string): ForgeRepository | undefined + /** Map a change request web URL to its reference, or undefined when this provider does not serve it. */ + changeRequestFromUrl(url: string): ChangeRequestRef | undefined + /** Build the canonical web URL of a change request this provider serves. */ + changeRequestUrl(ref: ChangeRequestRef): string + /** Read the status of several change requests, returning one result per requested key. */ + fetch( + refs: readonly ChangeRequestRef[], + signal: AbortSignal, + ): Promise> + /** Find open change requests whose head is `branch` in `repository`, excluding the default branch and forks. */ + findByHead(repository: ForgeRepository, branch: string, signal: AbortSignal): Promise +} + +/** + * Receives every polling result for one watched change request. + * @param update - the settled result. + */ +export type ChangeRequestListener = (update: ChangeRequestUpdate) => void + +/** Poll cadence and fetch deadline for watched change requests. */ +export interface Config { + /** Poll interval in milliseconds while checks run or mergeability is unknown. */ + activeIntervalMs?: number + /** Poll interval in milliseconds for an open change request whose checks have settled. */ + idleIntervalMs?: number + /** Largest delay in milliseconds between polls after consecutive failures. */ + maxBackoffMs?: number + /** Deadline in milliseconds for one provider fetch. */ + fetchTimeoutMs?: number +} diff --git a/packages/experimental/forge/tests/fake-provider.ts b/packages/experimental/forge/tests/fake-provider.ts new file mode 100644 index 0000000000..c432b7390f --- /dev/null +++ b/packages/experimental/forge/tests/fake-provider.ts @@ -0,0 +1,106 @@ +import { Context } from '@deepseek-ai/cordis' +import type { Fiber } from '@deepseek-ai/cordis' +import ForgeService, { changeRequestKey } from '../src/index.ts' +import type { Config } from '../src/index.ts' +import type { + ChangeRequestKey, ChangeRequestRef, ChangeRequestSnapshot, ForgeFailure, ForgeProvider, ForgeRepository, +} from '../src/types.ts' + +type FetchResult = ReadonlyMap + +/** Repository served by the default fake provider. */ +export const demo: ForgeRepository = { forge: 'fake', host: 'fake.test', owner: 'Octo', name: 'Demo' } + +/** Build a change request reference in `demo` or another repository. */ +export function ref(number: number, repository: ForgeRepository = demo): ChangeRequestRef { + return { repository, number } +} + +/** Build an open, mergeable snapshot whose checks passed. */ +export function snapshot(target: ChangeRequestRef, overrides: Partial = {}): ChangeRequestSnapshot { + return { + ref: target, + url: `https://${target.repository.host}/${target.repository.owner}/${target.repository.name}/change/${target.number}`, + title: `Change ${target.number}`, + state: 'open', + draft: false, + author: 'octo', + headBranch: 'topic', + headSha: 'a'.repeat(40), + baseBranch: 'main', + mergeability: 'mergeable', + reviewDecision: 'none', + checks: [], + checksRollup: 'success', + checksTruncated: false, + unresolvedThreads: 0, + updatedAt: '2026-10-09T00:00:00Z', + ...overrides, + } +} + +/** A scripted provider that records every call. */ +export class FakeProvider implements ForgeProvider { + readonly forge: string + readonly fetches: (readonly ChangeRequestRef[])[] = [] + readonly signals: AbortSignal[] = [] + readonly results = new Map() + readonly heads = new Map() + fetchImpl: ((refs: readonly ChangeRequestRef[], signal: AbortSignal) => Promise) | undefined + + constructor(forge = 'fake') { + this.forge = forge + } + + repositoryFromRemote(url: string): ForgeRepository | undefined { + const match = /^fake:\/\/([^/]+)\/([^/]+)\/([^/]+)$/.exec(url) + if (match === null) return undefined + const [, host = '', owner = '', name = ''] = match + return { forge: this.forge, host, owner, name } + } + + changeRequestFromUrl(url: string): ChangeRequestRef | undefined { + const match = /^https:\/\/([^/]+)\/([^/]+)\/([^/]+)\/change\/(\d+)$/.exec(url) + if (match === null) return undefined + const [, host = '', owner = '', name = '', number = '0'] = match + return { repository: { forge: this.forge, host, owner, name }, number: Number(number) } + } + + changeRequestUrl(target: ChangeRequestRef): string { + return `https://${target.repository.host}/${target.repository.owner}/${target.repository.name}/change/${target.number}` + } + + async fetch(refs: readonly ChangeRequestRef[], signal: AbortSignal): Promise { + this.fetches.push(refs) + this.signals.push(signal) + if (this.fetchImpl !== undefined) return await this.fetchImpl(refs, signal) + return new Map(refs.map((target) => { + const key = changeRequestKey(target) + return [key, this.results.get(key) ?? { code: 'not-found', message: 'missing' }] as const + })) + } + + async findByHead(repository: ForgeRepository, branch: string): Promise { + return this.heads.get(`${repository.owner}/${repository.name}:${branch}`) ?? [] + } + +} + +/** Boot a context with only the forge service. */ +export async function forgeContext(config: Config = {}): Promise { + const ctx = new Context() + await ctx.plugin(ForgeService, config) + return ctx +} + +/** Register `provider` from its own plugin fiber, returning that fiber. */ +export async function provide(ctx: Context, provider: ForgeProvider): Promise { + const fiber = ctx.plugin({ + inject: ['forge'], + apply(child: Context) { + child.forge.register(provider) + }, + }) + await fiber + return fiber +} diff --git a/packages/experimental/forge/tests/registry.spec.ts b/packages/experimental/forge/tests/registry.spec.ts new file mode 100644 index 0000000000..4258047a62 --- /dev/null +++ b/packages/experimental/forge/tests/registry.spec.ts @@ -0,0 +1,90 @@ +import { Context } from '@deepseek-ai/cordis' +import { afterEach, describe, expect, it } from 'vitest' +import ForgeService, { changeRequestKey } from '../src/index.ts' +import { demo, FakeProvider, forgeContext, provide, ref } from './fake-provider.ts' + +let ctx: Context | undefined + +afterEach(async () => { + await ctx?.fiber.dispose() + ctx = undefined +}) + +describe('forge registry', () => { + it('resolves remotes and change request URLs through the first provider that serves them', async () => { + ctx = await forgeContext() + await provide(ctx, new FakeProvider('fake')) + await provide(ctx, new FakeProvider('other')) + + expect(ctx.forge.repositoryFromRemote('fake://fake.test/Octo/Demo')).toEqual({ forge: 'fake', host: 'fake.test', owner: 'Octo', name: 'Demo' }) + expect(ctx.forge.changeRequestFromUrl('https://fake.test/Octo/Demo/change/7')).toEqual(ref(7)) + expect(ctx.forge.repositoryFromRemote('https://unknown.test/a/b')).toBeUndefined() + expect(ctx.forge.changeRequestFromUrl('https://unknown.test/a/b/pull/1')).toBeUndefined() + }) + + it('delegates repository operations to the provider of their forge', async () => { + ctx = await forgeContext() + const provider = new FakeProvider() + provider.heads.set('Octo/Demo:topic', [ref(3)]) + await provide(ctx, provider) + const signal = new AbortController().signal + + expect(ctx.forge.changeRequestUrl(ref(3))).toBe('https://fake.test/Octo/Demo/change/3') + await expect(ctx.forge.findByHead(demo, 'topic', signal)).resolves.toEqual([ref(3)]) + }) + + it('rejects operations for a forge without a provider', async () => { + ctx = await forgeContext() + const elsewhere = { ...demo, forge: 'missing' } + const signal = new AbortController().signal + + expect(() => ctx!.forge.changeRequestUrl(ref(1, elsewhere))).toThrow('forge: no provider is registered for "missing"') + await expect(ctx.forge.findByHead(elsewhere, 'topic', signal)).rejects.toThrow('forge: no provider is registered for "missing"') + }) + + it('refuses a second provider for the same forge', async () => { + ctx = await forgeContext() + await provide(ctx, new FakeProvider()) + + await expect(provide(ctx, new FakeProvider())).rejects.toThrow('forge: a provider for "fake" is already registered') + }) + + it('removes a provider when its contributing fiber is disposed', async () => { + ctx = await forgeContext() + const fiber = await provide(ctx, new FakeProvider()) + expect(ctx.forge.changeRequestFromUrl('https://fake.test/Octo/Demo/change/1')).toBeDefined() + + await fiber.dispose() + + expect(ctx.forge.changeRequestFromUrl('https://fake.test/Octo/Demo/change/1')).toBeUndefined() + expect(ctx.forge.repositoryFromRemote('fake://fake.test/Octo/Demo')).toBeUndefined() + }) + + it('removes a provider through the disposer that register returns', async () => { + ctx = await forgeContext() + const unregister = ctx.forge.register(new FakeProvider()) + expect(ctx.forge.repositoryFromRemote('fake://fake.test/Octo/Demo')).toBeDefined() + + unregister() + + expect(ctx.forge.repositoryFromRemote('fake://fake.test/Octo/Demo')).toBeUndefined() + }) + + it('builds case-insensitive keys from forge, host, owner, name, and number', () => { + const upper = changeRequestKey(ref(9, { forge: 'fake', host: 'FAKE.test', owner: 'OCTO', name: 'DEMO' })) + + expect(upper).toBe('fake:fake.test/octo/demo#9') + expect(changeRequestKey(ref(9))).toBe(upper) + }) + + it.each([ + ['activeIntervalMs', 0], + ['idleIntervalMs', -1], + ['maxBackoffMs', 1.5], + ['fetchTimeoutMs', 2_147_483_648], + ])('rejects %s = %s at load', async (key, value) => { + ctx = new Context() + + await expect(ctx.plugin(ForgeService, { [key]: value })).rejects.toThrow(`forge: ${key} must be a positive integer no greater than 2147483647`) + }) +}) diff --git a/packages/experimental/forge/tests/watch.spec.ts b/packages/experimental/forge/tests/watch.spec.ts new file mode 100644 index 0000000000..59ab961a02 --- /dev/null +++ b/packages/experimental/forge/tests/watch.spec.ts @@ -0,0 +1,309 @@ +import { Context } from '@deepseek-ai/cordis' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import ForgeService, { changeRequestKey } from '../src/index.ts' +import type { ChangeRequestKey, ChangeRequestRef, ChangeRequestSnapshot, ChangeRequestUpdate, ForgeFailure, ForgeRepository } from '../src/types.ts' +import { FakeProvider, forgeContext, provide, ref, snapshot } from './fake-provider.ts' + +const config = { activeIntervalMs: 1_000, idleIntervalMs: 5_000, maxBackoffMs: 6_000, fetchTimeoutMs: 500 } +const other: ForgeRepository = { forge: 'other', host: 'other.test', owner: 'o', name: 'r' } +const key = changeRequestKey(ref(1)) + +let ctx: Context | undefined + +beforeEach(() => { + vi.useFakeTimers({ now: 1_000_000 }) +}) + +afterEach(async () => { + await ctx?.fiber.dispose() + ctx = undefined + vi.useRealTimers() +}) + +async function setup(...providers: FakeProvider[]): Promise { + ctx = await forgeContext(config) + for (const provider of providers) await provide(ctx, provider) + return ctx +} + +function collect(context: Context, target: ChangeRequestRef = ref(1)): { updates: ChangeRequestUpdate[]; unwatch: () => void } { + const updates: ChangeRequestUpdate[] = [] + const unwatch = context.forge.watch(target, (update) => { + updates.push(update) + }) + return { updates, unwatch } +} + +/** A fetch that stays pending until released or until its signal aborts. */ +function held(provider: FakeProvider, result: (target: ChangeRequestRef) => ChangeRequestSnapshot | ForgeFailure): () => void { + const releases: (() => void)[] = [] + type Results = ReadonlyMap + provider.fetchImpl = (refs, signal) => new Promise((resolve, reject) => { + signal.addEventListener('abort', () => { + reject(signal.reason as Error) + }) + releases.push(() => { + resolve(new Map(refs.map(target => [changeRequestKey(target), result(target)] as const))) + }) + }) + return () => { + for (const release of releases.splice(0)) release() + } +} + +describe('forge watch', () => { + it('fetches a new watch at once and keeps the latest result', async () => { + const provider = new FakeProvider() + provider.results.set(key, snapshot(ref(1))) + const context = await setup(provider) + const { updates } = collect(context) + + await vi.advanceTimersByTimeAsync(0) + + expect(provider.fetches).toEqual([[ref(1)]]) + expect(updates).toEqual([{ key, fetchedAt: 1_000_000, snapshot: snapshot(ref(1)) }]) + expect(context.forge.latest(ref(1))).toEqual(updates[0]) + expect(context.forge.latest(ref(2))).toBeUndefined() + }) + + it('batches due change requests of one forge into one fetch', async () => { + const provider = new FakeProvider() + const context = await setup(provider) + collect(context, ref(1)) + collect(context, ref(2)) + + await vi.advanceTimersByTimeAsync(0) + + expect(provider.fetches).toEqual([[ref(1), ref(2)]]) + }) + + it('polls pending work at the active interval, settled work at the idle interval, and stops at a terminal state', async () => { + const provider = new FakeProvider() + provider.results.set(key, snapshot(ref(1), { checksRollup: 'pending' })) + const context = await setup(provider) + collect(context) + + await vi.advanceTimersByTimeAsync(0) + await vi.advanceTimersByTimeAsync(999) + expect(provider.fetches).toHaveLength(1) + provider.results.set(key, snapshot(ref(1))) + await vi.advanceTimersByTimeAsync(1) + expect(provider.fetches).toHaveLength(2) + await vi.advanceTimersByTimeAsync(4_999) + expect(provider.fetches).toHaveLength(2) + provider.results.set(key, snapshot(ref(1), { state: 'merged' })) + await vi.advanceTimersByTimeAsync(1) + expect(provider.fetches).toHaveLength(3) + await vi.advanceTimersByTimeAsync(60_000) + expect(provider.fetches).toHaveLength(3) + }) + + it('treats unknown mergeability as active work', async () => { + const provider = new FakeProvider() + provider.results.set(key, snapshot(ref(1), { mergeability: 'unknown' })) + const context = await setup(provider) + collect(context) + + await vi.advanceTimersByTimeAsync(1_000) + + expect(provider.fetches).toHaveLength(2) + }) + + it('backs off after consecutive failures up to the cap and resets after success', async () => { + const provider = new FakeProvider() + const context = await setup(provider) + const { updates } = collect(context) + + await vi.advanceTimersByTimeAsync(0) + expect(updates[0]).toEqual({ key, fetchedAt: 1_000_000, failure: { code: 'not-found', message: 'missing' } }) + await vi.advanceTimersByTimeAsync(1_999) + expect(provider.fetches).toHaveLength(1) + await vi.advanceTimersByTimeAsync(1) + expect(provider.fetches).toHaveLength(2) + await vi.advanceTimersByTimeAsync(3_999) + expect(provider.fetches).toHaveLength(2) + await vi.advanceTimersByTimeAsync(1) + expect(provider.fetches).toHaveLength(3) + await vi.advanceTimersByTimeAsync(5_999) + expect(provider.fetches).toHaveLength(3) + provider.results.set(key, snapshot(ref(1), { checksRollup: 'pending' })) + await vi.advanceTimersByTimeAsync(1) + expect(provider.fetches).toHaveLength(4) + await vi.advanceTimersByTimeAsync(1_000) + expect(provider.fetches).toHaveLength(5) + }) + + it('runs one fetch per forge at a time while another forge proceeds', async () => { + const slow = new FakeProvider('fake') + const quick = new FakeProvider('other') + const release = held(slow, target => snapshot(target, { checksRollup: 'pending' })) + quick.results.set(changeRequestKey(ref(5, other)), snapshot(ref(5, other))) + const context = await setup(slow, quick) + collect(context, ref(1)) + await vi.advanceTimersByTimeAsync(0) + + collect(context, ref(2)) + collect(context, ref(5, other)) + await vi.advanceTimersByTimeAsync(0) + expect(slow.fetches).toEqual([[ref(1)]]) + expect(quick.fetches).toEqual([[ref(5, other)]]) + + release() + await vi.advanceTimersByTimeAsync(0) + expect(slow.fetches).toEqual([[ref(1)], [ref(2)]]) + }) + + it('stops polling a change request after its last listener leaves', async () => { + const provider = new FakeProvider() + provider.results.set(key, snapshot(ref(1), { checksRollup: 'pending' })) + const context = await setup(provider) + const first = collect(context) + const second = collect(context) + await vi.advanceTimersByTimeAsync(0) + + first.unwatch() + await vi.advanceTimersByTimeAsync(1_000) + expect(provider.fetches).toHaveLength(2) + expect(first.updates).toHaveLength(1) + second.unwatch() + second.unwatch() + await vi.advanceTimersByTimeAsync(10_000) + expect(provider.fetches).toHaveLength(2) + expect(context.forge.latest(ref(1))).toBeUndefined() + }) + + it('keeps notifying other listeners when one listener throws', async () => { + const provider = new FakeProvider() + provider.results.set(key, snapshot(ref(1))) + const context = await setup(provider) + const warn = vi.spyOn(context.logger, 'warn').mockImplementation(() => {}) + context.forge.watch(ref(1), () => { + throw new Error('listener failed') + }) + const { updates } = collect(context) + + await vi.advanceTimersByTimeAsync(0) + + expect(updates).toHaveLength(1) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('listener failed')) + }) + + it('reports unavailable when the provider throws, omits a result, or is removed', async () => { + const provider = new FakeProvider() + provider.fetchImpl = async () => { + throw new Error('network down') + } + ctx = await forgeContext(config) + const fiber = await provide(ctx, provider) + const { updates } = collect(ctx) + + await vi.advanceTimersByTimeAsync(0) + expect(updates.at(-1)).toMatchObject({ failure: { code: 'unavailable', message: 'network down' } }) + provider.fetchImpl = async () => { + throw 'plain failure' + } + await vi.advanceTimersByTimeAsync(2_000) + expect(updates.at(-1)).toMatchObject({ failure: { code: 'unavailable', message: 'plain failure' } }) + provider.fetchImpl = async () => new Map() + await vi.advanceTimersByTimeAsync(4_000) + expect(updates.at(-1)).toMatchObject({ failure: { code: 'unavailable', message: `provider returned no result for ${key}` } }) + + await fiber.dispose() + expect(updates.at(-1)).toMatchObject({ failure: { code: 'unavailable', message: 'no provider is registered for "fake"' } }) + await vi.advanceTimersByTimeAsync(6_000) + expect(updates).toHaveLength(5) + expect(updates.at(-1)).toMatchObject({ failure: { code: 'unavailable', message: 'no provider is registered for "fake"' } }) + }) + + it('notifies only the watches of the forge whose provider left', async () => { + const leaving = new FakeProvider('fake') + const staying = new FakeProvider('other') + leaving.results.set(key, snapshot(ref(1))) + staying.results.set(changeRequestKey(ref(5, other)), snapshot(ref(5, other))) + ctx = await forgeContext(config) + const fiber = await provide(ctx, leaving) + await provide(ctx, staying) + const affected = collect(ctx, ref(1)) + const unaffected = collect(ctx, ref(5, other)) + await vi.advanceTimersByTimeAsync(0) + + await fiber.dispose() + + expect(affected.updates.at(-1)).toMatchObject({ failure: { code: 'unavailable' } }) + expect(unaffected.updates).toHaveLength(1) + }) + + it('drops the result of a fetch whose watch ended while it was in flight', async () => { + const provider = new FakeProvider() + const release = held(provider, target => snapshot(target)) + const context = await setup(provider) + const { updates, unwatch } = collect(context) + await vi.advanceTimersByTimeAsync(0) + + unwatch() + release() + await vi.advanceTimersByTimeAsync(0) + + expect(updates).toEqual([]) + expect(context.forge.latest(ref(1))).toBeUndefined() + }) + + it('arms no timer when a watch ends after the service stopped', async () => { + ctx = new Context() + const service = ctx.plugin(ForgeService, config) + await service + const unwatch = ctx.forge.watch(ref(1), () => {}) + + await service.dispose() + unwatch() + + expect(vi.getTimerCount()).toBe(0) + }) + + it('aborts a fetch that outlives fetchTimeoutMs', async () => { + const provider = new FakeProvider() + held(provider, target => snapshot(target)) + const context = await setup(provider) + const { updates } = collect(context) + + await vi.advanceTimersByTimeAsync(0) + await vi.advanceTimersByTimeAsync(500) + + expect(provider.signals[0]?.aborted).toBe(true) + expect(updates).toEqual([{ key, fetchedAt: 1_000_500, failure: { code: 'unavailable', message: 'FORGE_FETCH_TIMEOUT after 500ms' } }]) + }) + + it('cancels fetches and timers without notifying when the service is disposed', async () => { + const provider = new FakeProvider() + held(provider, target => snapshot(target)) + const context = await setup(provider) + const { updates } = collect(context) + await vi.advanceTimersByTimeAsync(0) + + await context.fiber.dispose() + ctx = undefined + + expect(provider.signals[0]?.aborted).toBe(true) + expect(updates).toEqual([]) + expect(vi.getTimerCount()).toBe(0) + }) + + it('releases a watch when the subscribing fiber is disposed', async () => { + const provider = new FakeProvider() + provider.results.set(key, snapshot(ref(1), { checksRollup: 'pending' })) + const context = await setup(provider) + const fiber = context.plugin({ + inject: ['forge'], + apply(child: Context) { + child.forge.watch(ref(1), () => {}) + }, + }) + await fiber + await vi.advanceTimersByTimeAsync(0) + + await fiber.dispose() + await vi.advanceTimersByTimeAsync(10_000) + + expect(provider.fetches).toHaveLength(1) + }) +}) diff --git a/packages/experimental/forge/tsconfig.json b/packages/experimental/forge/tsconfig.json new file mode 100644 index 0000000000..c54cd403b2 --- /dev/null +++ b/packages/experimental/forge/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": "../../../vendor/schemastery" + }, + { + "path": "../../util/brand" + }, + { + "path": "../../util/timeout" + } + ] +} diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 57be03234e..df46e3eb06 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -1194,6 +1194,88 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, + { + key: 'forge', + summary: 'Registry of forge providers.', + description: 'Registry of forge providers. Watchers of one change request share a single polling loop; each forge has at most one fetch in flight, which batches every due change request of that forge.', + methods: [ + { + signature: 'register(provider: ForgeProvider): () => void', + description: 'Register the provider of one forge family as an effect on the calling fiber. Watches of that forge receive an `unavailable` failure when the registration ends.', + parameters: [{ name: 'provider', description: 'provider to serve `provider.forge`.' }], + returns: 'the disposer that unregisters the provider.', + throws: ['when another provider already serves the same forge.'], + }, + { + signature: 'repositoryFromRemote(url: string): ForgeRepository | undefined', + description: 'Map a Git remote URL to a repository through the first provider that serves it.', + parameters: [{ name: 'url', description: 'fetch or push URL as Git reports it.' }], + returns: 'the repository, or undefined when no provider serves the URL.', + }, + { + signature: 'changeRequestFromUrl(url: string): ChangeRequestRef | undefined', + description: 'Map a change request web URL through the first provider that serves it.', + parameters: [{ name: 'url', description: 'web URL of a pull or merge request.' }], + returns: 'the change request, or undefined when no provider serves the URL.', + }, + { + signature: 'changeRequestUrl(ref: ChangeRequestRef): string', + description: 'Build the canonical web URL of a change request.', + parameters: [{ name: 'ref', description: 'change request served by a registered provider.' }], + returns: 'the provider\'s canonical URL.', + throws: ['when no provider serves the change request\'s forge.'], + }, + { + signature: 'async findByHead(repository: ForgeRepository, branch: string, signal: AbortSignal): Promise', + description: 'Find open change requests whose head is `branch`, excluding the default branch and forks.', + parameters: [{ name: 'repository', description: 'repository served by a registered provider.' }, { name: 'branch', description: 'head branch name on the host.' }, { name: 'signal', description: 'cancellation for the lookup.' }], + returns: 'matching change requests, newest first as the provider reports them.', + throws: ['when no provider serves the repository\'s forge or the lookup fails.'], + }, + { + signature: 'watch(ref: ChangeRequestRef, listener: ChangeRequestListener): () => void', + description: 'Watch one change request as an effect on the calling fiber. The first watcher starts polling at once; listeners receive every later result, and `latest` returns the current one. Polling stops when the last watcher leaves. Listeners must not throw; a thrown error is logged and skipped.', + parameters: [{ name: 'ref', description: 'change request to poll.' }, { name: 'listener', description: 'receives each polling result.' }], + returns: 'the disposer that stops this watch.', + }, + { + signature: 'latest(ref: ChangeRequestRef): ChangeRequestUpdate | undefined', + description: 'Read the latest polling result of a watched change request.', + parameters: [{ name: 'ref', description: 'change request to read.' }], + returns: 'the latest result, or undefined before the first result or without a watch.', + }, + ], + }, + { + key: 'forgeSession', + summary: 'Session-scoped change request bindings.', + description: 'Session-scoped change request bindings. The `forgeChangeRequests` projection derives them from `gh pr create` and `git push` results and from `plugin:forge-session/binding` records. After each push result the service looks up open change requests whose head is a pushed branch and records the unbound ones as discovered. Watching a Session polls its visible change requests through `ctx.forge`.', + methods: [ + { + signature: 'changeRequests(session: Session): SessionChangeRequest[]', + description: 'List the change requests bound to a Session and not dismissed.', + parameters: [{ name: 'session', description: 'Session whose bindings are read.' }], + returns: 'visible bindings in first-recorded order, each with its latest polling result.', + }, + { + signature: 'track(session: Session, url: string): void', + description: 'Bind a change request to a Session by URL, or restore a dismissed binding. Appends a `track` record with the provider\'s canonical URL unless the change request is already visible.', + parameters: [{ name: 'session', description: 'Session that receives the binding.' }, { name: 'url', description: 'web URL of the change request.' }], + throws: ['when no registered provider recognizes the URL.'], + }, + { + signature: 'dismiss(session: Session, url: string): void', + description: 'Hide a visible binding. Appends a `dismiss` record; a URL that is not visibly bound leaves the log unchanged. Discovery never restores a dismissed binding; `track` does.', + parameters: [{ name: 'session', description: 'Session that owns the binding.' }, { name: 'url', description: 'web URL of the change request.' }], + }, + { + signature: 'watch(session: Session, listener: SessionChangeRequestsListener): () => void', + description: 'Watch a Session\'s visible change requests as an effect on the calling fiber. Every visible change request that a provider recognizes is polled through `ctx.forge` while the Session has a watcher. Listeners receive the complete list after each later binding change or polling result; read the current list with changeRequests. Listeners must not throw; a thrown error is logged and skipped.', + parameters: [{ name: 'session', description: 'Session to watch.' }, { name: 'listener', description: 'receives the complete visible list.' }], + returns: 'the disposer that stops this watch.', + }, + ], + }, { key: 'fs', summary: 'Abstract filesystem provider.', @@ -5136,6 +5218,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'BashEnvVariableInfo', declaration: 'export interface BashEnvVariableInfo extends BashEnvVariable {\n contributor: string;\n key: DshEnvironmentKey;\n}', }, + { + name: 'BindingOrigin', + declaration: 'export type BindingOrigin = \'created\' | \'discovered\' | \'tracked\';', + }, { name: 'BoxProps', declaration: 'export interface BoxProps {\n readonly flexDirection?: \'row\' | \'column\' | undefined;\n readonly paddingX?: number | undefined;\n readonly paddingY?: number | undefined;\n readonly padding?: number | undefined;\n readonly gap?: number | undefined;\n readonly border?: boolean | string | undefined;\n readonly borderColor?: string | undefined;\n readonly children?: UiNode | undefined;\n}', @@ -5168,10 +5254,46 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ButtonProps', declaration: 'export interface ButtonProps {\n readonly label: string;\n readonly hotkey?: string | undefined;\n readonly disabled?: boolean | undefined;\n readonly onPress?: (() => unknown) | undefined;\n}', }, + { + name: 'ChangeRequestCheck', + declaration: 'export interface ChangeRequestCheck {\n readonly name: string;\n readonly state: CheckState;\n readonly url: string | null;\n readonly workflow: string | null;\n}', + }, + { + name: 'ChangeRequestKey', + declaration: 'export type ChangeRequestKey = Branded<\'ChangeRequestKey\'>;', + }, + { + name: 'ChangeRequestListener', + declaration: 'export type ChangeRequestListener = (update: ChangeRequestUpdate) => void;', + }, + { + name: 'ChangeRequestRef', + declaration: 'export interface ChangeRequestRef {\n readonly repository: ForgeRepository;\n readonly number: number;\n}', + }, + { + name: 'ChangeRequestSnapshot', + declaration: 'export interface ChangeRequestSnapshot {\n readonly ref: ChangeRequestRef;\n readonly url: string;\n readonly title: string;\n readonly state: ChangeRequestState;\n readonly draft: boolean;\n readonly author: string | null;\n readonly headBranch: string;\n readonly headSha: string;\n readonly baseBranch: string;\n readonly mergeability: Mergeability;\n readonly reviewDecision: ReviewDecision;\n readonly checks: readonly ChangeRequestCheck[];\n readonly checksRollup: ChecksRollup;\n readonly checksTruncated: boolean;\n readonly unresolvedThreads: number;\n readonly updatedAt: string;\n}', + }, + { + name: 'ChangeRequestState', + declaration: 'export type ChangeRequestState = \'open\' | \'closed\' | \'merged\';', + }, + { + name: 'ChangeRequestUpdate', + declaration: 'export type ChangeRequestUpdate = {\n readonly key: ChangeRequestKey;\n readonly fetchedAt: number;\n} & ({\n readonly snapshot: ChangeRequestSnapshot;\n} | {\n readonly failure: ForgeFailure;\n});', + }, { name: 'ChangeResult', declaration: 'export interface ChangeResult {\n changed: boolean;\n application: \'applied\' | \'restart-required\' | \'overridden\' | \'failed\' | \'cancelled\';\n restartReason?: RestartReason;\n stage: \'install\' | \'enable\' | \'remove\';\n target: string;\n enabled?: boolean;\n error?: ManagementError;\n warnings?: string[];\n packageResult?: PackageResult;\n bundle?: string;\n version?: string;\n pendingBuilds?: string[];\n approvedBuilds?: string[];\n registries?: Registry[];\n failedAt?: \'registry\' | \'spec-host\';\n}', }, + { + name: 'ChecksRollup', + declaration: 'export type ChecksRollup = \'none\' | \'pending\' | \'success\' | \'failure\';', + }, + { + name: 'CheckState', + declaration: 'export type CheckState = \'pending\' | \'success\' | \'failure\' | \'neutral\' | \'skipped\' | \'cancelled\';', + }, { name: 'ClientArtifactBaseline', declaration: 'export interface ClientArtifactBaseline {\n readonly path: string;\n readonly mtimeMs: number;\n readonly ctimeMs: number;\n readonly size: number;\n}', @@ -5732,6 +5854,18 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'FinishReasonMap', declaration: 'export interface FinishReasonMap {\n \'stop\': {\n kind: \'stop\';\n };\n \'tool-calls\': {\n kind: \'tool-calls\';\n };\n \'max-tokens\': {\n kind: \'max-tokens\';\n };\n \'aborted\': {\n kind: \'aborted\';\n failure: LlmFailure;\n };\n \'error\': {\n kind: \'error\';\n failure: LlmFailure;\n };\n}', }, + { + name: 'ForgeFailure', + declaration: 'export interface ForgeFailure {\n readonly code: \'not-found\' | \'unauthenticated\' | \'unavailable\';\n readonly message: string;\n}', + }, + { + name: 'ForgeProvider', + declaration: 'export interface ForgeProvider {\n readonly forge: string;\n repositoryFromRemote(url: string): ForgeRepository | undefined;\n changeRequestFromUrl(url: string): ChangeRequestRef | undefined;\n changeRequestUrl(ref: ChangeRequestRef): string;\n fetch(refs: readonly ChangeRequestRef[], signal: AbortSignal): Promise>;\n findByHead(repository: ForgeRepository, branch: string, signal: AbortSignal): Promise;\n}', + }, + { + name: 'ForgeRepository', + declaration: 'export interface ForgeRepository {\n readonly forge: string;\n readonly host: string;\n readonly owner: string;\n readonly name: string;\n}', + }, { name: 'FrequencyTooHighError', declaration: 'export interface FrequencyTooHighError {\n readonly code: \'frequency_too_high\';\n readonly message: string;\n}', @@ -6340,6 +6474,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'McpResourceRequest', declaration: 'export type McpResourceRequest = {\n method: \'resources/list\' | \'resources/templates/list\';\n cursor?: string;\n} | {\n method: \'resources/read\';\n uri: string;\n};', }, + { + name: 'Mergeability', + declaration: 'export type Mergeability = \'mergeable\' | \'conflicting\' | \'unknown\';', + }, { name: 'Message', declaration: 'export type Message = MessageRoleMap[keyof MessageRoleMap];', @@ -7096,6 +7234,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ResumeAgentOptions', declaration: 'export interface ResumeAgentOptions {\n readonly resumeSessionId: SessionId;\n readonly parentAgent?: Agent;\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: AgentSetup;\n}', }, + { + name: 'ReviewDecision', + declaration: 'export type ReviewDecision = \'approved\' | \'changes-requested\' | \'review-required\' | \'none\';', + }, { name: 'RpcId', declaration: 'export type RpcId = Branded<\'rpc-id\'>;', @@ -7328,6 +7470,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionCancelValue', declaration: 'export interface SessionCancelValue {\n readonly accepted: true;\n}', }, + { + name: 'SessionChangeRequest', + declaration: 'export interface SessionChangeRequest {\n readonly url: string;\n readonly origin: BindingOrigin;\n readonly ref: ChangeRequestRef | null;\n readonly update: ChangeRequestUpdate | null;\n}', + }, + { + name: 'SessionChangeRequestsListener', + declaration: 'export type SessionChangeRequestsListener = (changeRequests: readonly SessionChangeRequest[]) => void;', + }, { name: 'SessionControlBaseline', declaration: 'export interface SessionControlBaseline {\n readonly projections: Readonly>;\n}', diff --git a/packages/preset/agent-preset/skills/cordis-composition-reference/references/packages.md b/packages/preset/agent-preset/skills/cordis-composition-reference/references/packages.md index a08936db39..08b848e125 100644 --- a/packages/preset/agent-preset/skills/cordis-composition-reference/references/packages.md +++ b/packages/preset/agent-preset/skills/cordis-composition-reference/references/packages.md @@ -200,6 +200,9 @@ Every package below exports a Cordis plugin that a bundle patch can name in a Lo | `@deepseek-ai/dsh-experimental-computer-use-cua-driver-mcp` | yes | Experimental computer use through an installed Cua Driver MCP executable | | `@deepseek-ai/dsh-experimental-computer-use-cua-driver-native` | no | Experimental computer-use provider embedding the Cua Driver native npm SDK | | `@deepseek-ai/dsh-experimental-cua-macos` | yes | macOS native provider for shared Computer Use application and window objects | +| `@deepseek-ai/dsh-experimental-forge` | yes | Provider-neutral change request registry with shared status polling | +| `@deepseek-ai/dsh-experimental-forge-github` | yes | GitHub provider for the experimental forge registry, read through the GitHub CLI on the host | +| `@deepseek-ai/dsh-experimental-forge-session` | yes | Binds change requests to the Session that created, pushed, or tracked them | | `@deepseek-ai/dsh-experimental-inspector` | yes | Experimental cross-realm CDP hub for Host debugging and Client Runtime inspection | | `@deepseek-ai/dsh-experimental-ptc-runtime-python` | yes | CPython subprocess implementation of the DeepSeek Harness PTC execution seam | | `@deepseek-ai/dsh-experimental-session-inspector` | no | Experimental virtualized Session log and live Chat group/node inspectors | diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4325f3cd23..e0add2f7e3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -63,6 +63,15 @@ importers: '@deepseek-ai/dsh-experimental-cua-macos': specifier: workspace:* version: link:packages/experimental/cua-macos + '@deepseek-ai/dsh-experimental-forge': + specifier: workspace:* + version: link:packages/experimental/forge + '@deepseek-ai/dsh-experimental-forge-github': + specifier: workspace:* + version: link:packages/experimental/forge-github + '@deepseek-ai/dsh-experimental-forge-session': + specifier: workspace:* + version: link:packages/experimental/forge-session '@deepseek-ai/dsh-experimental-subagent-oar': specifier: workspace:* version: link:packages/experimental/subagent-oar @@ -8861,6 +8870,117 @@ importers: specifier: ^0.35.3 version: 0.35.5(@types/node@22.20.1) + packages/experimental/forge: + dependencies: + '@deepseek-ai/dsh-brand': + specifier: workspace:* + version: link:../../util/brand + '@deepseek-ai/dsh-timeout': + specifier: workspace:* + version: link:../../util/timeout + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:~ + version: link:../../../vendor/cordis + + packages/experimental/forge-github: + dependencies: + '@deepseek-ai/dsh-timeout': + specifier: workspace:* + version: link:../../util/timeout + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery + execa: + specifier: ^10.0.1 + version: 10.0.1 + zod: + specifier: ^4.4.3 + version: 4.4.3 + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:~ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-experimental-forge': + specifier: workspace:* + version: link:../forge + '@deepseek-ai/dsh-subprocess': + specifier: workspace:* + version: link:../../subprocess/subprocess + + packages/experimental/forge-session: + dependencies: + '@deepseek-ai/dsh-timeout': + specifier: workspace:* + version: link:../../util/timeout + '@deepseek-ai/dsh-util-values': + specifier: workspace:* + version: link:../../util/values + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery + zod: + specifier: ^4.4.3 + version: 4.4.3 + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:~ + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-include': + specifier: workspace:~ + version: link:../../../vendor/include + '@deepseek-ai/cordis-plugin-loader': + specifier: workspace:~ + version: link:../../../vendor/loader + '@deepseek-ai/dsh-agent': + specifier: workspace:* + version: link:../../core/agent + '@deepseek-ai/dsh-agent-loop': + specifier: workspace:* + version: link:../../core/agent-loop + '@deepseek-ai/dsh-bash-local': + specifier: workspace:* + version: link:../../shell/bash-local + '@deepseek-ai/dsh-experimental-forge': + specifier: workspace:* + version: link:../forge + '@deepseek-ai/dsh-experimental-forge-github': + specifier: workspace:* + version: link:../forge-github + '@deepseek-ai/dsh-fs-local': + specifier: workspace:* + version: link:../../fs/fs-local + '@deepseek-ai/dsh-llm': + specifier: workspace:* + version: link:../../llm/llm + '@deepseek-ai/dsh-session': + specifier: workspace:* + version: link:../../core/session + '@deepseek-ai/dsh-session-projection': + specifier: workspace:* + version: link:../../session/session-projection + '@deepseek-ai/dsh-shell-env': + specifier: workspace:* + version: link:../../shell/shell-env + '@deepseek-ai/dsh-subprocess-local': + specifier: workspace:* + version: link:../../subprocess/subprocess-local + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:* + version: link:../../core/system-prompt + '@deepseek-ai/dsh-tool-bash': + specifier: workspace:* + version: link:../../shell/tool-bash + '@deepseek-ai/dsh-tools': + specifier: workspace:* + version: link:../../core/tools + '@deepseek-ai/dsh-working-directory': + specifier: workspace:* + version: link:../../session/working-directory + packages/experimental/hook-protocol: devDependencies: '@deepseek-ai/cordis': diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index f5791d537d..f3037f5fa4 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -118,6 +118,8 @@ export const SERVICE_PAGE: Record = { sessionProjections: 'session-projection.md', workingDirectory: 'working-directory.md', worktrees: 'worktrees.md', + forge: 'forge.md', + forgeSession: 'forge.md', sessionController: 'session.md', sessionSkillCatalog: 'skills.md', sessions: 'session.md', @@ -363,6 +365,13 @@ export const LINK_MAP: Readonly> = { CreateAgentOptions: 'core.md', CreateWorktreeRequest: 'worktrees.md', CreatedWorktree: 'worktrees.md', + ChangeRequestListener: 'forge.md', + ChangeRequestRef: 'forge.md', + ChangeRequestUpdate: 'forge.md', + ForgeProvider: 'forge.md', + ForgeRepository: 'forge.md', + SessionChangeRequest: 'forge.md', + SessionChangeRequestsListener: 'forge.md', GenerateOptions: 'llm-streaming.md', Inbox: 'core.md', InboxItem: 'core.md', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 299eb8b04d..4821691d10 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -178,6 +178,22 @@ const SERVICE_ROLES: ServiceRole[] = [ consumers: ['api-gateway', 'host-frontend-static'], note: 'Owns browser authentication and shared HTTP request dispatch; API adapters register endpoints and streams.', }, + { + key: 'forge', + pkg: 'experimental-forge', + title: 'Code host change requests', + mode: 'seam', + implementations: ['experimental-forge-github'], + consumers: ['experimental-forge-session'], + note: 'Providers map remote and change request URLs, look up change requests by head branch, and read status in batches; watchers of one change request share one polling loop.', + }, + { + key: 'forgeSession', + pkg: 'experimental-forge-session', + title: 'Session change request bindings', + mode: 'service', + note: 'A host-only projection derives bindings from shell results and binding records; push results trigger head-branch discovery, and Session watchers poll bound change requests through the forge registry.', + }, { key: 'worktrees', pkg: 'experimental-worktree', diff --git a/scripts/release/families.spec.ts b/scripts/release/families.spec.ts index 5ead068208..85334c630e 100644 --- a/scripts/release/families.spec.ts +++ b/scripts/release/families.spec.ts @@ -69,6 +69,9 @@ describe('release families', () => { '@deepseek-ai/dsh-cua-bundle', '@deepseek-ai/dsh-experimental-cua-macos', '@deepseek-ai/dsh-cua', + '@deepseek-ai/dsh-experimental-forge-github', + '@deepseek-ai/dsh-experimental-forge-session', + '@deepseek-ai/dsh-experimental-forge', '@deepseek-ai/dsh-hook-protocol', '@deepseek-ai/dsh-hooks-claude-code', '@deepseek-ai/dsh-hooks-codex', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index a7c0978c25..b7c5d7d894 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -2477,6 +2477,56 @@ "symbol": "CreatedWorktree", "source": "packages/experimental/worktree/src/types.ts" }, + { + "doc": "docs/subsystems/forge.md", + "symbol": "ForgeRepository", + "source": "packages/experimental/forge/src/types.ts" + }, + { + "doc": "docs/subsystems/forge.md", + "symbol": "ChangeRequestRef", + "source": "packages/experimental/forge/src/types.ts" + }, + { + "doc": "docs/subsystems/forge.md", + "symbol": "ForgeProvider", + "source": "packages/experimental/forge/src/types.ts" + }, + { + "doc": "docs/subsystems/forge.md", + "symbol": "ChangeRequestSnapshot", + "source": "packages/experimental/forge/src/types.ts" + }, + { + "doc": "docs/subsystems/forge.md", + "symbol": "ChangeRequestUpdate", + "source": "packages/experimental/forge/src/types.ts" + }, + { + "doc": "docs/subsystems/forge.md", + "symbol": "BindingOrigin", + "source": "packages/experimental/forge-session/src/types.ts" + }, + { + "doc": "docs/subsystems/forge.md", + "symbol": "BindingRecord", + "source": "packages/experimental/forge-session/src/types.ts" + }, + { + "doc": "docs/subsystems/forge.md", + "symbol": "SessionChangeRequest", + "source": "packages/experimental/forge-session/src/types.ts" + }, + { + "doc": "docs/subsystems/forge.md", + "symbol": "ChangeRequestListener", + "source": "packages/experimental/forge/src/types.ts" + }, + { + "doc": "docs/subsystems/forge.md", + "symbol": "SessionChangeRequestsListener", + "source": "packages/experimental/forge-session/src/types.ts" + }, { "doc": "docs/subsystems/boot.md", "symbol": "ProfileReconciliation", diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index be4dd9b16b..69e4437c55 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -59,6 +59,9 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/api/terminal-controller': { kind: 'indirect', reason: 'Working-directory recovery owns directory context; user terminal screens never enter model requests.' }, 'packages/client/ui-sidebar-terminal': { kind: 'none', reason: 'The browser renders user terminal screens without exposing them to the model.' }, 'packages/ssh/ssh-helper-runtime': { kind: 'none', reason: 'The private executable carrier registers no model-facing content; its providers and consumers own operation results.' }, + 'packages/experimental/forge': { kind: 'none', reason: 'Providers and watchers exchange code host status without contributing model context.' }, + 'packages/experimental/forge-github': { kind: 'none', reason: 'The provider reads pull request status for host-side watchers without contributing model context.' }, + 'packages/experimental/forge-session': { kind: 'none', reason: 'Bindings, discovery records, and polled status stay on the host; binding records never enter model requests.' }, 'packages/experimental/worktree': { kind: 'indirect', reason: 'The worktree tool renders creation results; the working-directory service owns model-visible directory context.' }, 'packages/ssh/ssh': { kind: 'none', reason: 'The connection owner transports private provider operations; consumers own all model-facing content.' }, 'packages/ssh/fs-ssh': { kind: 'indirect', reason: 'The remote filesystem delegates model rendering to the existing filesystem consumers.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index 4f6eaa1d25..da06e877b6 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -347,6 +347,9 @@ "@deepseek-ai/dsh-experimental-tool-agent-team": ["./packages/experimental/tool-agent-team/src"], "@deepseek-ai/dsh-experimental-worktree": ["./packages/experimental/worktree/src"], "@deepseek-ai/dsh-experimental-tool-worktree": ["./packages/experimental/tool-worktree/src"], + "@deepseek-ai/dsh-experimental-forge": ["./packages/experimental/forge/src"], + "@deepseek-ai/dsh-experimental-forge-github": ["./packages/experimental/forge-github/src"], + "@deepseek-ai/dsh-experimental-forge-session": ["./packages/experimental/forge-session/src"], "@deepseek-ai/dsh-experimental-webworker-runtime": ["./packages/experimental/webworker-runtime/src"], "@deepseek-ai/dsh-experimental-webworker-packer": ["./packages/experimental/webworker-packer/src"], "@deepseek-ai/dsh-experimental-inspector": ["./packages/experimental/inspector/src"], diff --git a/tsconfig.host.json b/tsconfig.host.json index b07df109db..3db777878a 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -369,6 +369,9 @@ { "path": "./packages/session/working-directory" }, { "path": "./packages/experimental/worktree" }, { "path": "./packages/experimental/tool-worktree" }, + { "path": "./packages/experimental/forge" }, + { "path": "./packages/experimental/forge-github" }, + { "path": "./packages/experimental/forge-session" }, { "path": "./packages/session/tool-working-directory" }, { "path": "./packages/session/session-title-llm" }, { "path": "./packages/session/session-title-first-prompt-llm" }, diff --git a/vitest.config.ts b/vitest.config.ts index 56d206808f..e426afd622 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -108,6 +108,10 @@ const windowsRunnerCoverageExclusions = process.platform === 'win32' 'packages/session/session-persistence-jsonl/src/lease.ts', // The macOS provider transport uses Unix sockets; POSIX lanes cover its real socket lifecycle. 'packages/experimental/cua-macos/src/shared-native.ts', + // The GitHub CLI runner's suite stands in for `gh` with POSIX shell + // scripts and skips on Windows: the Linux and macOS lanes hold its + // per-file 100%, while the provider suite pins its callers everywhere. + 'packages/experimental/forge-github/src/gh.ts', ] : [] From 7c586e5b4c80f045c22d14ae94e7be671626a335 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 17 Oct 2026 13:48:13 +0800 Subject: [PATCH 02/85] fix(forge): run every polling round on the forge service itself A polling round started through a caller's ctx.forge runs on that caller's cordis shadow receiver, and the loop schedule() -> setTimeout -> this.poll() -> this.fetch() -> this.schedule() wrapped one more shadow around it on every round. Within a few hundred rounds a write such as `this.timer = ...` in the tail of fetch() overflowed the stack, the rejection went unhandled, and app-boot exited the whole process with "fatal load failure". At the default intervals that is a few hours of watching a change request without activity. The timer now calls a class-field arrow function, which captures the instance at construction, so each round starts from the instance and the receiver chain no longer grows. A watch test polls 1,000 rounds through ctx.forge. --- packages/experimental/forge/src/index.ts | 15 +++++++++++---- packages/experimental/forge/tests/watch.spec.ts | 15 +++++++++++++++ 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/packages/experimental/forge/src/index.ts b/packages/experimental/forge/src/index.ts index 9ae7b38d9d..8a76e34b92 100644 --- a/packages/experimental/forge/src/index.ts +++ b/packages/experimental/forge/src/index.ts @@ -51,6 +51,16 @@ export class ForgeService extends Service { private readonly pending = new Set>() private readonly lifetime = new AbortController() private timer: ReturnType | undefined + /** + * Poll what is due. A field rather than a method, so the timer runs it on + * the instance itself: a polling round started through a caller's `ctx.forge` + * would otherwise run on that caller's shadow receiver, and every later round + * would nest one more shadow until a property write overflows the stack. + */ + private readonly tick = (): void => { + this.timer = undefined + this.poll() + } constructor(ctx: Context, config: Config) { super(ctx, 'forge') @@ -200,10 +210,7 @@ export class ForgeService extends Service { next = next === undefined ? watch.dueAt : Math.min(next, watch.dueAt) } if (next === undefined) return - this.timer = setTimeout(() => { - this.timer = undefined - this.poll() - }, Math.max(0, next - Date.now())) + this.timer = setTimeout(this.tick, Math.max(0, next - Date.now())) } private poll(): void { diff --git a/packages/experimental/forge/tests/watch.spec.ts b/packages/experimental/forge/tests/watch.spec.ts index 59ab961a02..fb783721c2 100644 --- a/packages/experimental/forge/tests/watch.spec.ts +++ b/packages/experimental/forge/tests/watch.spec.ts @@ -66,6 +66,21 @@ describe('forge watch', () => { expect(context.forge.latest(ref(2))).toBeUndefined() }) + it('keeps polling a change request watched through ctx.forge for many rounds', async () => { + // A round that ran on the previous round's receiver would nest one more shadow each time, + // and such a chain overflows the stack within a few hundred rounds. + ctx = await forgeContext({ activeIntervalMs: 1, idleIntervalMs: 1, maxBackoffMs: 1, fetchTimeoutMs: 500 }) + const provider = new FakeProvider() + provider.results.set(key, snapshot(ref(1))) + await provide(ctx, provider) + const { updates } = collect(ctx) + + for (let step = 0; step < 300 && updates.length < 1_000; step++) await vi.advanceTimersByTimeAsync(10) + + expect(updates.length).toBeGreaterThanOrEqual(1_000) + expect(updates.at(-1)).toMatchObject({ key, snapshot: snapshot(ref(1)) }) + }) + it('batches due change requests of one forge into one fetch', async () => { const provider = new FakeProvider() const context = await setup(provider) From fc43a2d507ec4ff079a02efde824cb07aa3f17a8 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 20 Oct 2026 11:13:38 +0800 Subject: [PATCH 03/85] feat(forge): add the pull request panel and worktree Sessions The forge panel shows the pull requests a Session created or pushed in a right-sidebar tab, with a Session header button and branch label, and lets a new Session start in its own Git worktree from the New Session options row. The forge bundle ships the panel with the forge registry, the GitHub provider, the Session bindings, and an isolated worktree service. The panel listens to the agent-controller/admission stage. It holds the first message of a Session with a worktree request while it creates and enters the worktree as Agent maintenance, and refuses messages while the worktree the Session works in is gone. A failed or stopped creation returns the message to the composer: the card above the composer shows the failure, sending again retries with a new branch name, and Continue without a worktree withdraws the request. A removed worktree can be checked out again or left for the source checkout. A message that enters without admission still gets its worktree at the first step proposal. --- .../feature/2026-10-09-forge-panel.i18n.yaml | 22 + .../feature/2026-10-09-forge-panel.md | 60 + .../feature/2026-10-09-forge-panel.zh.md | 60 + ...-session-change-request-bindings.i18n.yaml | 4 +- ...6-10-09-session-change-request-bindings.md | 4 +- ...0-09-session-change-request-bindings.zh.md | 4 +- apps/cli/package.json | 3 +- apps/web/package.json | 3 +- .../expected/forge-panel/panel.expected.md | 19 + .../plugin-config/official.expected.md | 4 + .../plugin-manager/live-enabled.expected.md | 4 + .../plugin-manager/manager.expected.md | 4 + .../plugin-manager/refresh.expected.md | 4 +- apps/web/tests/forge-panel.e2e.ts | 324 +++++ apps/web/tsconfig.json | 1 + docs/capability-seams.i18n.yaml | 4 +- docs/capability-seams.md | 10 +- docs/capability-seams.zh.md | 10 +- docs/config-catalog.md | 34 +- docs/config-catalog.zh.md | 34 +- docs/event-producer-consumer.md | 8 +- docs/event-producer-consumer.zh.md | 8 +- ...experimental-persistence-catalog.i18n.yaml | 4 +- docs/experimental-persistence-catalog.md | 1 + docs/experimental-persistence-catalog.zh.md | 1 + docs/module-graph.md | 17 + docs/module-graph.zh.md | 17 + docs/subsystems/forge.i18n.yaml | 11 +- docs/subsystems/forge.md | 336 ++++- docs/subsystems/forge.zh.md | 336 ++++- docs/subsystems/worktrees.i18n.yaml | 4 +- docs/subsystems/worktrees.md | 53 +- docs/subsystems/worktrees.zh.md | 53 +- packages/boot/app-boot/src/profile.ts | 1 + packages/experimental/README.i18n.yaml | 8 +- packages/experimental/README.md | 4 +- packages/experimental/README.zh.md | 4 +- .../client-ui-forge/README.i18n.yaml | 37 + .../experimental/client-ui-forge/README.md | 112 ++ .../experimental/client-ui-forge/README.zh.md | 112 ++ .../client-ui-forge/locale/en.json | 6 + .../client-ui-forge/locale/zh.json | 6 + .../experimental/client-ui-forge/package.json | 129 ++ .../src/client/ActionToast.tsx | 38 + .../src/client/BranchChip.module.css | 39 + .../client-ui-forge/src/client/BranchChip.tsx | 62 + .../src/client/ChangeRequestCard.tsx | 328 +++++ .../src/client/ForgeTab.module.css | 490 +++++++ .../client-ui-forge/src/client/ForgeTab.tsx | 176 +++ .../src/client/ForgeTabTitle.tsx | 21 + .../src/client/HeaderButton.module.css | 35 + .../src/client/HeaderButton.tsx | 67 + .../src/client/WorktreeProgress.module.css | 220 +++ .../src/client/WorktreeProgress.tsx | 210 +++ .../src/client/WorktreeToggle.module.css | 105 ++ .../src/client/WorktreeToggle.tsx | 147 ++ .../client-ui-forge/src/client/format.ts | 37 + .../client-ui-forge/src/client/index.ts | 24 + .../client-ui-forge/src/client/locales.ts | 252 ++++ .../client-ui-forge/src/client/mount.ts | 214 +++ .../client-ui-forge/src/client/source.ts | 227 +++ .../client-ui-forge/src/client/status.ts | 117 ++ .../client-ui-forge/src/css-modules.d.ts | 5 + .../experimental/client-ui-forge/src/git.ts | 228 +++ .../experimental/client-ui-forge/src/index.ts | 377 +++++ .../experimental/client-ui-forge/src/panel.ts | 336 +++++ .../experimental/client-ui-forge/src/parse.ts | 87 ++ .../experimental/client-ui-forge/src/types.ts | 219 +++ .../client-ui-forge/src/worktree.ts | 274 ++++ .../tests/branch-chip.client.spec.tsx | 112 ++ .../tests/change-request-card.client.spec.tsx | 320 +++++ .../tests/client-fixtures.client.ts | 132 ++ .../tests/components.client.spec.tsx | 171 +++ .../tests/controller.host.spec.ts | 1226 +++++++++++++++++ .../tests/forge-tab.client.spec.tsx | 295 ++++ .../tests/format.client.spec.ts | 48 + .../client-ui-forge/tests/git.host.spec.ts | 428 ++++++ .../client-ui-forge/tests/harness.ts | 541 ++++++++ .../tests/mount.client.spec.ts | 492 +++++++ .../client-ui-forge/tests/parse.host.spec.ts | 88 ++ .../tests/source.client.spec.ts | 258 ++++ .../tests/status.client.spec.ts | 131 ++ .../tests/worktree-progress.client.spec.tsx | 280 ++++ .../tests/worktree-toggle.client.spec.tsx | 298 ++++ .../tests/worktree.host.spec.ts | 501 +++++++ .../client-ui-forge/tsconfig.client.json | 72 + .../client-ui-forge/tsconfig.host.json | 69 + .../client-ui-forge/tsconfig.json | 11 + .../client-ui-forge/tsdown.config.ts | 3 + .../forge-bundle/README.i18n.yaml | 37 + packages/experimental/forge-bundle/README.md | 77 ++ .../experimental/forge-bundle/README.zh.md | 77 ++ .../forge-bundle/cordis.patch.yml | 17 + packages/experimental/forge-bundle/icon.svg | 11 + .../experimental/forge-bundle/locale/en.json | 6 + .../experimental/forge-bundle/locale/zh.json | 6 + .../experimental/forge-bundle/package.json | 53 + .../experimental/forge-bundle/src/index.ts | 2 + .../experimental/forge-bundle/tsconfig.json | 15 + .../forge-bundle/tsdown.config.ts | 3 + .../forge-github/README.i18n.yaml | 8 +- packages/experimental/forge-github/README.md | 6 +- .../experimental/forge-github/README.zh.md | 6 +- packages/experimental/forge-github/src/gh.ts | 7 +- .../experimental/forge-github/src/graphql.ts | 25 +- .../experimental/forge-github/src/provider.ts | 14 +- .../forge-github/tests/fixtures.ts | 5 + .../forge-github/tests/gh.spec.ts | 6 +- .../forge-github/tests/graphql.spec.ts | 25 +- .../forge-github/tests/provider.spec.ts | 25 + .../forge-session/README.i18n.yaml | 4 +- packages/experimental/forge-session/README.md | 2 +- .../experimental/forge-session/README.zh.md | 2 +- .../experimental/forge-session/package.json | 3 + .../experimental/forge-session/src/types.ts | 4 +- .../forge-session/tests/fixtures.ts | 9 +- .../tests/loader-composition.spec.ts | 5 +- packages/experimental/forge/README.i18n.yaml | 8 +- packages/experimental/forge/README.md | 6 +- packages/experimental/forge/README.zh.md | 6 +- packages/experimental/forge/package.json | 3 + packages/experimental/forge/src/index.ts | 26 +- packages/experimental/forge/src/types.ts | 20 + .../experimental/forge/tests/fake-provider.ts | 9 +- .../experimental/forge/tests/registry.spec.ts | 3 + .../experimental/forge/tests/watch.spec.ts | 34 + .../experimental/worktree/README.i18n.yaml | 4 +- packages/experimental/worktree/README.md | 4 +- packages/experimental/worktree/README.zh.md | 4 +- packages/experimental/worktree/src/index.ts | 158 ++- packages/experimental/worktree/src/types.ts | 29 +- .../worktree/tests/checkout-safety.spec.ts | 23 + .../worktree/tests/worktree.spec.ts | 153 +- .../src/client/slot-catalog.ts | 10 +- .../extensions/tool-cordis/src/api-catalog.ts | 157 ++- .../references/packages.md | 1 + pnpm-lock.yaml | 155 +++ scripts/gen-cordis-catalog.ts | 7 + scripts/gen-doc-graphs.ts | 12 +- scripts/optional-bundles.spec.ts | 30 + scripts/release/families.spec.ts | 2 + scripts/type-equiv.manifest.json | 85 ++ .../verify-package-readme-model-experience.ts | 2 + tsconfig.base.json | 6 + tsconfig.client.json | 1 + tsconfig.host.json | 3 + vitest.config.ts | 7 + 147 files changed, 12984 insertions(+), 135 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-10-09-forge-panel.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-10-09-forge-panel.md create mode 100644 .agents/notes/implemented/feature/2026-10-09-forge-panel.zh.md create mode 100644 apps/web/tests/expected/forge-panel/panel.expected.md create mode 100644 apps/web/tests/forge-panel.e2e.ts create mode 100644 packages/experimental/client-ui-forge/README.i18n.yaml create mode 100644 packages/experimental/client-ui-forge/README.md create mode 100644 packages/experimental/client-ui-forge/README.zh.md create mode 100644 packages/experimental/client-ui-forge/locale/en.json create mode 100644 packages/experimental/client-ui-forge/locale/zh.json create mode 100644 packages/experimental/client-ui-forge/package.json create mode 100644 packages/experimental/client-ui-forge/src/client/ActionToast.tsx create mode 100644 packages/experimental/client-ui-forge/src/client/BranchChip.module.css create mode 100644 packages/experimental/client-ui-forge/src/client/BranchChip.tsx create mode 100644 packages/experimental/client-ui-forge/src/client/ChangeRequestCard.tsx create mode 100644 packages/experimental/client-ui-forge/src/client/ForgeTab.module.css create mode 100644 packages/experimental/client-ui-forge/src/client/ForgeTab.tsx create mode 100644 packages/experimental/client-ui-forge/src/client/ForgeTabTitle.tsx create mode 100644 packages/experimental/client-ui-forge/src/client/HeaderButton.module.css create mode 100644 packages/experimental/client-ui-forge/src/client/HeaderButton.tsx create mode 100644 packages/experimental/client-ui-forge/src/client/WorktreeProgress.module.css create mode 100644 packages/experimental/client-ui-forge/src/client/WorktreeProgress.tsx create mode 100644 packages/experimental/client-ui-forge/src/client/WorktreeToggle.module.css create mode 100644 packages/experimental/client-ui-forge/src/client/WorktreeToggle.tsx create mode 100644 packages/experimental/client-ui-forge/src/client/format.ts create mode 100644 packages/experimental/client-ui-forge/src/client/index.ts create mode 100644 packages/experimental/client-ui-forge/src/client/locales.ts create mode 100644 packages/experimental/client-ui-forge/src/client/mount.ts create mode 100644 packages/experimental/client-ui-forge/src/client/source.ts create mode 100644 packages/experimental/client-ui-forge/src/client/status.ts create mode 100644 packages/experimental/client-ui-forge/src/css-modules.d.ts create mode 100644 packages/experimental/client-ui-forge/src/git.ts create mode 100644 packages/experimental/client-ui-forge/src/index.ts create mode 100644 packages/experimental/client-ui-forge/src/panel.ts create mode 100644 packages/experimental/client-ui-forge/src/parse.ts create mode 100644 packages/experimental/client-ui-forge/src/types.ts create mode 100644 packages/experimental/client-ui-forge/src/worktree.ts create mode 100644 packages/experimental/client-ui-forge/tests/branch-chip.client.spec.tsx create mode 100644 packages/experimental/client-ui-forge/tests/change-request-card.client.spec.tsx create mode 100644 packages/experimental/client-ui-forge/tests/client-fixtures.client.ts create mode 100644 packages/experimental/client-ui-forge/tests/components.client.spec.tsx create mode 100644 packages/experimental/client-ui-forge/tests/controller.host.spec.ts create mode 100644 packages/experimental/client-ui-forge/tests/forge-tab.client.spec.tsx create mode 100644 packages/experimental/client-ui-forge/tests/format.client.spec.ts create mode 100644 packages/experimental/client-ui-forge/tests/git.host.spec.ts create mode 100644 packages/experimental/client-ui-forge/tests/harness.ts create mode 100644 packages/experimental/client-ui-forge/tests/mount.client.spec.ts create mode 100644 packages/experimental/client-ui-forge/tests/parse.host.spec.ts create mode 100644 packages/experimental/client-ui-forge/tests/source.client.spec.ts create mode 100644 packages/experimental/client-ui-forge/tests/status.client.spec.ts create mode 100644 packages/experimental/client-ui-forge/tests/worktree-progress.client.spec.tsx create mode 100644 packages/experimental/client-ui-forge/tests/worktree-toggle.client.spec.tsx create mode 100644 packages/experimental/client-ui-forge/tests/worktree.host.spec.ts create mode 100644 packages/experimental/client-ui-forge/tsconfig.client.json create mode 100644 packages/experimental/client-ui-forge/tsconfig.host.json create mode 100644 packages/experimental/client-ui-forge/tsconfig.json create mode 100644 packages/experimental/client-ui-forge/tsdown.config.ts create mode 100644 packages/experimental/forge-bundle/README.i18n.yaml create mode 100644 packages/experimental/forge-bundle/README.md create mode 100644 packages/experimental/forge-bundle/README.zh.md create mode 100644 packages/experimental/forge-bundle/cordis.patch.yml create mode 100644 packages/experimental/forge-bundle/icon.svg create mode 100644 packages/experimental/forge-bundle/locale/en.json create mode 100644 packages/experimental/forge-bundle/locale/zh.json create mode 100644 packages/experimental/forge-bundle/package.json create mode 100644 packages/experimental/forge-bundle/src/index.ts create mode 100644 packages/experimental/forge-bundle/tsconfig.json create mode 100644 packages/experimental/forge-bundle/tsdown.config.ts diff --git a/.agents/notes/implemented/feature/2026-10-09-forge-panel.i18n.yaml b/.agents/notes/implemented/feature/2026-10-09-forge-panel.i18n.yaml new file mode 100644 index 0000000000..2053db1e3c --- /dev/null +++ b/.agents/notes/implemented/feature/2026-10-09-forge-panel.i18n.yaml @@ -0,0 +1,22 @@ +# Bilingual-pair consistency record for 2026-10-09-forge-panel.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-10-09-forge-panel.md +/agent-note-pull-request-panel-and-worktree-sessions: + en: 5ef5ca5d20eeff13 + zh: 7a6b13796ab8daa8 +/agent-note-pull-request-panel-and-worktree-sessions/problem: + en: afcbe41922d3476c + zh: 18291d09efbe1343 +/agent-note-pull-request-panel-and-worktree-sessions/decision: + en: d77e478797516c02 + zh: f22d12966cca6096 +/agent-note-pull-request-panel-and-worktree-sessions/deferred: + en: 715eea526e6d7905 + zh: 96151efa9cd01d0d +/agent-note-pull-request-panel-and-worktree-sessions/alternatives-considered: + en: 5d10c2928a7e76e2 + zh: 9475c60f58b1f30f +/agent-note-pull-request-panel-and-worktree-sessions/consequences: + en: 13997aee8d8bdf53 + zh: cf23447393c56555 diff --git a/.agents/notes/implemented/feature/2026-10-09-forge-panel.md b/.agents/notes/implemented/feature/2026-10-09-forge-panel.md new file mode 100644 index 0000000000..90bab12c9f --- /dev/null +++ b/.agents/notes/implemented/feature/2026-10-09-forge-panel.md @@ -0,0 +1,60 @@ +# Agent Note: Pull request panel and worktree Sessions + +Status: implemented + +English | [中文](2026-10-09-forge-panel.zh.md) + +## Problem + +[Session change request bindings](2026-10-09-session-change-request-bindings.md) give each Session a list of pull requests, but nothing shows it. A user who asks the agent to open pull requests needs their checks, reviews, and mergeability without leaving the conversation, and wants a new Session's edits and commits kept out of the current checkout. Git must run where the Session's commands run, which can be a remote or confined execution world; inspection must not change the repository, and a worktree must exist before the model's first action. + +## Decision + +[`dsh-experimental-client-ui-forge`](../../../../packages/experimental/client-ui-forge/README.md) is a two-face package. Its Host half, the `forgePanel` Remote, assembles everything the panel shows for one Session into one streamed state; the forge registry and the binding service keep only their domain operations. Its Client half is a right-sidebar tab, a Session header button that shows or hides it, a header branch label, a New Session option, a creation card above the composer, and a toast. The [forge bundle](../../../../packages/experimental/forge-bundle/README.md) ships the panel, the forge registry, the GitHub provider, the bindings, and an isolated worktree service as one default-disabled Plugins page entry. + +The Remote inspects the checkout through the Session's subprocess provider under the Session's read-only sandbox policy, so it reads what the Session's commands see and cannot write. Inspection reads refs and configuration only: the top-level and common Git directories, the branch, the upstream, and the remotes. It runs when a Session is followed, after each `working-directory/change`, `tool/result`, and `sandbox/mode` event, and on refresh, one at a time with one trailing rerun. + +Status polling follows panel subscriptions: a Session's bindings are watched only while one of its surfaces is mounted, and the registry stops polling change requests nobody watches. Each card keeps the last snapshot a poll returned, so a transient failure keeps the title and number visible. + +A new Session starts in a worktree through a request, not an immediate creation. Checking the option in the New Session row, a `conversation.hero.options` entry, appends a `plugin:client-ui-forge/worktree` record with the base branch; changing the base or clearing the option appends another, and nothing touches the repository until the user sends. The option is offered only where creation can succeed: under `workspace-write`, the checkout and its common Git directory must lie inside the writable root, which excludes a project that is itself a linked worktree. The worktree must exist before the first message enters the Session, so the message runs there and a stopped creation leaves it unsent: the panel's listener on the [`agent-controller/admission` stage](../architecture/2026-10-17-agent-controller-message-delivery.md) holds the first message while it creates and enters the worktree as Agent maintenance, then lets the message enter, and the New Session composer shows the message as sending meanwhile. The creation's stage streams to a card above the composer, where Cancel stops it and returns the message to editing. A failure also returns the message to editing, and the card keeps the failure: sending again starts a new attempt with a new branch name, and Continue without a worktree withdraws the request. An `agent/pre-step` listener still creates the worktree at the first step proposal of a Session whose request is open, because other senders reach the Agent without admission; it moves the working-directory notice into the proposed messages, and a failure ends the turn. The `forgeWorktree` projection ignores requests after the first `turn/start` and creations after the first `step/start`, so a running Session never moves. The Session header names the branch the Session works on. + +The worktree a Session works in can be removed outside it. Each inspection checks whether the created worktree's directory still exists, and admission checks again for every message of a worktree Session, refusing it while the directory is missing so the message stays in the composer. Restore checks the branch out again at the recorded directory, or creates the branch again at its base commit when it is gone; Continue in the project moves the Session to the source checkout. + +The panel no longer moves a Session between existing worktrees. Removing a pull request appends a `dismiss` record, and its toast offers Undo through `track`. + +## Deferred + +A left-navigation review page next to Plugins and Scheduled tasks, listing the user's pull requests or merge requests on the selected provider, is deferred. It attaches without changing this package's Session surfaces: + +- **Provider capability** — an optional `ForgeProvider` method such as `listForViewer(host, filter, signal)` returns change request references; GitHub implements it with `gh search prs --author @me` or the GraphQL `viewer`, and the registry aggregates enabled providers. +- **Remote** — a stream like `follow` that is keyed by provider and filter instead of a Session and polls through the same `ctx.forge.watch`. +- **Client** — a `sidebar.panellist` entry with a `main` page in this package, as `ui-schedule` does, rendering `ChangeRequestCard`, which takes a change request, its snapshot, and optional actions. +- **Settings** — provider choice and hosts on the bundle's configuration page. + +## Alternatives considered + +**A Host service package for Git inspection.** No second consumer needs checkout facts yet; a model tool can move them into a shared service when it exists. + +**Running Git as a Host process.** Remote and confined execution worlds would show the Host's files instead of the Session's, and the Host would read directories the Session cannot. + +**Creating the worktree when the option is checked.** Every toggle or base change would create and abandon a checkout, and the Session would move before the user decided to send. + +**Carrying the option in the Session creation request.** The Session controller owns a closed request; a plugin record keeps the option inside this package and survives a reload of the blank Session. + +**Switching between existing worktrees from the panel.** A switched Session keeps the sandbox root of its project, so a worktree outside the project stays read-only, and moving mid-conversation splits the model's view of the files. Starting a Session in its worktree avoids both. + +**The option in the composer tool row.** The tool row stays after the first message, when the option no longer applies; the New Session row disappears once the Session starts. The row offered only single slots, so `ui-conversation` gained the list slot `conversation.hero.options`. + +**Creating the worktree within the first turn.** The message is already accepted when the first step proposal runs, so cancelling could only drop it or queue it again, and the New Session screen is already gone. A thrown pre-step error also discards the claimed message. + +**Creating the worktree in a maintenance job when a message arrives.** The message would sit in the queue and the composer would already be cleared; holding the message at admission keeps it in the composer until the worktree exists. + +**A submit gate in the Web composer.** The browser held the message before the Host saw it, so every check needed a Remote call, the stage of a refused message lived only in the browser, and other senders bypassed it. Admission holds the message where it enters the Agent and keeps the failure in the panel state. + +**The creation card in the transcript.** Transcript nodes accept no plugin kinds; the input dock above the composer is the nearest list slot, and it renders on the New Session screen. + +**A Session-row hover section and manual URL tracking.** Both duplicated the tab. Bindings come from `gh pr create` and pushes, and `track` remains for Undo. + +## Consequences + +The panel shows current status only while it is open; checks that finish while nothing is mounted produce no notification. Changes made outside the Session appear after Refresh or the next tool result. Every inspection spawns up to five short Git processes in the Session's execution world. A worktree exists only for a Session that requested it before its first step, and a failed or cancelled attempt can leave its branch or checkout behind. Only the Web Session Remote admits messages so far; other senders create the worktree within the first turn and send into a removed worktree. Restoring loses changes that were never committed. With Git Worktrees also enabled, two independent worktree services exist; the model's tool never sees the panel's service. diff --git a/.agents/notes/implemented/feature/2026-10-09-forge-panel.zh.md b/.agents/notes/implemented/feature/2026-10-09-forge-panel.zh.md new file mode 100644 index 0000000000..e6cf1f0a4e --- /dev/null +++ b/.agents/notes/implemented/feature/2026-10-09-forge-panel.zh.md @@ -0,0 +1,60 @@ +# Agent Note: Pull request 面板与 worktree Session + +Status: implemented + +[English](2026-10-09-forge-panel.md) | 中文 + +## Problem + +[Session 变更请求绑定](2026-10-09-session-change-request-bindings.zh.md)为每个 Session 提供了 pull request 列表,但没有地方展示。让 agent 开 pull request 的用户需要在不离开对话的情况下看到它们的检查、评审与能否合并,也希望新 Session 的修改和提交不影响当前检出。Git 必须在 Session 命令运行的地方执行,这可能是远程或受限的执行环境;检查不能改动仓库,而且 worktree 必须在模型第一次动作之前就存在。 + +## Decision + +[`dsh-experimental-client-ui-forge`](../../../../packages/experimental/client-ui-forge/README.zh.md) 是一个双端包。宿主端的 `forgePanel` Remote 把面板为一个 Session 展示的全部内容组装成一条流式状态;forge 注册表与绑定服务只保留各自的领域操作。客户端包括一个右侧边栏标签页、一个显示或隐藏它的 Session 标题栏按钮、一个标题栏分支标签、一个新会话选项、一张位于输入框上方的创建卡片,以及一个 toast。[forge 套件](../../../../packages/experimental/forge-bundle/README.zh.md)把面板、forge 注册表、GitHub 提供方、绑定服务与一个隔离的 worktree 服务作为插件页中默认禁用的一个条目发布。 + +Remote 通过 Session 的子进程提供方、在 Session 的只读沙箱策略下检查检出,因此它读到的与 Session 命令看到的相同,且无法写入。检查只读取 ref 与配置:顶层目录与公共 Git 目录、分支、上游与远端。它在 Session 被关注时、每个 `working-directory/change`、`tool/result` 与 `sandbox/mode` 事件之后以及刷新时运行,一次只运行一个,最多再追加一次。 + +状态轮询跟随面板订阅:只有该 Session 的某个界面挂载时才监听其绑定,注册表停止轮询无人监听的变更请求。每张卡片保留最近一次轮询得到的快照,因此短暂的失败不会让标题与编号消失。 + +新 Session 通过请求而不是立即创建来以 worktree 开始。勾选新会话那一行中的选项(一个 `conversation.hero.options` 条目)会追加一条带基准分支的 `plugin:client-ui-forge/worktree` 记录;更换基准或取消勾选会再追加一条;用户发送之前不会改动仓库。只有创建能够成功时才提供该选项:在 `workspace-write` 下,检出及其公共 Git 目录必须位于可写根之内,这排除了本身就是链接 worktree 的项目。worktree 必须在第一条消息进入 Session 之前就存在,这样消息才会在其中运行,停止创建时消息也不会进入:面板在 [`agent-controller/admission` 阶段](../architecture/2026-10-17-agent-controller-message-delivery.zh.md)上的监听器暂留第一条消息,同时以 Agent 维护任务创建并进入 worktree,之后再放行消息;此期间新会话页的输入框把消息显示为正在发送。创建的阶段以流的形式推送给输入框上方的卡片,卡片上的“取消”停止创建并让消息回到可编辑状态。失败时消息同样回到可编辑状态,卡片保留失败信息:再次发送会用新分支名开始新的尝试,“不使用工作树”撤回请求。一个 `agent/pre-step` 监听器仍会在请求未完成的 Session 的第一个步骤提议中创建 worktree,因为其他发送方不经过接纳就能把消息交给 Agent;它把工作目录通知移入本步提议的消息,失败时这一轮结束。`forgeWorktree` 投影忽略第一个 `turn/start` 之后的请求以及第一个 `step/start` 之后的创建,因此运行中的 Session 永远不会移动。Session 标题栏显示 Session 所在的分支。 + +Session 所在的 worktree 可能在 Session 之外被删除。每次检查都会确认所创建 worktree 的目录是否还在;在 worktree 中工作的 Session 的每条消息在接纳时也会再确认一次,目录缺失时拒绝消息,让它留在输入框里。“恢复”在记录的目录重新检出分支;分支也已删除时,在其基准提交重新创建分支。“在项目目录继续”把 Session 移回源检出。 + +面板不再在现有 worktree 之间移动 Session。移除 pull request 会追加一条 `dismiss` 记录,它的 toast 通过 `track` 提供“撤销”。 + +## Deferred + +在左侧导航“插件”“自动化任务”旁边新增代码审查页、列出用户在所选提供方上的 pull request 或 merge request,这项工作推迟。它的接入不需要改动本包面向 Session 的界面: + +- **提供方能力**——`ForgeProvider` 增加一个可选方法,例如 `listForViewer(host, filter, signal)`,返回变更请求引用;GitHub 用 `gh search prs --author @me` 或 GraphQL `viewer` 实现,注册表汇总已启用的提供方。 +- **Remote**——一条类似 `follow` 的流,以提供方与筛选条件而不是 Session 为键,通过同一个 `ctx.forge.watch` 轮询。 +- **客户端**——在本包中以 `sidebar.panellist` 条目加一个 `main` 页面实现(与 `ui-schedule` 相同),渲染接收变更请求、其快照与可选操作的 `ChangeRequestCard`。 +- **设置**——在套件的配置页中选择提供方与主机。 + +## Alternatives considered + +**为 Git 检查单独建一个宿主服务包。** 目前没有第二个使用方需要检出信息;出现模型工具时,可以再把它们移入共享服务。 + +**以宿主进程运行 Git。** 远程与受限的执行环境会显示宿主的文件而不是 Session 的文件,宿主还会读取 Session 无法读取的目录。 + +**勾选时立即创建 worktree。** 每次切换勾选或更换基准都会创建并丢弃一个检出,而且用户决定发送之前 Session 就已经移动。 + +**在创建 Session 的请求中携带该选项。** 这个请求由 Session 控制器拥有,字段是封闭的;插件记录让选项留在本包之内,并且在空白 Session 重新加载后仍然保留。 + +**在面板中切换现有 worktree。** 切换后的 Session 仍保留其项目的沙箱根,因此项目之外的 worktree 依然只读;对话中途移动还会让模型对文件的认识前后割裂。让 Session 从一开始就在自己的 worktree 中运行可以同时避免这两点。 + +**把选项放在输入框工具栏。** 发出第一条消息后工具栏依然存在,而那时该选项已不再适用;新会话那一行在 Session 开始后就会消失。那一行原先只提供单值 slot,因此 `ui-conversation` 增加了列表型 slot `conversation.hero.options`。 + +**在第一轮之内创建 worktree。** 第一个步骤提议运行时,消息已经被接收,取消只能丢弃它或把它放回队列,新会话页也已经离开。在 pre-step 中抛出的错误还会丢弃已认领的消息。 + +**在消息到达时用维护任务创建 worktree。** 消息会停在队列里,输入框也已经被清空;在接纳阶段暂留消息可以让它在 worktree 建好之前留在输入框里。 + +**在 Web 输入框里设发送闸门。** 浏览器在宿主看到消息之前就拦住它,因此每次检查都要一次 Remote 调用,被拒绝消息的阶段只存在于浏览器中,其他发送方也会绕过它。接纳阶段在消息进入 Agent 的位置暂留它,并把失败保留在面板状态中。 + +**把创建卡片放进对话记录。** 对话记录的节点不接受插件定义的类型;输入框上方的停靠区是最近的列表型 slot,而且它在新会话页也会渲染。 + +**Session 行悬停分区与手动粘贴 URL 跟踪。** 二者都与标签页重复。绑定来自 `gh pr create` 与推送,`track` 保留给“撤销”使用。 + +## Consequences + +面板只在打开时显示当前状态;没有任何界面挂载时检查结束不会有通知。Session 之外的改动要等到刷新或下一个工具结果才会出现。每次检查最多在 Session 的执行环境中启动五个短暂的 Git 进程。只有在第一步之前提出请求的 Session 才会有 worktree,失败或被取消的尝试可能留下它的分支或检出。目前只有 Web Session Remote 接纳消息;其他发送方在第一轮之内创建 worktree,也会向已删除的 worktree 发送消息。恢复会丢失从未提交的修改。同时启用 Git Worktrees 时会存在两个互不影响的 worktree 服务;模型的工具看不到面板的服务。 diff --git a/.agents/notes/implemented/feature/2026-10-09-session-change-request-bindings.i18n.yaml b/.agents/notes/implemented/feature/2026-10-09-session-change-request-bindings.i18n.yaml index 877a85b4ba..98bff039af 100644 --- a/.agents/notes/implemented/feature/2026-10-09-session-change-request-bindings.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-10-09-session-change-request-bindings.i18n.yaml @@ -9,8 +9,8 @@ en: e736740546fb8219 zh: 46a16a7a633716ec /agent-note-session-change-request-bindings/decision: - en: c91bbf1c6e224e2e - zh: 8c48d97146457381 + en: f785279cfb4e8709 + zh: 32525080dc8efe93 /agent-note-session-change-request-bindings/alternatives-considered: en: 15fdaef4e432fc72 zh: 8d36c859bd89f403 diff --git a/.agents/notes/implemented/feature/2026-10-09-session-change-request-bindings.md b/.agents/notes/implemented/feature/2026-10-09-session-change-request-bindings.md index 8f466a3da1..dbedb3c3e2 100644 --- a/.agents/notes/implemented/feature/2026-10-09-session-change-request-bindings.md +++ b/.agents/notes/implemented/feature/2026-10-09-session-change-request-bindings.md @@ -12,9 +12,9 @@ A Session that opens or updates pull requests leaves them only in its shell outp The experimental forge capability seam has three roles. [`dsh-experimental-forge`](../../../../packages/experimental/forge/README.md) defines provider-neutral repository and change request references, a provider registry, and shared polling: watchers of one change request share one loop, each forge has one batched fetch in flight, and the cadence follows check, mergeability, and lifecycle state. [`dsh-experimental-forge-github`](../../../../packages/experimental/forge-github/README.md) is the first provider; it reads GitHub through the GitHub CLI on the host, so credentials stay in that tool's own store and never enter Session execution worlds. [`dsh-experimental-forge-session`](../../../../packages/experimental/forge-session/README.md) binds change requests to Sessions. -Bindings derive from the Session log instead of a separate store. A host-only projection reads `bash` and `pwsh` calls: `gh pr create` results bind the URLs they print, and `git push` results record the branches they updated. Caller actions and discovery results are `plugin:forge-session/binding` records. Because every input is a log event, fork and resume carry bindings without new persistence, and the projection rebuilds them from any checkpoint. The printed URL or ref line is the evidence, not the exit status: an existing pull request reported by a failing `gh pr create` still binds, and a partially rejected push still records the branches it updated. +Bindings derive from the Session log instead of a separate store. A Session projection reads `bash` and `pwsh` calls: `gh pr create` results bind the URLs they print, and `git push` results record the branches they updated. Caller actions and discovery results are `plugin:forge-session/binding` records. Because every input is a log event, fork and resume carry bindings without new persistence, and the projection rebuilds them from any checkpoint. The printed URL or ref line is the evidence, not the exit status: an existing pull request reported by a failing `gh pr create` still binds, and a partially rejected push still records the branches it updated. -After each push result, the service looks up open change requests for the pushed branches through the provider and records the unbound ones. A dismissed binding stays hidden from discovery and returns only through `track`. Polled status is not logged; it is process-local state that exists while a watcher displays the Session. +After each push result, the service looks up open change requests for the pushed branches through the provider and records the unbound ones. A dismissed binding stays hidden from discovery and returns only through `track`. Polled status is not logged; it is process-local state that exists while a watcher displays the Session. The projection's client view therefore carries bindings without status. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-10-09-session-change-request-bindings.zh.md b/.agents/notes/implemented/feature/2026-10-09-session-change-request-bindings.zh.md index 24d9703c4d..1748ba919b 100644 --- a/.agents/notes/implemented/feature/2026-10-09-session-change-request-bindings.zh.md +++ b/.agents/notes/implemented/feature/2026-10-09-session-change-request-bindings.zh.md @@ -12,9 +12,9 @@ Status: implemented 实验性的 forge 能力 seam 由三个角色组成。[`dsh-experimental-forge`](../../../../packages/experimental/forge/README.zh.md) 定义提供方无关的仓库与变更请求引用、提供方注册表与共享轮询:同一变更请求的监听方共享一个循环,每个 forge 同时只有一次批量读取,轮询节奏随检查、可合并性与生命周期状态变化。[`dsh-experimental-forge-github`](../../../../packages/experimental/forge-github/README.zh.md) 是第一个提供方;它通过宿主上的 GitHub CLI 读取 GitHub,因此凭据保留在该工具自己的存储中,从不进入 Session 的执行环境。[`dsh-experimental-forge-session`](../../../../packages/experimental/forge-session/README.zh.md) 把变更请求绑定到 Session。 -绑定从 Session 日志派生,而不是存放在单独的存储中。一个仅宿主侧的投影读取 `bash` 与 `pwsh` 调用:`gh pr create` 的结果绑定其输出的 URL,`git push` 的结果记录其更新的分支。调用方操作与发现结果是 `plugin:forge-session/binding` 插件记录。由于所有输入都是日志事件,fork 与恢复无需新的持久化即可带上绑定,投影也能从任意检查点重建它们。证据是输出中的 URL 或 ref 行,而不是退出状态:失败的 `gh pr create` 报告的已有 pull request 仍会绑定,部分被拒绝的推送仍会记录它已更新的分支。 +绑定从 Session 日志派生,而不是存放在单独的存储中。一个 Session 投影读取 `bash` 与 `pwsh` 调用:`gh pr create` 的结果绑定其输出的 URL,`git push` 的结果记录其更新的分支。调用方操作与发现结果是 `plugin:forge-session/binding` 插件记录。由于所有输入都是日志事件,fork 与恢复无需新的持久化即可带上绑定,投影也能从任意检查点重建它们。证据是输出中的 URL 或 ref 行,而不是退出状态:失败的 `gh pr create` 报告的已有 pull request 仍会绑定,部分被拒绝的推送仍会记录它已更新的分支。 -每次推送结果之后,服务通过提供方为被推送的分支查找打开状态的变更请求,并记录尚未绑定的那些。被隐藏的绑定不会被发现逻辑恢复,只能通过 `track` 恢复。轮询到的状态不写入日志;它是进程内状态,仅在有监听方展示该 Session 时存在。 +每次推送结果之后,服务通过提供方为被推送的分支查找打开状态的变更请求,并记录尚未绑定的那些。被隐藏的绑定不会被发现逻辑恢复,只能通过 `track` 恢复。轮询到的状态不写入日志;它是进程内状态,仅在有监听方展示该 Session 时存在。因此投影的客户端视图只带绑定,不带状态。 ## Alternatives considered diff --git a/apps/cli/package.json b/apps/cli/package.json index d7c3e78f72..b04682ffd6 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -106,7 +106,8 @@ "@deepseek-ai/dsh-experimental-session-search": "workspace:*", "@deepseek-ai/dsh-experimental-tool-worktree": "workspace:*", "@deepseek-ai/dsh-cua-bundle": "workspace:*", - "@deepseek-ai/dsh-experimental-plugin-market-bundle": "workspace:*" + "@deepseek-ai/dsh-experimental-plugin-market-bundle": "workspace:*", + "@deepseek-ai/dsh-experimental-forge-bundle": "workspace:*" }, "devDependencies": { "@deepseek-ai/dsh-skill-badge": "workspace:*", diff --git a/apps/web/package.json b/apps/web/package.json index 6e90bc5d2c..b81b1dba3d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -69,6 +69,7 @@ "@deepseek-ai/dsh-experimental-voice-input-bundle": "workspace:*", "@deepseek-ai/dsh-experimental-cot-translation-bundle": "workspace:*", "@deepseek-ai/dsh-experimental-worktree": "workspace:*", - "@deepseek-ai/dsh-experimental-plugin-market-bundle": "workspace:*" + "@deepseek-ai/dsh-experimental-plugin-market-bundle": "workspace:*", + "@deepseek-ai/dsh-experimental-forge-bundle": "workspace:*" } } diff --git a/apps/web/tests/expected/forge-panel/panel.expected.md b/apps/web/tests/expected/forge-panel/panel.expected.md new file mode 100644 index 0000000000..4b340e0efd --- /dev/null +++ b/apps/web/tests/expected/forge-panel/panel.expected.md @@ -0,0 +1,19 @@ +- text: 工作树 dsh/{{worktree}} 基于 release +- heading "本会话的 Pull Request · 1" [level=3] +- button "刷新状态" +- list: + - listitem: + - 'button "Add the pull request panel #7 feat +128 −14 1 项失败" [expanded]' + - button "查看 PR" + - button "复制 PR 链接" + - text: 状态 可供审查 · 手动跟踪 + - button "检查 1 项失败 · 1 项通过" [expanded] + - list: + - listitem: + - button "test 2 分 7 秒" + - listitem: + - button "lint 42 秒" + - text: 评审 等待评审 · 1 条未解决讨论 合并 可合并 + - separator + - button "添加到聊天" + - button "从会话中移除 PR" diff --git a/apps/web/tests/expected/plugin-config/official.expected.md b/apps/web/tests/expected/plugin-config/official.expected.md index 27c2e72887..b95862b22d 100644 --- a/apps/web/tests/expected/plugin-config/official.expected.md +++ b/apps/web/tests/expected/plugin-config/official.expected.md @@ -42,6 +42,10 @@ - button "查看 Computer Use": Computer Use - text: 目前仅支持 macOS,Windows 支持正在开发中。为已有及新建 PTC 会话加载 Computer Use。请切换到 PTC 模式来使用,并授予辅助功能和屏幕录制权限。 - switch "启用 Computer Use" + - listitem: + - button "查看 PR 与工作树": PR 与工作树 + - text: 实验性 跟踪每个会话创建的 Pull Request 及其检查与评审,新会话也可以在 Git 工作树中开始;GitHub 通过 gh 命令行访问 + - switch "启用 PR 与工作树" - heading "已安装插件" [level=3] - list: - listitem: diff --git a/apps/web/tests/expected/plugin-manager/live-enabled.expected.md b/apps/web/tests/expected/plugin-manager/live-enabled.expected.md index cfe553e2bb..a21ecf4382 100644 --- a/apps/web/tests/expected/plugin-manager/live-enabled.expected.md +++ b/apps/web/tests/expected/plugin-manager/live-enabled.expected.md @@ -42,6 +42,10 @@ - button "查看 Computer Use": Computer Use - text: 目前仅支持 macOS,Windows 支持正在开发中。为已有及新建 PTC 会话加载 Computer Use。请切换到 PTC 模式来使用,并授予辅助功能和屏幕录制权限。 - switch "启用 Computer Use" + - listitem: + - button "查看 PR 与工作树": PR 与工作树 + - text: 实验性 跟踪每个会话创建的 Pull Request 及其检查与评审,新会话也可以在 Git 工作树中开始;GitHub 通过 gh 命令行访问 + - switch "启用 PR 与工作树" - heading "已安装插件" [level=3] - list: - listitem: diff --git a/apps/web/tests/expected/plugin-manager/manager.expected.md b/apps/web/tests/expected/plugin-manager/manager.expected.md index d58ad62b03..56d7479ae9 100644 --- a/apps/web/tests/expected/plugin-manager/manager.expected.md +++ b/apps/web/tests/expected/plugin-manager/manager.expected.md @@ -42,6 +42,10 @@ - button "查看 Computer Use": Computer Use - text: 目前仅支持 macOS,Windows 支持正在开发中。为已有及新建 PTC 会话加载 Computer Use。请切换到 PTC 模式来使用,并授予辅助功能和屏幕录制权限。 - switch "启用 Computer Use" + - listitem: + - button "查看 PR 与工作树": PR 与工作树 + - text: 实验性 跟踪每个会话创建的 Pull Request 及其检查与评审,新会话也可以在 Git 工作树中开始;GitHub 通过 gh 命令行访问 + - switch "启用 PR 与工作树" - heading "已安装插件" [level=3] - list: - listitem: diff --git a/apps/web/tests/expected/plugin-manager/refresh.expected.md b/apps/web/tests/expected/plugin-manager/refresh.expected.md index 85d666fd3e..56871c9d6b 100644 --- a/apps/web/tests/expected/plugin-manager/refresh.expected.md +++ b/apps/web/tests/expected/plugin-manager/refresh.expected.md @@ -6,7 +6,7 @@ Focus at 500ms: - tooltip "刷新" failure: pending - button "刷新" [disabled] -{"busy":"true","spinners":1,"cards":12,"skeletons":0,"alerts":[],"retry":0,"toasts":[]} +{"busy":"true","spinners":1,"cards":13,"skeletons":0,"alerts":[],"retry":0,"toasts":[]} failure: settled - button "刷新" -{"busy":"false","spinners":0,"cards":12,"skeletons":0,"alerts":[],"retry":0,"toasts":["刷新失败,请重试"]} +{"busy":"false","spinners":0,"cards":13,"skeletons":0,"alerts":[],"retry":0,"toasts":["刷新失败,请重试"]} diff --git a/apps/web/tests/forge-panel.e2e.ts b/apps/web/tests/forge-panel.e2e.ts new file mode 100644 index 0000000000..71ba44fa09 --- /dev/null +++ b/apps/web/tests/forge-panel.e2e.ts @@ -0,0 +1,324 @@ +/** Worktree Sessions and the pull request panel through the real optional bundle, a Git checkout, and a scripted GitHub CLI. */ +import { execFile } from 'node:child_process' +import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { basename, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { promisify } from 'node:util' +import type {} from '@deepseek-ai/dsh-experimental-forge-session' +import { pluginRecordOf, type SessionEvent } from '@deepseek-ai/dsh-session' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { chromium, type Browser, type Page } from 'playwright' +import { expect, it, onTestFailed, onTestFinished } from 'vitest' +import { + captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, readPersistedEvents, watchConsole, + webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { connectFreshWorkspaceZh, saveFailureShot, writeComposerDraft, ZH_BROWSER_LOCALE } from './support.ts' + +const BUNDLE = fileURLToPath(new URL('../../../packages/experimental/forge-bundle', import.meta.url)) +const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/lifecycle-chrome/session.v4.jsonl', import.meta.url)) +const EXPECTED = fileURLToPath(new URL('./expected/forge-panel', import.meta.url)) +const MODE = webSnapshotMode() +const PULL = 'https://github.com/octo/demo/pull/7' +const run = promisify(execFile) + +/** + * GitHub CLI double: signed in to github.com; GraphQL serves pull request 7 of + * octo/demo with one failed and one passed check run, one unresolved thread, + * and a required review. Every invocation is appended to `calls.jsonl`. + */ +const GH = `#!/usr/bin/env node +import { appendFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +const args = process.argv.slice(2) +appendFileSync(join(dirname(fileURLToPath(import.meta.url)), 'calls.jsonl'), JSON.stringify(args) + '\\n') +if (args[0] === 'auth' && args[1] === 'status') { + process.stdout.write('github.com\\n Logged in to github.com account octo\\n') + process.exit(0) +} +if (args[0] === 'api' && args[1] === 'graphql') { + const query = args.find(arg => arg.startsWith('query=')).slice('query='.length) + const check = (name, conclusion, completedAt) => ({ + __typename: 'CheckRun', name, status: 'COMPLETED', conclusion, + detailsUrl: 'https://github.com/octo/demo/actions/runs/1', startedAt: '2026-10-09T00:00:00Z', completedAt, + checkSuite: { workflowRun: { workflow: { name: 'CI' } } }, + }) + const pull = number => number !== 7 ? null : { + number, url: 'https://github.com/octo/demo/pull/7', title: 'Add the pull request panel', state: 'OPEN', isDraft: false, + updatedAt: '2026-10-09T00:00:00Z', closedAt: null, author: { login: 'octo' }, headRefName: 'feat', headRefOid: 'a'.repeat(40), + baseRefName: 'main', additions: 128, deletions: 14, mergeable: 'MERGEABLE', reviewDecision: 'REVIEW_REQUIRED', + reviewThreads: { nodes: [{ isResolved: false }] }, + commits: { nodes: [{ commit: { statusCheckRollup: { state: 'FAILURE', contexts: { + pageInfo: { hasNextPage: false }, + nodes: [check('lint', 'SUCCESS', '2026-10-09T00:00:42Z'), check('test', 'FAILURE', '2026-10-09T00:02:07Z')], + } } } }] }, + } + const data = {} + let repository + for (const match of query.matchAll(/(r\\d+): repository\\(|(p\\d+): pullRequest\\(number: (\\d+)\\)/g)) { + if (match[1] !== undefined) data[repository = match[1]] = {} + else data[repository][match[2]] = pull(Number(match[3])) + } + process.stdout.write(JSON.stringify({ data })) + process.exit(0) +} +process.stderr.write('unexpected gh invocation\\n') +process.exit(1) +` + +async function git(cwd: string, ...args: string[]): Promise { + const { stdout } = await run('git', args, { cwd, env: { ...process.env, GIT_CONFIG_NOSYSTEM: '1', GIT_TERMINAL_PROMPT: '0' } }) + return stdout.trim() +} + +/** A Web scaffold with the bundle, a GitHub checkout of `main` and `release`, and a fresh Session whose worktree option chose `release`. */ +interface ReleaseRequest { + readonly scaffold: WebScaffold + readonly page: Page + /** Temporary directory of the GitHub CLI double and its call log. */ + readonly root: string + /** The Session's workspace, a main checkout. */ + readonly project: string + readonly tripwire: ReturnType +} + +/** + * Launch the bundle against a GitHub checkout of `main` and `release`, open a fresh Session, and choose the worktree option from `release`. + * @param failureShot - name of the screenshot saved when the test fails. + * @returns the scaffold, page, and checkout. + */ +async function requestReleaseWorktree(failureShot: string): Promise { + const resources: { scaffold?: WebScaffold; browser?: Browser } = {} + const root = await mkdtemp(join(tmpdir(), 'dsh-forge-panel-')) + onTestFinished(async () => { + const failures: unknown[] = [] + await resources.browser?.close().catch((error: unknown) => failures.push(error)) + await resources.scaffold?.close().catch((error: unknown) => failures.push(error)) + await rm(root, { recursive: true, force: true }).catch((error: unknown) => failures.push(error)) + if (failures.length > 0) throw new AggregateError(failures, 'Forge panel teardown failed') + }) + const gh = join(root, 'gh') + await writeFile(gh, GH) + await chmod(gh, 0o755) + const overlay = join(root, 'forge.patch.yml') + await writeFile(overlay, `- id: forge-github\n config: ${JSON.stringify({ ghCommand: gh })}\n`) + const scaffold = await launchWebScaffold({ + profile: { packages: [{ dir: BUNDLE, enabled: true }] }, + extraOverlayPath: overlay, + replayFixture: FIXTURE, + // The worktree notice, worktree records, and bindings are asserted separately from the borrowed conversation. + compareReplaySession: false, + }) + resources.scaffold = scaffold + + const project = join(scaffold.workspaceCwd, 'workspace') + await mkdir(project, { recursive: true }) + await git(project, 'init', '--quiet', '--initial-branch=main') + await git(project, 'config', 'user.name', 'Forge Panel') + await git(project, 'config', 'user.email', 'forge-panel@example.test') + await git(project, 'config', 'commit.gpgsign', 'false') + await writeFile(join(project, 'README.md'), 'demo\n') + await git(project, 'add', 'README.md') + await git(project, 'commit', '--quiet', '-m', 'init') + await git(project, 'remote', 'add', 'origin', 'https://github.com/octo/demo.git') + await git(project, 'branch', 'release') + await git(project, 'commit', '--quiet', '--allow-empty', '-m', 'main only') + + const browser = await chromium.launch() + resources.browser = browser + const page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE, timezoneId: 'Asia/Shanghai' }) + const tripwire = watchConsole(page) + onTestFailed(async () => { await saveFailureShot(page, failureShot) }) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await connectFreshWorkspaceZh(page, scaffold.workspaceCwd) + + const option = page.locator('[data-forge-worktree]') + await option.waitFor({ timeout: 20_000 }) + await option.getByRole('checkbox', { name: '工作树', exact: true }).check() + await option.getByRole('button', { name: '基于 main', exact: true }).click() + await page.getByRole('menuitem', { name: 'release', exact: true }).click() + await option.getByRole('button', { name: '基于 release', exact: true }).waitFor() + return { scaffold, page, root, project, tripwire } +} + +/** + * Send the first prompt of the borrowed scenario. + * @returns the prompt and the turn's settlement, which resolves to the Session id. + */ +async function sendPrompt(page: Page, scaffold: WebScaffold): Promise<{ readonly prompt: string; readonly settled: Promise }> { + const [prompt] = fixtureUserPrompts(await readFile(FIXTURE, 'utf8')) + if (prompt === undefined) throw new Error('The borrowed scenario requires a user prompt') + const settled = scaffold.whenTurnSettled() + const composer = page.locator('[data-composer-input]').first() + await writeComposerDraft(page, composer, prompt) + await composer.press('Enter') + return { prompt, settled } +} + +function worktreeRecords(events: readonly SessionEvent[]): unknown[] { + return events.flatMap((event) => { + const record = pluginRecordOf(event) + return record?.type === 'plugin:client-ui-forge/worktree' ? [record.data] : [] + }) +} + +it.skipIf(MODE === 'record')('starts a Session in a worktree before its first message, follows its pull request, and restores the removed worktree', async () => { + const { scaffold, page, root, project, tripwire } = await requestReleaseWorktree('web-e2e-forge-panel') + const { settled } = await sendPrompt(page, scaffold) + const sessionId = await settled + await page.getByText('LIGHTHOUSE', { exact: true }).waitFor() + await page.locator('[data-forge-creation]').waitFor({ state: 'detached' }) + await page.locator('[data-forge-worktree]').waitFor({ state: 'detached' }) + + const session = scaffold.ctx.sessions.get(sessionId) + if (session === undefined) throw new Error('The worktree Session must stay loaded') + const directory = scaffold.ctx.workingDirectory.get(session) + const branch = `dsh/${basename(directory)}` + expect(directory).toBe(join(project, '.agents', 'worktrees', branch)) + expect(branch).toMatch(/^dsh\/[0-9a-f]{8}$/) + expect(await git(directory, 'rev-parse', '--abbrev-ref', 'HEAD')).toBe(branch) + const baseCommit = await git(project, 'rev-parse', 'release') + expect(await git(directory, 'rev-parse', 'HEAD')).toBe(baseCommit) + const events = await readPersistedEvents(scaffold, sessionId) + expect(worktreeRecords(events)).toEqual([ + { action: 'request', from: 'main' }, + { action: 'request', from: 'release' }, + { action: 'created', worktree: { path: directory, branch, from: 'release', baseCommit, repositoryRoot: project } }, + ]) + // The worktree existed before the turn, so the first model request carried its directory notice. + const created = events.findIndex((event) => { + const record = pluginRecordOf(event) + const data = record?.type === 'plugin:client-ui-forge/worktree' ? record.data : null + return typeof data === 'object' && data !== null && 'action' in data && data.action === 'created' + }) + const turn = events.findIndex(event => event.type === 'turn/start') + const notice = events.findIndex(event => event.type === 'user/message' && event.data.source.kind === 'working-directory') + expect(created).toBeLessThan(turn) + expect(notice).toBeGreaterThan(turn) + expect(notice).toBeLessThan(events.findIndex(event => event.type === 'assistant/message')) + + const label = page.locator('[data-forge-branch]') + await expect.poll(() => label.textContent(), { timeout: 20_000 }).toBe(branch) + await label.hover() + await page.getByRole('tooltip').filter({ hasText: `工作树:${directory},基于 release 创建` }).waitFor() + await page.mouse.move(0, 0) + + // A pull request bound the way a `gh pr create` result binds one. + scaffold.ctx.forgeSession.track(session, PULL) + const header = page.locator('[data-forge-header]') + await expect.poll(() => header.getAttribute('aria-label'), { timeout: 20_000 }).toBe('本会话的 PR:有需要处理的项') + expect(await header.getAttribute('aria-pressed')).toBe('false') + await header.click() + await expect.poll(() => header.getAttribute('aria-pressed')).toBe('true') + const panel = page.locator('[data-forge-panel]') + const checkout = panel.locator('[data-forge-checkout]') + await checkout.getByText(branch, { exact: true }).waitFor({ timeout: 20_000 }) + await checkout.getByText('基于 release', { exact: true }).waitFor() + const change = panel.locator(`[data-forge-change="${PULL}"]`) + await change.getByText('Add the pull request panel', { exact: true }).waitFor({ timeout: 20_000 }) + await change.getByText('1 项失败', { exact: true }).waitFor() + await change.getByRole('button', { name: /^Add the pull request panel/ }).click() + await change.getByRole('button', { name: /^检查/ }).click() + await change.getByText('test', { exact: true }).waitFor() + await compareOrRefreshGolden(join(EXPECTED, 'panel.expected.md'), + (await captureStableAria(page, '[data-forge-panel]', scaffold.workspaceCwd)).replaceAll(branch, 'dsh/{{worktree}}'), MODE) + + const composer = page.locator('[data-composer-input]').first() + await change.getByRole('button', { name: '添加到聊天', exact: true }).click() + await expect.poll(() => composer.textContent()).toContain(PULL) + + const bindings = async () => (await readPersistedEvents(scaffold, sessionId)).flatMap((event) => { + const record = pluginRecordOf(event) + return record?.type === 'plugin:forge-session/binding' ? [record.data] : [] + }) + await change.getByRole('button', { name: '从会话中移除 PR', exact: true }).click() + await change.waitFor({ state: 'detached' }) + await panel.getByText('本会话还没有 PR', { exact: true }).waitFor() + await page.getByRole('alert').filter({ hasText: '已从会话中移除 #7' }).getByRole('button', { name: '撤销', exact: true }).click() + await change.getByText('Add the pull request panel', { exact: true }).waitFor({ timeout: 20_000 }) + await expect.poll(bindings).toEqual([{ action: 'track', url: PULL }, { action: 'dismiss', url: PULL }, { action: 'track', url: PULL }]) + + const calls = (await readFile(join(root, 'calls.jsonl'), 'utf8')).trim().split('\n').map(line => JSON.parse(line) as string[]) + expect(calls).toContainEqual(['auth', 'status', '--hostname', 'github.com']) + expect(calls.some(args => args[0] === 'api' && args[1] === 'graphql' && args.includes('--hostname'))).toBe(true) + await header.click() + await expect.poll(() => header.getAttribute('aria-pressed')).toBe('false') + + // Removing the worktree outside the Session returns the next message to the composer until the worktree is restored. + await git(project, 'worktree', 'remove', '--force', directory) + const refused = page.waitForResponse('**/api/session/prompt') + await composer.press('Enter') + const envelope = await (await refused).json() as { readonly result: unknown } + expect(envelope.result).toMatchObject({ + ok: false, error: { code: 'session/prompt-rejected', details: { reason: 'forge/worktree-missing', presented: true } }, + }) + const missing = page.locator('[data-forge-missing]') + await missing.getByText(`可以在原路径重新检出分支 ${branch};删除前未提交的修改无法找回`, { exact: true }).waitFor() + await expect.poll(() => label.getAttribute('data-missing')).toBe('') + await page.locator('[data-composer-input][contenteditable="true"]').waitFor() + expect(await composer.textContent()).toContain(PULL) + // The card presents the refusal, so the composer adds no notice of its own. + expect(await page.getByRole('alert').filter({ hasText: 'was removed' }).count()).toBe(0) + expect((await readPersistedEvents(scaffold, sessionId)).filter(event => event.type === 'turn/start')).toHaveLength(1) + await missing.getByRole('button', { name: '恢复工作树', exact: true }).click() + await missing.waitFor({ state: 'detached', timeout: 20_000 }) + await page.getByRole('alert').filter({ hasText: '工作树已恢复' }).waitFor() + expect(await git(directory, 'rev-parse', '--abbrev-ref', 'HEAD')).toBe(branch) + expect(await git(directory, 'rev-parse', 'HEAD')).toBe(baseCommit) + await expect.poll(() => label.getAttribute('data-missing')).toBeNull() + expect(await composer.textContent()).toContain(PULL) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) +}) + +it.skipIf(MODE === 'record')('returns the first message to the New Session composer when the worktree cannot be created, retries on the next send, and sends it without the worktree', async () => { + const { scaffold, page, project, tripwire } = await requestReleaseWorktree('web-e2e-forge-panel-failure') + await git(project, 'branch', '--quiet', '-D', 'release') + const { prompt, settled } = await sendPrompt(page, scaffold) + const card = page.locator('[data-forge-creation="failed"]') + const failure = card.getByRole('region', { name: '创建工作树失败', exact: true }) + await failure.getByText('原因', { exact: true }).waitFor({ timeout: 20_000 }) + const attempted = await failure.getByRole('definition').first().textContent() + expect(attempted).toMatch(/^dsh\/[0-9a-f]{8}$/) + await failure.getByText('再次发送消息即可重试', { exact: true }).waitFor() + const option = page.locator('[data-forge-worktree]') + const checkbox = option.getByRole('checkbox', { name: '工作树', exact: true }) + const composer = page.locator('[data-composer-input]').first() + await page.locator('[data-composer-input][contenteditable="true"]').waitFor() + expect(await composer.textContent()).toBe(prompt) + expect(await checkbox.isChecked()).toBe(true) + expect(await checkbox.isDisabled()).toBe(false) + await option.getByRole('button', { name: '基于 release', exact: true }).waitFor() + // The card presents the refusal, so the composer adds no notice of its own. + expect(await page.getByRole('alert').filter({ hasText: 'could not be created' }).count()).toBe(0) + + // Sending again tries again with a new branch name. + await composer.press('Enter') + await expect.poll(() => failure.getByRole('definition').first().textContent(), { timeout: 20_000 }).not.toBe(attempted) + await page.locator('[data-composer-input][contenteditable="true"]').waitFor() + expect(await composer.textContent()).toBe(prompt) + + await card.getByRole('button', { name: '不使用工作树', exact: true }).click() + await card.waitFor({ state: 'detached' }) + await expect.poll(() => checkbox.isChecked()).toBe(false) + await composer.press('Enter') + const sessionId = await settled + await page.getByText('LIGHTHOUSE', { exact: true }).waitFor() + await card.waitFor({ state: 'detached' }) + + const session = scaffold.ctx.sessions.get(sessionId) + if (session === undefined) throw new Error('The Session must stay loaded') + expect(scaffold.ctx.workingDirectory.get(session)).toBe(project) + const events = await readPersistedEvents(scaffold, sessionId) + expect(worktreeRecords(events)).toEqual([{ action: 'request', from: 'main' }, { action: 'request', from: 'release' }, { action: 'cancel' }]) + expect(events.filter(event => event.type === 'turn/start')).toHaveLength(1) + expect(events.some(event => event.type === 'user/message' && event.data.source.kind === 'working-directory')).toBe(false) + const label = page.locator('[data-forge-branch]') + await expect.poll(() => label.textContent(), { timeout: 20_000 }).toBe('main') + await label.hover() + await page.getByRole('tooltip').filter({ hasText: `当前检出:${project}` }).waitFor() + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) +}) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index df35c5e0e0..474b48f335 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -44,6 +44,7 @@ "tests/voice-input.e2e.ts", "tests/cot-translation.e2e.ts", "tests/cot-translation-paid.e2e.ts", + "tests/forge-panel.e2e.ts", "tests/voice-setup.e2e.ts", "tests/voice-download.e2e.ts", "tests/prefix-proxy.ts", diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index 9162441ce9..ac1d7bfe81 100644 --- a/docs/capability-seams.i18n.yaml +++ b/docs/capability-seams.i18n.yaml @@ -6,5 +6,5 @@ en: 41d772192075e133 zh: 5ef3da69b4de1d8a /capability-seams-and-core-services: - en: cd68131cccc623de - zh: 9c977cdc8767be10 + en: 15ba282f07026c0b + zh: 73acd9f55122ee43 diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 9002a66c72..ad09747cb5 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -33,7 +33,9 @@ flowchart LR svc_forge["ctx.forge
Code host change requests"] pkg_experimental_forge_github["experimental-forge-github"] pkg_experimental_forge_session["experimental-forge-session"] + pkg_experimental_client_ui_forge["experimental-client-ui-forge"] svc_forgeSession["ctx.forgeSession
Session change request bindings"] + svc_forgePanel["ctx.forgePanel
Forge panel Remote"] pkg_experimental_worktree["experimental-worktree"] svc_worktrees["ctx.worktrees
Git worktree creation"] pkg_experimental_tool_worktree["experimental-tool-worktree"] @@ -355,6 +357,7 @@ flowchart LR pkg_experimental_browser_use_stagehand_native --> svc_browserUse pkg_experimental_claude_code_mods --> svc_claudeCodeMods pkg_experimental_client_ui_cot_translation --> svc_cotTranslation + pkg_experimental_client_ui_forge --> svc_forgePanel pkg_experimental_computer_use_cua_driver_mcp --> svc_computerUse pkg_experimental_computer_use_cua_driver_native --> svc_computerUse pkg_experimental_forge --> svc_forge @@ -507,7 +510,9 @@ flowchart LR svc_dynamicCordisRunner --> pkg_tool_cordis svc_fileReferences --> pkg_api_session_controller svc_fileUploads --> pkg_api_session_controller + svc_forge --> pkg_experimental_client_ui_forge svc_forge --> pkg_experimental_forge_session + svc_forgeSession --> pkg_experimental_client_ui_forge svc_fs --> pkg_tool_fs svc_headlessStartup --> pkg_headless svc_hmr --> pkg_app_boot @@ -640,8 +645,9 @@ flowchart LR | `ctx.pluginMarket` | `core` | [`plugin-market`](../packages/boot/plugin-market) | - | [`client-ui-plugin-market`](../packages/client/ui-plugin-market), [`plugin-market`](../packages/boot/plugin-market) | - | Fetches and validates the index sources the user configures; installation goes through the plugin manager. | | `ctx.profileContext` | `core` | [`app-boot`](../packages/boot/app-boot) | - | [`plugin-manager`](../packages/boot/plugin-manager) | - | The dsh launcher supplies data-only profile locations and composition inputs; reload scheduling belongs to dsh-hmr. | | `ctx.connection` | `core` | [`client-connection`](../packages/client/connection) | - | [`api-gateway`](../packages/api/gateway), [`host-frontend-static`](../packages/host/frontend-static) | - | Owns browser authentication and shared HTTP request dispatch; API adapters register endpoints and streams. | -| `ctx.forge` | `seam` | [`experimental-forge`](../packages/experimental/forge) | [`experimental-forge-github`](../packages/experimental/forge-github) | [`experimental-forge-session`](../packages/experimental/forge-session) | - | Providers map remote and change request URLs, look up change requests by head branch, and read status in batches; watchers of one change request share one polling loop. | -| `ctx.forgeSession` | `service` | [`experimental-forge-session`](../packages/experimental/forge-session) | - | - | - | A host-only projection derives bindings from shell results and binding records; push results trigger head-branch discovery, and Session watchers poll bound change requests through the forge registry. | +| `ctx.forge` | `seam` | [`experimental-forge`](../packages/experimental/forge) | [`experimental-forge-github`](../packages/experimental/forge-github) | [`experimental-forge-session`](../packages/experimental/forge-session), [`experimental-client-ui-forge`](../packages/experimental/client-ui-forge) | - | Providers map remote and change request URLs, look up change requests by head branch, and read status in batches; watchers of one change request share one polling loop. | +| `ctx.forgeSession` | `service` | [`experimental-forge-session`](../packages/experimental/forge-session) | - | [`experimental-client-ui-forge`](../packages/experimental/client-ui-forge) | - | A Session projection derives bindings from shell results and binding records and publishes their URLs without status to clients; push results trigger head-branch discovery, and Session watchers poll bound change requests through the forge registry. | +| `ctx.forgePanel` | `service` | [`experimental-client-ui-forge`](../packages/experimental/client-ui-forge) | - | - | - | Streams one panel state per followed Session: a read-only Git inspection in the Session sandbox, the recognized code host and its sign-in, and the bound change requests with polled status. | | `ctx.worktrees` | `seam` | [`experimental-worktree`](../packages/experimental/worktree) | [`experimental-worktree`](../packages/experimental/worktree) | [`experimental-tool-worktree`](../packages/experimental/tool-worktree) | - | Explicit experimental creation from a pinned local commit. Existing write permissions govern checkout and shared Git metadata; the working-directory service owns the resulting Session directory. | | `ctx.workingDirectory` | `seam` | [`working-directory`](../packages/session/working-directory) | [`working-directory`](../packages/session/working-directory) | [`tool-working-directory`](../packages/session/tool-working-directory), [`tool-fs`](../packages/fs/tool-fs), [`tool-bash`](../packages/shell/tool-bash), [`tool-pwsh`](../packages/shell/tool-pwsh), [`subagent`](../packages/subagent/subagent), [`sdk-jsonrpc-server`](../packages/sdk/server) | - | One Session projection owns the effective execution directory. The filesystem validates changes; user context reports them while original metadata and write grants stay fixed. | | `ctx.mcpResources` | `seam` | [`mcp-resources`](../packages/mcp/mcp-resources) | [`mcp-client`](../packages/mcp/mcp-client) | [`mcp-resources`](../packages/mcp/mcp-resources) | - | Connection-owned providers serve shared resource tools in the calling agent scope. | diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index 8c926481d6..f75a102deb 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -35,7 +35,9 @@ flowchart LR svc_forge["ctx.forge
Code host change requests"] pkg_experimental_forge_github["experimental-forge-github"] pkg_experimental_forge_session["experimental-forge-session"] + pkg_experimental_client_ui_forge["experimental-client-ui-forge"] svc_forgeSession["ctx.forgeSession
Session change request bindings"] + svc_forgePanel["ctx.forgePanel
Forge panel Remote"] pkg_experimental_worktree["experimental-worktree"] svc_worktrees["ctx.worktrees
Git worktree creation"] pkg_experimental_tool_worktree["experimental-tool-worktree"] @@ -357,6 +359,7 @@ flowchart LR pkg_experimental_browser_use_stagehand_native --> svc_browserUse pkg_experimental_claude_code_mods --> svc_claudeCodeMods pkg_experimental_client_ui_cot_translation --> svc_cotTranslation + pkg_experimental_client_ui_forge --> svc_forgePanel pkg_experimental_computer_use_cua_driver_mcp --> svc_computerUse pkg_experimental_computer_use_cua_driver_native --> svc_computerUse pkg_experimental_forge --> svc_forge @@ -509,7 +512,9 @@ flowchart LR svc_dynamicCordisRunner --> pkg_tool_cordis svc_fileReferences --> pkg_api_session_controller svc_fileUploads --> pkg_api_session_controller + svc_forge --> pkg_experimental_client_ui_forge svc_forge --> pkg_experimental_forge_session + svc_forgeSession --> pkg_experimental_client_ui_forge svc_fs --> pkg_tool_fs svc_headlessStartup --> pkg_headless svc_hmr --> pkg_app_boot @@ -642,8 +647,9 @@ flowchart LR | `ctx.pluginMarket` | `core` | [`plugin-market`](../packages/boot/plugin-market) | - | [`client-ui-plugin-market`](../packages/client/ui-plugin-market), [`plugin-market`](../packages/boot/plugin-market) | - | 获取并校验用户配置的索引源;安装通过插件管理器完成。 | | `ctx.profileContext` | `core` | [`app-boot`](../packages/boot/app-boot) | - | [`plugin-manager`](../packages/boot/plugin-manager) | - | dsh launcher 提供纯数据形式的 profile 位置与组合输入;重载调度由 dsh-hmr 负责。 | | `ctx.connection` | `core` | [`client-connection`](../packages/client/connection) | - | [`api-gateway`](../packages/api/gateway), [`host-frontend-static`](../packages/host/frontend-static) | - | 负责浏览器认证与共享 HTTP 请求分发;API 适配器注册端点和流。 | -| `ctx.forge` | `seam` | [`experimental-forge`](../packages/experimental/forge) | [`experimental-forge-github`](../packages/experimental/forge-github) | [`experimental-forge-session`](../packages/experimental/forge-session) | - | 提供方映射远端与变更请求 URL、按 head 分支查找变更请求并批量读取状态;同一变更请求的监听方共享一个轮询循环。 | -| `ctx.forgeSession` | `service` | [`experimental-forge-session`](../packages/experimental/forge-session) | - | - | - | 仅宿主侧的投影从 shell 结果与绑定记录派生绑定;推送结果触发按 head 分支的发现,Session 监听方通过 forge 注册表轮询已绑定的变更请求。 | +| `ctx.forge` | `seam` | [`experimental-forge`](../packages/experimental/forge) | [`experimental-forge-github`](../packages/experimental/forge-github) | [`experimental-forge-session`](../packages/experimental/forge-session), [`experimental-client-ui-forge`](../packages/experimental/client-ui-forge) | - | 提供方映射远端与变更请求 URL、按 head 分支查找变更请求并批量读取状态;同一变更请求的监听方共享一个轮询循环。 | +| `ctx.forgeSession` | `service` | [`experimental-forge-session`](../packages/experimental/forge-session) | - | [`experimental-client-ui-forge`](../packages/experimental/client-ui-forge) | - | Session 投影从 shell 结果与绑定记录派生绑定,并把不含状态的 URL 发布给客户端;推送结果触发按 head 分支的发现,Session 监听方通过 forge 注册表轮询已绑定的变更请求。 | +| `ctx.forgePanel` | `service` | [`experimental-client-ui-forge`](../packages/experimental/client-ui-forge) | - | - | - | 为每个被跟随的 Session 推送一份面板状态:在 Session 沙箱中进行的只读 Git 检查、识别出的代码托管平台及其登录状态,以及带轮询状态的已绑定变更请求。 | | `ctx.worktrees` | `seam` | [`experimental-worktree`](../packages/experimental/worktree) | [`experimental-worktree`](../packages/experimental/worktree) | [`experimental-tool-worktree`](../packages/experimental/tool-worktree) | - | 显式实验组合从固定的本地提交创建工作树。检出目录与共享 Git 元数据受现有写权限约束;工作目录服务拥有创建后的 Session 目录。 | | `ctx.workingDirectory` | `seam` | [`working-directory`](../packages/session/working-directory) | [`working-directory`](../packages/session/working-directory) | [`tool-working-directory`](../packages/session/tool-working-directory), [`tool-fs`](../packages/fs/tool-fs), [`tool-bash`](../packages/shell/tool-bash), [`tool-pwsh`](../packages/shell/tool-pwsh), [`subagent`](../packages/subagent/subagent), [`sdk-jsonrpc-server`](../packages/sdk/server) | - | 一个 Session 投影拥有有效执行目录。文件系统验证变更,用户上下文报告目录信息,原始元数据与写入授权保持不变。 | | `ctx.mcpResources` | `seam` | [`mcp-resources`](../packages/mcp/mcp-resources) | [`mcp-client`](../packages/mcp/mcp-client) | [`mcp-resources`](../packages/mcp/mcp-resources) | - | 连接所有者提供的操作在调用 agent 的作用域内服务于共享资源工具。 | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 6b96f3a40e..565aef147b 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1010,6 +1010,35 @@ export interface CotTranslationPreferences { ``` + + + +## `@deepseek-ai/dsh-experimental-client-ui-forge` + +- `inject`: `forge` · `forgeSession` · `sessionProjections` · `workingDirectory` · `fs` · `subprocess` · `sandbox` · `sandboxPolicy` · `agents` +- `source`: [`packages/experimental/client-ui-forge/src/types.ts:178`](../packages/experimental/client-ui-forge/src/types.ts) + +```ts config-catalog +/** Host settings of the forge panel Remote. */ +export interface Config { + /** Git executable name or absolute path in the Session's execution world. */ + gitCommand?: string + /** Deadline in milliseconds for one Git command, including confinement. */ + gitTimeoutMs?: number + /** Termination grace period in milliseconds for each Git process. */ + graceMs?: number + /** Maximum captured bytes per Git output stream. */ + maxOutputBytes?: number + /** Deadline in milliseconds for one provider sign-in probe. */ + readinessTimeoutMs?: number + /** Most local branches listed as worktree bases. */ + maxBranches?: number + /** Prefix of the branch and checkout directory created for a Session worktree; eight random hexadecimal digits follow it. */ + worktreePrefix?: string +} +``` + + @@ -1054,7 +1083,7 @@ export type Config = CuaConfig ## `@deepseek-ai/dsh-experimental-forge` -- `source`: [`packages/experimental/forge/src/types.ts:142`](../packages/experimental/forge/src/types.ts) +- `source`: [`packages/experimental/forge/src/types.ts:162`](../packages/experimental/forge/src/types.ts) ```ts config-catalog /** Poll cadence and fetch deadline for watched change requests. */ @@ -1443,7 +1472,7 @@ export type PaidTranslationProvider = 'deepseek-account' | 'deepseek-official' ## `@deepseek-ai/dsh-experimental-worktree` - `inject`: `workingDirectory` · `fs` · `subprocess` · `sandbox` · `sandboxPolicy` -- `source`: [`packages/experimental/worktree/src/index.ts:19`](../packages/experimental/worktree/src/index.ts) +- `source`: [`packages/experimental/worktree/src/index.ts:20`](../packages/experimental/worktree/src/index.ts) ```ts config-catalog /** Worktree location, generated names, executables, and subprocess resource limits. */ @@ -4919,6 +4948,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. | `@deepseek-ai/dsh-experimental-badge-skill-bundle` | — | [`packages/experimental/badge-skill-bundle/src/index.ts`](../packages/experimental/badge-skill-bundle/src/index.ts) | | `@deepseek-ai/dsh-experimental-browser-use-runtime` | — | [`packages/experimental/browser-use-runtime/src/index.ts`](../packages/experimental/browser-use-runtime/src/index.ts) | | `@deepseek-ai/dsh-experimental-cot-translation-bundle` | — | [`packages/experimental/cot-translation-bundle/src/index.ts`](../packages/experimental/cot-translation-bundle/src/index.ts) | +| `@deepseek-ai/dsh-experimental-forge-bundle` | — | [`packages/experimental/forge-bundle/src/index.ts`](../packages/experimental/forge-bundle/src/index.ts) | | `@deepseek-ai/dsh-experimental-inspector-profile` | — | [`packages/experimental/inspector-profile/src/index.ts`](../packages/experimental/inspector-profile/src/index.ts) | | `@deepseek-ai/dsh-experimental-plugin-market-bundle` | — | [`packages/experimental/plugin-market-bundle/src/index.ts`](../packages/experimental/plugin-market-bundle/src/index.ts) | | `@deepseek-ai/dsh-experimental-ralph-bundle` | — | [`packages/experimental/ralph-bundle/src/index.ts`](../packages/experimental/ralph-bundle/src/index.ts) | diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 7bb196013a..f6029f247f 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -1012,6 +1012,35 @@ export interface CotTranslationPreferences { ``` + + + +## `@deepseek-ai/dsh-experimental-client-ui-forge` + +- `inject`: `forge` · `forgeSession` · `sessionProjections` · `workingDirectory` · `fs` · `subprocess` · `sandbox` · `sandboxPolicy` · `agents` +- `source`: [`packages/experimental/client-ui-forge/src/types.ts:178`](../packages/experimental/client-ui-forge/src/types.ts) + +```ts config-catalog +/** Host settings of the forge panel Remote. */ +export interface Config { + /** Git executable name or absolute path in the Session's execution world. */ + gitCommand?: string + /** Deadline in milliseconds for one Git command, including confinement. */ + gitTimeoutMs?: number + /** Termination grace period in milliseconds for each Git process. */ + graceMs?: number + /** Maximum captured bytes per Git output stream. */ + maxOutputBytes?: number + /** Deadline in milliseconds for one provider sign-in probe. */ + readinessTimeoutMs?: number + /** Most local branches listed as worktree bases. */ + maxBranches?: number + /** Prefix of the branch and checkout directory created for a Session worktree; eight random hexadecimal digits follow it. */ + worktreePrefix?: string +} +``` + + @@ -1056,7 +1085,7 @@ export type Config = CuaConfig ## `@deepseek-ai/dsh-experimental-forge` -- `source`: [`packages/experimental/forge/src/types.ts:142`](../packages/experimental/forge/src/types.ts) +- `source`: [`packages/experimental/forge/src/types.ts:162`](../packages/experimental/forge/src/types.ts) ```ts config-catalog /** Poll cadence and fetch deadline for watched change requests. */ @@ -1445,7 +1474,7 @@ export type PaidTranslationProvider = 'deepseek-account' | 'deepseek-official' ## `@deepseek-ai/dsh-experimental-worktree` - `inject`: `workingDirectory` · `fs` · `subprocess` · `sandbox` · `sandboxPolicy` -- `source`: [`packages/experimental/worktree/src/index.ts:19`](../packages/experimental/worktree/src/index.ts) +- `source`: [`packages/experimental/worktree/src/index.ts:20`](../packages/experimental/worktree/src/index.ts) ```ts config-catalog /** Worktree location, generated names, executables, and subprocess resource limits. */ @@ -4921,6 +4950,7 @@ export interface Config { | `@deepseek-ai/dsh-experimental-badge-skill-bundle` | — | [`packages/experimental/badge-skill-bundle/src/index.ts`](../packages/experimental/badge-skill-bundle/src/index.ts) | | `@deepseek-ai/dsh-experimental-browser-use-runtime` | — | [`packages/experimental/browser-use-runtime/src/index.ts`](../packages/experimental/browser-use-runtime/src/index.ts) | | `@deepseek-ai/dsh-experimental-cot-translation-bundle` | — | [`packages/experimental/cot-translation-bundle/src/index.ts`](../packages/experimental/cot-translation-bundle/src/index.ts) | +| `@deepseek-ai/dsh-experimental-forge-bundle` | — | [`packages/experimental/forge-bundle/src/index.ts`](../packages/experimental/forge-bundle/src/index.ts) | | `@deepseek-ai/dsh-experimental-inspector-profile` | — | [`packages/experimental/inspector-profile/src/index.ts`](../packages/experimental/inspector-profile/src/index.ts) | | `@deepseek-ai/dsh-experimental-plugin-market-bundle` | — | [`packages/experimental/plugin-market-bundle/src/index.ts`](../packages/experimental/plugin-market-bundle/src/index.ts) | | `@deepseek-ai/dsh-experimental-ralph-bundle` | — | [`packages/experimental/ralph-bundle/src/index.ts`](../packages/experimental/ralph-bundle/src/index.ts) | diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 1d6e8423b4..cab9074f0a 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -8,7 +8,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | -| `agent-controller/admission` | `waterfall` | [`packages/core/agent-controller/src/types.ts:79`](../packages/core/agent-controller/src/types.ts) | [`agent-controller`](../packages/core/agent-controller) (`waterfall`) | - | +| `agent-controller/admission` | `waterfall` | [`packages/core/agent-controller/src/types.ts:79`](../packages/core/agent-controller/src/types.ts) | [`agent-controller`](../packages/core/agent-controller) (`waterfall`) | `client-ui-forge` | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:196`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | | `agent-preset/selected` | `emit` | [`packages/preset/agent-preset-registry/src/types.ts:66`](../packages/preset/agent-preset-registry/src/types.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`) | [`cua`](../packages/experimental/cua), `remotes` | | `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:363`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` | @@ -18,7 +18,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:299`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs), [`user-questions`](../packages/interaction/user-questions) | | `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:307`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`user-questions`](../packages/interaction/user-questions) | | `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:288`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) | -| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:320`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `session-controller`, [`session-reference`](../packages/context/session-reference), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill) | +| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:320`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, `client-ui-forge`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `session-controller`, [`session-reference`](../packages/context/session-reference), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill) | | `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:337`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`compaction-basic`](../packages/compaction/compaction-basic), [`webhook`](../packages/experimental/webhook) | | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:353`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:280`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller` | @@ -66,7 +66,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:44`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | | `session/created` | `emit` | [`packages/core/session/src/index.ts:54`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`permission-presets`](../packages/interaction/permission-presets), [`schedule`](../packages/schedule/schedule), `server`, `session-controller`, [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:63`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:74`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `agent-team`, `claude-code-mods`, [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, `forge-session`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`loader-smoke`](../packages/test-support/loader-smoke), `product-analytics`, [`ptc-runtime-node`](../packages/ptc-runtime/ptc-runtime-node), `server`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-questions`](../packages/interaction/user-questions), [`workspace-changes`](../packages/deliverables/workspace-changes) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:74`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `agent-team`, `claude-code-mods`, `client-ui-forge`, [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, `forge-session`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`loader-smoke`](../packages/test-support/loader-smoke), `product-analytics`, [`ptc-runtime-node`](../packages/ptc-runtime/ptc-runtime-node), `server`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-questions`](../packages/interaction/user-questions), [`workspace-changes`](../packages/deliverables/workspace-changes) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:82`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) | | `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:75`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`emit`) | `remotes` | | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:296`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - | @@ -102,7 +102,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `internal/config` | [`config-editor`](../packages/boot/config-editor) (`waterfall`) | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | | `internal/dispatch` | - | [`terminal-bash`](../packages/terminal/terminal-bash) | | `internal/plugin` | - | `computer-use-cua-driver-native`, `inspector`, `loader`, [`lsp-stdio`](../packages/lsp/lsp-stdio), [`mcp-client`](../packages/mcp/mcp-client), `modules` | -| `internal/service` | - | `gateway` | +| `internal/service` | - | `client-ui-forge`, `gateway` | | `internal/status` | - | [`agent`](../packages/core/agent), `inspector`, `translator`, [`web`](../packages/web/web) | | `internal/update` | - | [`app-boot`](../packages/boot/app-boot) | | `loader/volatile-update` | - | [`cua`](../packages/experimental/cua), [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`plugin-market`](../packages/boot/plugin-market), `product-analytics`, `speech-to-text` | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 7faa56c644..53d25df43c 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -10,7 +10,7 @@ | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | -| `agent-controller/admission` | `waterfall` | [`packages/core/agent-controller/src/types.ts:79`](../packages/core/agent-controller/src/types.ts) | [`agent-controller`](../packages/core/agent-controller) (`waterfall`) | - | +| `agent-controller/admission` | `waterfall` | [`packages/core/agent-controller/src/types.ts:79`](../packages/core/agent-controller/src/types.ts) | [`agent-controller`](../packages/core/agent-controller) (`waterfall`) | `client-ui-forge` | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:196`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | | `agent-preset/selected` | `emit` | [`packages/preset/agent-preset-registry/src/types.ts:66`](../packages/preset/agent-preset-registry/src/types.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`) | [`cua`](../packages/experimental/cua), `remotes` | | `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:363`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` | @@ -20,7 +20,7 @@ | `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:299`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs), [`user-questions`](../packages/interaction/user-questions) | | `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:307`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`user-questions`](../packages/interaction/user-questions) | | `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:288`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) | -| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:320`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `session-controller`, [`session-reference`](../packages/context/session-reference), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill) | +| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:320`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, `client-ui-forge`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `session-controller`, [`session-reference`](../packages/context/session-reference), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill) | | `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:337`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`compaction-basic`](../packages/compaction/compaction-basic), [`webhook`](../packages/experimental/webhook) | | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:353`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:280`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller` | @@ -68,7 +68,7 @@ | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:44`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | | `session/created` | `emit` | [`packages/core/session/src/index.ts:54`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`permission-presets`](../packages/interaction/permission-presets), [`schedule`](../packages/schedule/schedule), `server`, `session-controller`, [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:63`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:74`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `agent-team`, `claude-code-mods`, [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, `forge-session`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`loader-smoke`](../packages/test-support/loader-smoke), `product-analytics`, [`ptc-runtime-node`](../packages/ptc-runtime/ptc-runtime-node), `server`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-questions`](../packages/interaction/user-questions), [`workspace-changes`](../packages/deliverables/workspace-changes) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:74`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `agent-team`, `claude-code-mods`, `client-ui-forge`, [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, `forge-session`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`loader-smoke`](../packages/test-support/loader-smoke), `product-analytics`, [`ptc-runtime-node`](../packages/ptc-runtime/ptc-runtime-node), `server`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-questions`](../packages/interaction/user-questions), [`workspace-changes`](../packages/deliverables/workspace-changes) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:82`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) | | `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:75`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`emit`) | `remotes` | | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:296`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - | @@ -104,7 +104,7 @@ | `internal/config` | [`config-editor`](../packages/boot/config-editor) (`waterfall`) | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | | `internal/dispatch` | - | [`terminal-bash`](../packages/terminal/terminal-bash) | | `internal/plugin` | - | `computer-use-cua-driver-native`, `inspector`, `loader`, [`lsp-stdio`](../packages/lsp/lsp-stdio), [`mcp-client`](../packages/mcp/mcp-client), `modules` | -| `internal/service` | - | `gateway` | +| `internal/service` | - | `client-ui-forge`, `gateway` | | `internal/status` | - | [`agent`](../packages/core/agent), `inspector`, `translator`, [`web`](../packages/web/web) | | `internal/update` | - | [`app-boot`](../packages/boot/app-boot) | | `loader/volatile-update` | - | [`cua`](../packages/experimental/cua), [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`plugin-market`](../packages/boot/plugin-market), `product-analytics`, `speech-to-text` | diff --git a/docs/experimental-persistence-catalog.i18n.yaml b/docs/experimental-persistence-catalog.i18n.yaml index 1511a6adc2..4d30b1d272 100644 --- a/docs/experimental-persistence-catalog.i18n.yaml +++ b/docs/experimental-persistence-catalog.i18n.yaml @@ -6,5 +6,5 @@ en: a11f106978c9dce7 zh: a11f106978c9dce7 /experimental-plugin-record-catalog: - en: 90e85bc15ccdeb75 - zh: 3fd9e6d80b2bc689 + en: 3df60f36caaca677 + zh: e587f87d2a0dbc48 diff --git a/docs/experimental-persistence-catalog.md b/docs/experimental-persistence-catalog.md index 245685d8e4..2b43495e23 100644 --- a/docs/experimental-persistence-catalog.md +++ b/docs/experimental-persistence-catalog.md @@ -13,6 +13,7 @@ Shared Session event declarations and resolved schemas appear in the [Session pe | Record | Owner | Purpose | Payload and source | |---|---|---|---| +| `plugin:client-ui-forge/worktree` | `@deepseek-ai/dsh-experimental-client-ui-forge` | A request, withdrawal, or creation of the worktree a Session starts in. | WorktreeRecord · [`packages/experimental/client-ui-forge/src/types.ts`](../packages/experimental/client-ui-forge/src/types.ts) | | `plugin:forge-session/binding` | `@deepseek-ai/dsh-experimental-forge-session` | A caller or discovery action that binds, restores, or hides one change request. | BindingRecord · [`packages/experimental/forge-session/src/types.ts`](../packages/experimental/forge-session/src/types.ts) | | `plugin:translator/request` | `@deepseek-ai/dsh-experimental-translator` | Provider-independent translation input, retained before dispatch. | TranslationRequestRecord · [`packages/experimental/translator/src/types.ts`](../packages/experimental/translator/src/types.ts) | | `plugin:translator/result` | `@deepseek-ai/dsh-experimental-translator` | Successful translation, retained before it is returned to the consumer. | TranslationResultRecord · [`packages/experimental/translator/src/types.ts`](../packages/experimental/translator/src/types.ts) | diff --git a/docs/experimental-persistence-catalog.zh.md b/docs/experimental-persistence-catalog.zh.md index d9a22569e6..34f49c7195 100644 --- a/docs/experimental-persistence-catalog.zh.md +++ b/docs/experimental-persistence-catalog.zh.md @@ -13,6 +13,7 @@ | 记录 | 所属包 | 用途 | 载荷与源码 | |---|---|---|---| +| `plugin:client-ui-forge/worktree` | `@deepseek-ai/dsh-experimental-client-ui-forge` | A request, withdrawal, or creation of the worktree a Session starts in. | WorktreeRecord · [`packages/experimental/client-ui-forge/src/types.ts`](../packages/experimental/client-ui-forge/src/types.ts) | | `plugin:forge-session/binding` | `@deepseek-ai/dsh-experimental-forge-session` | A caller or discovery action that binds, restores, or hides one change request. | BindingRecord · [`packages/experimental/forge-session/src/types.ts`](../packages/experimental/forge-session/src/types.ts) | | `plugin:translator/request` | `@deepseek-ai/dsh-experimental-translator` | Provider-independent translation input, retained before dispatch. | TranslationRequestRecord · [`packages/experimental/translator/src/types.ts`](../packages/experimental/translator/src/types.ts) | | `plugin:translator/result` | `@deepseek-ai/dsh-experimental-translator` | Successful translation, retained before it is returned to the consumer. | TranslationResultRecord · [`packages/experimental/translator/src/types.ts`](../packages/experimental/translator/src/types.ts) | diff --git a/docs/module-graph.md b/docs/module-graph.md index 6e6a0c340e..2f115877fe 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -263,12 +263,14 @@ flowchart TD pkg_experimental_client_ui_agent_team["experimental-client-ui-agent-team"] pkg_experimental_client_ui_claude_code_mods["experimental-client-ui-claude-code-mods"] pkg_experimental_client_ui_cot_translation["experimental-client-ui-cot-translation"] + pkg_experimental_client_ui_forge["experimental-client-ui-forge"] pkg_experimental_client_ui_voice_input["experimental-client-ui-voice-input"] pkg_experimental_computer_use_cua_driver_mcp["experimental-computer-use-cua-driver-mcp"] pkg_experimental_computer_use_cua_driver_native["experimental-computer-use-cua-driver-native"] pkg_experimental_cot_translation_bundle["experimental-cot-translation-bundle"] pkg_experimental_cua_macos["experimental-cua-macos"] pkg_experimental_forge["experimental-forge"] + pkg_experimental_forge_bundle["experimental-forge-bundle"] pkg_experimental_forge_github["experimental-forge-github"] pkg_experimental_forge_session["experimental-forge-session"] pkg_experimental_inspector["experimental-inspector"] @@ -933,6 +935,19 @@ flowchart TD pkg_experimental_browser_use_stagehand_native --> pkg_browser_use pkg_experimental_browser_use_stagehand_native --> pkg_system_prompt pkg_experimental_browser_use_stagehand_native --> pkg_tools + pkg_experimental_client_ui_forge --> pkg_agent + pkg_experimental_client_ui_forge --> pkg_agent_controller + pkg_experimental_client_ui_forge --> pkg_experimental_forge + pkg_experimental_client_ui_forge --> pkg_experimental_forge_session + pkg_experimental_client_ui_forge --> pkg_experimental_worktree + pkg_experimental_client_ui_forge --> pkg_fs + pkg_experimental_client_ui_forge --> pkg_sandbox + pkg_experimental_client_ui_forge --> pkg_sandbox_policy + pkg_experimental_client_ui_forge --> pkg_session + pkg_experimental_client_ui_forge --> pkg_session_projection + pkg_experimental_client_ui_forge --> pkg_subprocess + pkg_experimental_client_ui_forge --> pkg_typert_protocol + pkg_experimental_client_ui_forge --> pkg_working_directory pkg_experimental_computer_use_cua_driver_native --> pkg_computer_use pkg_experimental_computer_use_cua_driver_native --> pkg_system_prompt pkg_experimental_computer_use_cua_driver_native --> pkg_tools @@ -1554,6 +1569,7 @@ flowchart TD | [`experimental-client-ui-voice-input`](../packages/experimental/client-ui-voice-input) | `experimental` | — | | [`experimental-cot-translation-bundle`](../packages/experimental/cot-translation-bundle) | `experimental` | — | | [`experimental-forge`](../packages/experimental/forge) | `experimental` | — | +| [`experimental-forge-bundle`](../packages/experimental/forge-bundle) | `experimental` | — | | [`experimental-inspector`](../packages/experimental/inspector) | `experimental` | — | | [`experimental-inspector-profile`](../packages/experimental/inspector-profile) | `experimental` | — | | [`experimental-plugin-market-bundle`](../packages/experimental/plugin-market-bundle) | `experimental` | — | @@ -1719,6 +1735,7 @@ flowchart TD | [`experimental-browser-use-chrome-devtools-mcp`](../packages/experimental/browser-use-chrome-devtools-mcp) | `experimental` | [`agent`](../packages/core/agent), [`browser-use`](../packages/browser-use/browser-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`experimental-browser-use-playwright-mcp`](../packages/experimental/browser-use-playwright-mcp) | `experimental` | [`agent`](../packages/core/agent), [`browser-use`](../packages/browser-use/browser-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`experimental-browser-use-stagehand-native`](../packages/experimental/browser-use-stagehand-native) | `experimental` | [`agent`](../packages/core/agent), [`browser-use`](../packages/browser-use/browser-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`experimental-client-ui-forge`](../packages/experimental/client-ui-forge) | `experimental` | [`agent`](../packages/core/agent), [`agent-controller`](../packages/core/agent-controller), [`experimental-forge`](../packages/experimental/forge), [`experimental-forge-session`](../packages/experimental/forge-session), [`experimental-worktree`](../packages/experimental/worktree), [`fs`](../packages/fs/fs), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subprocess`](../packages/subprocess/subprocess), [`typert-protocol`](../packages/typert/protocol), [`working-directory`](../packages/session/working-directory) | | [`experimental-computer-use-cua-driver-native`](../packages/experimental/computer-use-cua-driver-native) | `experimental` | [`computer-use`](../packages/computer-use/computer-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`experimental-cua-macos`](../packages/experimental/cua-macos) | `experimental` | [`agent`](../packages/core/agent), [`computer-use`](../packages/computer-use/computer-use), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-questions`](../packages/interaction/user-questions) | | [`experimental-tool-worktree`](../packages/experimental/tool-worktree) | `experimental` | [`experimental-worktree`](../packages/experimental/worktree), [`tools`](../packages/core/tools) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 8430790bdf..e569e3df31 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -265,12 +265,14 @@ flowchart TD pkg_experimental_client_ui_agent_team["experimental-client-ui-agent-team"] pkg_experimental_client_ui_claude_code_mods["experimental-client-ui-claude-code-mods"] pkg_experimental_client_ui_cot_translation["experimental-client-ui-cot-translation"] + pkg_experimental_client_ui_forge["experimental-client-ui-forge"] pkg_experimental_client_ui_voice_input["experimental-client-ui-voice-input"] pkg_experimental_computer_use_cua_driver_mcp["experimental-computer-use-cua-driver-mcp"] pkg_experimental_computer_use_cua_driver_native["experimental-computer-use-cua-driver-native"] pkg_experimental_cot_translation_bundle["experimental-cot-translation-bundle"] pkg_experimental_cua_macos["experimental-cua-macos"] pkg_experimental_forge["experimental-forge"] + pkg_experimental_forge_bundle["experimental-forge-bundle"] pkg_experimental_forge_github["experimental-forge-github"] pkg_experimental_forge_session["experimental-forge-session"] pkg_experimental_inspector["experimental-inspector"] @@ -935,6 +937,19 @@ flowchart TD pkg_experimental_browser_use_stagehand_native --> pkg_browser_use pkg_experimental_browser_use_stagehand_native --> pkg_system_prompt pkg_experimental_browser_use_stagehand_native --> pkg_tools + pkg_experimental_client_ui_forge --> pkg_agent + pkg_experimental_client_ui_forge --> pkg_agent_controller + pkg_experimental_client_ui_forge --> pkg_experimental_forge + pkg_experimental_client_ui_forge --> pkg_experimental_forge_session + pkg_experimental_client_ui_forge --> pkg_experimental_worktree + pkg_experimental_client_ui_forge --> pkg_fs + pkg_experimental_client_ui_forge --> pkg_sandbox + pkg_experimental_client_ui_forge --> pkg_sandbox_policy + pkg_experimental_client_ui_forge --> pkg_session + pkg_experimental_client_ui_forge --> pkg_session_projection + pkg_experimental_client_ui_forge --> pkg_subprocess + pkg_experimental_client_ui_forge --> pkg_typert_protocol + pkg_experimental_client_ui_forge --> pkg_working_directory pkg_experimental_computer_use_cua_driver_native --> pkg_computer_use pkg_experimental_computer_use_cua_driver_native --> pkg_system_prompt pkg_experimental_computer_use_cua_driver_native --> pkg_tools @@ -1556,6 +1571,7 @@ flowchart TD | [`experimental-client-ui-voice-input`](../packages/experimental/client-ui-voice-input) | `experimental` | — | | [`experimental-cot-translation-bundle`](../packages/experimental/cot-translation-bundle) | `experimental` | — | | [`experimental-forge`](../packages/experimental/forge) | `experimental` | — | +| [`experimental-forge-bundle`](../packages/experimental/forge-bundle) | `experimental` | — | | [`experimental-inspector`](../packages/experimental/inspector) | `experimental` | — | | [`experimental-inspector-profile`](../packages/experimental/inspector-profile) | `experimental` | — | | [`experimental-plugin-market-bundle`](../packages/experimental/plugin-market-bundle) | `experimental` | — | @@ -1721,6 +1737,7 @@ flowchart TD | [`experimental-browser-use-chrome-devtools-mcp`](../packages/experimental/browser-use-chrome-devtools-mcp) | `experimental` | [`agent`](../packages/core/agent), [`browser-use`](../packages/browser-use/browser-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`experimental-browser-use-playwright-mcp`](../packages/experimental/browser-use-playwright-mcp) | `experimental` | [`agent`](../packages/core/agent), [`browser-use`](../packages/browser-use/browser-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`experimental-browser-use-stagehand-native`](../packages/experimental/browser-use-stagehand-native) | `experimental` | [`agent`](../packages/core/agent), [`browser-use`](../packages/browser-use/browser-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`experimental-client-ui-forge`](../packages/experimental/client-ui-forge) | `experimental` | [`agent`](../packages/core/agent), [`agent-controller`](../packages/core/agent-controller), [`experimental-forge`](../packages/experimental/forge), [`experimental-forge-session`](../packages/experimental/forge-session), [`experimental-worktree`](../packages/experimental/worktree), [`fs`](../packages/fs/fs), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subprocess`](../packages/subprocess/subprocess), [`typert-protocol`](../packages/typert/protocol), [`working-directory`](../packages/session/working-directory) | | [`experimental-computer-use-cua-driver-native`](../packages/experimental/computer-use-cua-driver-native) | `experimental` | [`computer-use`](../packages/computer-use/computer-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`experimental-cua-macos`](../packages/experimental/cua-macos) | `experimental` | [`agent`](../packages/core/agent), [`computer-use`](../packages/computer-use/computer-use), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-questions`](../packages/interaction/user-questions) | | [`experimental-tool-worktree`](../packages/experimental/tool-worktree) | `experimental` | [`experimental-worktree`](../packages/experimental/worktree), [`tools`](../packages/core/tools) | diff --git a/docs/subsystems/forge.i18n.yaml b/docs/subsystems/forge.i18n.yaml index 0555d82c9f..55eaa139b9 100644 --- a/docs/subsystems/forge.i18n.yaml +++ b/docs/subsystems/forge.i18n.yaml @@ -3,8 +3,8 @@ # After editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/forge.md /forges: - en: 416233808b0653c3 - zh: 22fbac4b46e741ad + en: 1b51f9d385dafe76 + zh: 4bb73627aa69d652 /forges/providers-and-change-requests: en: 4ec7d1892b4067db zh: 6bb70d21ae3d7a89 @@ -12,5 +12,8 @@ en: 26ccfa2e342e3e67 zh: fe6cc2a622757c48 /forges/session-bindings: - en: 036a714f103fafd8 - zh: aa11325be880025d + en: ad28bdce5c5d4d49 + zh: 1acd042350634970 +/forges/panel: + en: 76bc9afcf36e3df2 + zh: 673b61562b150c95 diff --git a/docs/subsystems/forge.md b/docs/subsystems/forge.md index 8dc217841d..c66b1f5cd1 100644 --- a/docs/subsystems/forge.md +++ b/docs/subsystems/forge.md @@ -2,7 +2,7 @@ English | [中文](forge.zh.md) -The experimental forge capability seam reads change requests, meaning pull requests and merge requests, from code hosts. [`dsh-experimental-forge`](../../packages/experimental/forge/README.md) is the Service Definition and provides `ctx.forge`; [`dsh-experimental-forge-github`](../../packages/experimental/forge-github/README.md) is the Service Provider for GitHub hosts; [`dsh-experimental-forge-session`](../../packages/experimental/forge-session/README.md) is the Consumer that binds change requests to Sessions and provides `ctx.forgeSession`. All three load only through explicit composition. +The experimental forge capability seam reads change requests, meaning pull requests and merge requests, from code hosts. [`dsh-experimental-forge`](../../packages/experimental/forge/README.md) is the Service Definition and provides `ctx.forge`; [`dsh-experimental-forge-github`](../../packages/experimental/forge-github/README.md) is the Service Provider for GitHub hosts; [`dsh-experimental-forge-session`](../../packages/experimental/forge-session/README.md) is the Consumer that binds change requests to Sessions and provides `ctx.forgeSession`; [`dsh-experimental-client-ui-forge`](../../packages/experimental/client-ui-forge/README.md) is the GUI Consumer that provides `ctx.forgePanel`. The default-disabled [forge bundle](../../packages/experimental/forge-bundle/README.md) composes all four, with an isolated [worktree service](../../packages/experimental/worktree/README.md) for the panel, from the Plugins page; otherwise they load only through explicit composition. ## Providers and change requests @@ -53,6 +53,18 @@ interface ForgeProvider { ): Promise> /** Find open change requests whose head is `branch` in `repository`, excluding the default branch and forks. */ findByHead(repository: ForgeRepository, branch: string, signal: AbortSignal): Promise + /** Report whether the provider can reach `host` with current credentials. */ + readiness(host: string, signal: AbortSignal): Promise +} +``` + +```ts type-equiv +/** Whether a provider can reach one host with the current credentials. */ +interface ForgeReadiness { + /** `ready`, `unauthenticated` (sign-in needed), or `unavailable` (tool missing or host unreachable). */ + readonly state: 'ready' | 'unauthenticated' | 'unavailable' + /** Human-readable detail, or null when ready. */ + readonly message: string | null } ``` @@ -81,6 +93,10 @@ interface ChangeRequestSnapshot { readonly headSha: string /** Branch the change request merges into. */ readonly baseBranch: string + /** Lines the change request adds. */ + readonly additions: number + /** Lines the change request deletes. */ + readonly deletions: number /** Merge conflict status; `unknown` while the host computes it. */ readonly mergeability: Mergeability /** Aggregate review outcome. */ @@ -95,6 +111,8 @@ interface ChangeRequestSnapshot { readonly unresolvedThreads: number /** ISO 8601 time of the host's last update to the change request. */ readonly updatedAt: string + /** ISO 8601 time the change request was merged or closed, or null while it is open. */ + readonly closedAt: string | null } ``` @@ -124,7 +142,7 @@ type ChangeRequestListener = (update: ChangeRequestUpdate) => void ## Session bindings -`ctx.forgeSession` derives each Session's bindings from its own log: `gh pr create` results bind the URLs they print, and `git push` results trigger head-branch discovery through `ctx.forge`. Track, dismiss, and discovery actions are `plugin:forge-session/binding` records, which stay outside the model-visible conversation. The [Session binding README](../../packages/experimental/forge-session/README.md#use-this-package) owns the recognition and discovery rules. +`ctx.forgeSession` derives each Session's bindings from its own log: `gh pr create` results bind the URLs they print, and `git push` results trigger head-branch discovery through `ctx.forge`. Track, dismiss, and discovery actions are `plugin:forge-session/binding` records, which stay outside the model-visible conversation. The `forgeChangeRequests` projection stays on the Host. The [Session binding README](../../packages/experimental/forge-session/README.md#use-this-package) owns the recognition and discovery rules. ```ts type-equiv /** @@ -168,6 +186,188 @@ interface SessionChangeRequest { type SessionChangeRequestsListener = (changeRequests: readonly SessionChangeRequest[]) => void ``` +## Panel + +`ctx.forgePanel` is the Host Remote of the [forge panel](../../packages/experimental/client-ui-forge/README.md#use-this-package). Following a Session streams a `ForgePanelState`: a read-only Git inspection of the Session's current directory in its execution world, the code host recognized from the upstream remote or `origin`, the sign-in state of that host, the Session's bindings with their polled status and last snapshot, and its worktree request. Its actions list base branches, bind and hide URLs, refresh, request or withdraw a worktree, stop a running creation, and restore a removed worktree or leave it for the source checkout. A request is a `plugin:client-ui-forge/worktree` record. The panel listens to [Agent admission](../../packages/core/agent/README.md): it holds the first message of a Session with an open request while it creates the worktree through `ctx.worktrees`, refuses messages while the worktree the Session works in is gone, and keeps the last failed creation in the panel state. Each refusal carries a `ForgeAdmissionRefusal` reason, and the panel presents it while it follows the Session. The first step proposal creates the worktree for a message that entered without admission. Requests after the first turn starts are ignored. Changes to a Session's checkout run as maintenance of its Agent, so a message from another client waits for them. + +```ts type-equiv +/** Git facts for the checkout that contains the Session's current directory. */ +interface ForgeCheckout { + /** Top-level directory of the checkout. */ + readonly root: string + /** Checked-out branch, or null when HEAD is detached. */ + readonly branch: string | null +} +``` + +```ts type-equiv +/** Inspection result for the Session's current directory. */ +type ForgeCheckoutState = + /** The first inspection has not finished. */ + | { readonly state: 'pending' } + /** The current directory is not inside a Git checkout. */ + | { readonly state: 'none' } + /** Git could not run or reported an error. */ + | { readonly state: 'failed'; readonly message: string } + /** The checkout facts. */ + | { readonly state: 'ready'; readonly checkout: ForgeCheckout } +``` + +```ts type-equiv +/** The worktree the panel created and entered for a Session before its first step. */ +interface ForgeSessionWorktree { + /** Canonical checkout directory. */ + readonly path: string + /** Branch created for the checkout. */ + readonly branch: string + /** Requested base branch, or null when the checkout started from HEAD of the source checkout. */ + readonly from: string | null + /** Commit object name the branch started from. */ + readonly baseCommit: string + /** Canonical root of the source checkout. */ + readonly repositoryRoot: string +} +``` + +```ts type-equiv +/** Payload of the `plugin:client-ui-forge/worktree` record. */ +type WorktreeRecord = + /** Create a worktree before the Session's first step, starting from `from`, or from HEAD when it is null. */ + | { readonly action: 'request'; readonly from: string | null } + /** Withdraw the open request. */ + | { readonly action: 'cancel' } + /** The requested worktree was created and became the Session's working directory. */ + | { readonly action: 'created'; readonly worktree: ForgeSessionWorktree } +``` + +```ts type-equiv +/** Why the Session's checkout cannot host a new worktree. */ +type ForgeWorktreeBlocker = + /** The current directory is not inside a Git checkout. */ + | 'not-git' + /** The Session's sandbox cannot write the checkout or its Git directory, as for a linked worktree outside the writable root. */ + | 'outside-writable-root' + /** The Session runs read-only. */ + | 'read-only' +``` + +```ts type-equiv +/** One running worktree creation. */ +interface ForgeWorktreeCreation { + /** Running step: `prepare` validates and creates the branch, then `checkout` writes the files. */ + readonly phase: 'prepare' | 'checkout' + /** Branch the attempt creates. */ + readonly branch: string + /** Base branch, or null for HEAD. */ + readonly from: string | null + /** + * Whether a message waits for the creation before it enters the Session. + * Only such a creation can be stopped, which returns the message to its + * sender; a creation at the first step serves a message the Session already holds. + */ + readonly held: boolean +} +``` + +```ts type-equiv +/** The last attempt to create the requested worktree for a message, which failed. */ +interface ForgeWorktreeFailure { + /** Branch the attempt created, or null when it failed before naming one. */ + readonly branch: string | null + /** Base branch of the attempt, or null for HEAD. */ + readonly from: string | null + /** Step that failed. */ + readonly phase: 'prepare' | 'checkout' + /** Failure text. */ + readonly message: string +} +``` + +```ts type-equiv +/** + * Why the panel refuses a message at Agent admission; the refusal reason a + * `session/prompt-rejected` error carries. The panel shows each refusal itself + * while it follows the Session. + */ +type ForgeAdmissionRefusal = + /** The requested worktree could not be created; the panel state carries the failure. */ + | 'forge/worktree-creation-failed' + /** The creation the message waited for was stopped. */ + | 'forge/worktree-creation-stopped' + /** The worktree the Session works in was removed. */ + | 'forge/worktree-missing' +``` + +```ts type-equiv +/** The worktree a Session works in, whose directory no longer exists. */ +interface ForgeMissingWorktree { + /** Whether the worktree's branch still exists; restoring creates a missing branch again at its base commit. */ + readonly branchExists: boolean +} +``` + +```ts type-equiv +/** Worktree facts of one Session in the panel state. */ +interface ForgePanelWorktree { + /** + * Whether the Session can still request or withdraw a worktree: a worktree + * service is mounted, no turn has started, and none was created. + */ + readonly editable: boolean + /** Why the checkout cannot host a worktree, or null when it can, before the first inspection, and after a failed one. */ + readonly blocker: ForgeWorktreeBlocker | null + /** Open request, or null without one. */ + readonly request: ForgeWorktreeRequest | null + /** Created worktree, or null before creation. */ + readonly created: ForgeSessionWorktree | null + /** Running creation, or null. */ + readonly creation: ForgeWorktreeCreation | null + /** + * The failed attempt that returned the last message to its sender, or null. + * It clears when another attempt starts and when the request changes or is withdrawn. + */ + readonly failure: ForgeWorktreeFailure | null + /** The created worktree the Session works in when its directory is gone, or null. */ + readonly missing: ForgeMissingWorktree | null +} +``` + +```ts type-equiv +/** One bound change request in the panel state. */ +interface ForgePanelChangeRequest extends SessionChangeRequest { + /** Latest snapshot a poll returned, kept while later polls fail, or null before the first successful poll. */ + readonly snapshot: ChangeRequestSnapshot | null +} +``` + +```ts type-equiv +/** Everything the panel shows for one Session. */ +interface ForgePanelState { + /** Current working directory of the Session. */ + readonly directory: string + /** Inspection result for `directory`. */ + readonly checkout: ForgeCheckoutState + /** Code host repository of the checkout's upstream remote or `origin`, or null when no provider serves them. */ + readonly repository: ForgeRepository | null + /** Sign-in state for the repository's host, or null before the first probe and without a repository. */ + readonly readiness: ForgeReadiness | null + /** Visible change request bindings with their latest polling results, in first-recorded order. */ + readonly changeRequests: readonly ForgePanelChangeRequest[] + /** Worktree request and creation state. */ + readonly worktree: ForgePanelWorktree +} +``` + +```ts type-equiv +/** Local branches offered as the base of a new worktree. */ +interface ForgeBranches { + /** Checked-out branch, or null when HEAD is detached. */ + readonly current: string | null + /** Local branch names, `current` first and then most recently committed first, at most `maxBranches`. */ + readonly branches: readonly string[] +} +``` + @@ -225,6 +425,15 @@ changeRequestUrl(ref: ChangeRequestRef): string */ async findByHead(repository: ForgeRepository, branch: string, signal: AbortSignal): Promise +/** + * Report whether the provider can reach the repository's host with current credentials. + * @param repository - repository served by a registered provider. + * @param signal - cancellation for the probe. + * @returns the provider's readiness for the host. + * @throws when no provider serves the repository's forge. + */ +async readiness(repository: ForgeRepository, signal: AbortSignal): Promise + /** * Watch one change request as an effect on the calling fiber. The first * watcher starts polling at once; listeners receive every later result, @@ -236,6 +445,14 @@ async findByHead(repository: ForgeRepository, branch: string, signal: AbortSigna */ watch(ref: ChangeRequestRef, listener: ChangeRequestListener): () => void +/** + * Poll a watched change request at once, including after a terminal state. + * A fetch already in flight for its forge serves the request; an unwatched + * change request is unchanged. + * @param ref - change request to poll. + */ +refresh(ref: ChangeRequestRef): void + /** * Read the latest polling result of a watched change request. * @param ref - change request to read. @@ -246,6 +463,121 @@ latest(ref: ChangeRequestRef): ChangeRequestUpdate | undefined Source: [`packages/experimental/forge/src/index.ts`](../../packages/experimental/forge/src/index.ts) + + +### `ctx.forgePanel` — `ForgePanelController` + +Serves the forge panel. Following a Session inspects the checkout of its current directory read-only in the Session's execution world, recognizes the code host of the upstream remote or `origin`, probes sign-in for that host, and polls the Session's bound change requests through `ctx.forge` until the last follower of that Session leaves. A worktree request makes the panel create a branch and checkout through an optional `ctx.worktrees` and enter it: at Agent admission of the Session's first message, which waits for the worktree, or at the first step proposal of a message that entered without admission. A failed or stopped creation returns the message to its sender. Admission also refuses messages while the worktree the Session works in is gone; that worktree can be checked out again or left for the source checkout. Changes to a Session's checkout run as Agent maintenance, so a message that arrives meanwhile waits for them. + +```ts cordis-catalog +/** + * Stream the panel state of a Session: the current state first, then each + * later state. Intermediate states may be skipped when the reader is slower + * than the updates. The stream ends when `signal` aborts or the Remote stops. + * @param session - Session to follow. + * @param signal - ends the stream. + * @returns panel states in publication order. + */ +@Remote({ mode: 'stream' }) async *follow(session: Session, signal: AbortSignal): AsyncIterable + +/** + * List the local branches that can start a worktree of the Session's current checkout. + * @param session - Session whose current directory is listed. + * @param signal - request cancellation. + * @returns the checked-out branch and at most `maxBranches` local branches, the checked-out one first. + * @throws when the current directory is not inside a checkout or Git fails. + */ +@Remote async branches(session: Session, signal: AbortSignal): Promise + +/** + * Bind a change request to a Session by URL, or restore a dismissed binding. + * @param session - Session that receives the binding. + * @param url - web URL of the change request. + * @param signal - request cancellation. + * @throws `forgePanel/unrecognized-url` when no registered provider serves the URL. + */ +@Remote async track(session: Session, url: string, signal: AbortSignal): Promise + +/** + * Hide a visible binding of a Session; other URLs leave the Session unchanged. + * @param session - Session that owns the binding. + * @param url - web URL of the change request. + * @param signal - request cancellation. + */ +@Remote async dismiss(session: Session, url: string, signal: AbortSignal): Promise + +/** + * Reinspect a followed Session's checkout, probe sign-in again, and poll its + * change requests at once; an unfollowed Session is unchanged. + * @param session - Session whose panel refreshes. + * @param signal - request cancellation. + */ +@Remote async refresh(session: Session, signal: AbortSignal): Promise + +/** + * Ask for a new worktree before the Session's first turn, replacing an open + * request and forgetting a failed attempt. Admission of the next message, or + * else the first step proposal, creates a branch from `from`, or from HEAD of + * the current checkout when it is null, and enters its checkout. + * @param session - Session without a started turn. + * @param from - local base branch, or null for HEAD. + * @param signal - request cancellation. + * @throws `forgePanel/invalid-branch` when `from` is empty; `forgePanel/worktrees-unavailable` + * without a worktree service; `forgePanel/session-started` after a turn started or the worktree was + * created; `forgePanel/session-busy` while the worktree is being created. + */ +@Remote async requestWorktree(session: Session, from: string | null, signal: AbortSignal): Promise + +/** + * Withdraw the open worktree request of a Session with its failed attempt; without a request the Session is unchanged. + * @param session - Session without a started turn. + * @param signal - request cancellation. + * @throws `forgePanel/worktrees-unavailable` without a worktree service; `forgePanel/session-started` + * after a turn started or the worktree was created; `forgePanel/session-busy` while the worktree is + * being created. + */ +@Remote async cancelWorktree(session: Session, signal: AbortSignal): Promise + +/** + * Stop the creation that holds a message of the Session at admission; the + * message goes back to its sender and the request stays open. Without such a + * creation the Session is unchanged. + * @param session - Session whose creation stops. + * @param signal - request cancellation. + */ +@Remote async stopCreation(session: Session, signal: AbortSignal): Promise + +/** + * Check out the created worktree of a Session again at its recorded + * directory when that directory is gone: its branch when the branch still + * exists, otherwise a new branch of the same name at the base commit. Changes + * that were never committed are not recovered. Runs as Agent maintenance; + * without a missing directory the Session is unchanged. + * @param session - Session whose worktree directory is gone. + * @param signal - request cancellation, which aborts the restoration. + * @throws `forgePanel/no-worktree` without a created worktree; `forgePanel/worktrees-unavailable` + * without a worktree service; `forgePanel/session-busy` while the Session's Agent is not loaded or + * has other work; `forgePanel/restore-failed` when Git fails. + */ +@Remote async restoreWorktree(session: Session, signal: AbortSignal): Promise + +/** + * Move a Session from its created worktree to the source checkout the + * worktree came from; the Session no longer works in the worktree. Runs as + * Agent maintenance. + * @param session - Session with a created worktree. + * @param signal - request cancellation. + * @throws `forgePanel/no-worktree` without a created worktree; `forgePanel/session-busy` while the + * Session's Agent is not loaded or has other work; the working-directory service's error when the + * source checkout no longer exists. + */ +@Remote async continueInProject(session: Session, signal: AbortSignal): Promise +``` + +Types: [Session](session.md) + +Source: [`packages/experimental/client-ui-forge/src/index.ts`](../../packages/experimental/client-ui-forge/src/index.ts) + ### `ctx.forgeSession` — `ForgeSessionService` diff --git a/docs/subsystems/forge.zh.md b/docs/subsystems/forge.zh.md index 28b7d1d782..33d49f215e 100644 --- a/docs/subsystems/forge.zh.md +++ b/docs/subsystems/forge.zh.md @@ -2,7 +2,7 @@ [English](forge.md) | 中文 -实验性的 forge 能力 seam 从代码托管平台读取变更请求(change request,即 pull request 与 merge request)。[`dsh-experimental-forge`](../../packages/experimental/forge/README.zh.md) 是 Service Definition,提供 `ctx.forge`;[`dsh-experimental-forge-github`](../../packages/experimental/forge-github/README.zh.md) 是服务 GitHub 主机的 Service Provider;[`dsh-experimental-forge-session`](../../packages/experimental/forge-session/README.zh.md) 是把变更请求绑定到 Session 的 Consumer,提供 `ctx.forgeSession`。三者都只能通过显式组合加载。 +实验性的 forge 能力 seam 从代码托管平台读取变更请求(change request,即 pull request 与 merge request)。[`dsh-experimental-forge`](../../packages/experimental/forge/README.zh.md) 是 Service Definition,提供 `ctx.forge`;[`dsh-experimental-forge-github`](../../packages/experimental/forge-github/README.zh.md) 是服务 GitHub 主机的 Service Provider;[`dsh-experimental-forge-session`](../../packages/experimental/forge-session/README.zh.md) 是把变更请求绑定到 Session 的 Consumer,提供 `ctx.forgeSession`;[`dsh-experimental-client-ui-forge`](../../packages/experimental/client-ui-forge/README.zh.md) 是提供 `ctx.forgePanel` 的 GUI Consumer。默认禁用的 [forge Bundle](../../packages/experimental/forge-bundle/README.zh.md) 可从插件页面组合这四个包,并为面板附带一个隔离的 [worktree 服务](../../packages/experimental/worktree/README.zh.md);除此之外,它们只能通过显式组合加载。 ## 提供方与变更请求 @@ -53,6 +53,18 @@ interface ForgeProvider { ): Promise> /** Find open change requests whose head is `branch` in `repository`, excluding the default branch and forks. */ findByHead(repository: ForgeRepository, branch: string, signal: AbortSignal): Promise + /** Report whether the provider can reach `host` with current credentials. */ + readiness(host: string, signal: AbortSignal): Promise +} +``` + +```ts type-equiv +/** Whether a provider can reach one host with the current credentials. */ +interface ForgeReadiness { + /** `ready`, `unauthenticated` (sign-in needed), or `unavailable` (tool missing or host unreachable). */ + readonly state: 'ready' | 'unauthenticated' | 'unavailable' + /** Human-readable detail, or null when ready. */ + readonly message: string | null } ``` @@ -81,6 +93,10 @@ interface ChangeRequestSnapshot { readonly headSha: string /** Branch the change request merges into. */ readonly baseBranch: string + /** Lines the change request adds. */ + readonly additions: number + /** Lines the change request deletes. */ + readonly deletions: number /** Merge conflict status; `unknown` while the host computes it. */ readonly mergeability: Mergeability /** Aggregate review outcome. */ @@ -95,6 +111,8 @@ interface ChangeRequestSnapshot { readonly unresolvedThreads: number /** ISO 8601 time of the host's last update to the change request. */ readonly updatedAt: string + /** ISO 8601 time the change request was merged or closed, or null while it is open. */ + readonly closedAt: string | null } ``` @@ -124,7 +142,7 @@ type ChangeRequestListener = (update: ChangeRequestUpdate) => void ## Session 绑定 -`ctx.forgeSession` 从每个 Session 自己的日志派生其绑定:`gh pr create` 的结果绑定其输出的 URL,`git push` 的结果通过 `ctx.forge` 触发按 head 分支的发现。track、dismiss 与发现操作都是 `plugin:forge-session/binding` 插件记录,位于模型可见对话之外。识别与发现规则见 [Session 绑定 README](../../packages/experimental/forge-session/README.zh.md#use-this-package)。 +`ctx.forgeSession` 从每个 Session 自己的日志派生其绑定:`gh pr create` 的结果绑定其输出的 URL,`git push` 的结果通过 `ctx.forge` 触发按 head 分支的发现。track、dismiss 与发现操作都是 `plugin:forge-session/binding` 插件记录,位于模型可见对话之外。`forgeChangeRequests` 投影只位于宿主侧。识别与发现规则见 [Session 绑定 README](../../packages/experimental/forge-session/README.zh.md#use-this-package)。 ```ts type-equiv /** @@ -168,6 +186,188 @@ interface SessionChangeRequest { type SessionChangeRequestsListener = (changeRequests: readonly SessionChangeRequest[]) => void ``` +## 面板 + +`ctx.forgePanel` 是 [forge 面板](../../packages/experimental/client-ui-forge/README.zh.md#use-this-package)的宿主 Remote。跟随一个 Session 时,它以流的形式推送 `ForgePanelState`:在 Session 执行环境中对其当前目录进行的只读 Git 检查、从上游远端或 `origin` 识别出的代码托管平台、该平台的登录状态、Session 的绑定及其轮询状态与最近的快照,以及它的 worktree 请求。它的操作可以列出基准分支、绑定和隐藏 URL、刷新、请求或撤回 worktree、停止正在进行的创建,以及恢复已删除的 worktree 或离开它回到源检出。请求是一条 `plugin:client-ui-forge/worktree` 记录。面板监听 [Agent 消息接纳](../../packages/core/agent/README.zh.md):Session 有未完成的请求时,它在通过 `ctx.worktrees` 创建 worktree 期间暂留第一条消息;Session 所在的 worktree 已被删除时拒绝消息;最近一次失败的创建保留在面板状态中。每次拒绝都带有一个 `ForgeAdmissionRefusal` 原因,面板跟随该 Session 时由它自行呈现。对于未经接纳就进入的消息,由第一个步骤提议创建 worktree。第一轮开始之后的请求会被忽略。对 Session 检出的改动以其 Agent 的维护任务运行,因此其他客户端发来的消息会等它们完成。 + +```ts type-equiv +/** Git facts for the checkout that contains the Session's current directory. */ +interface ForgeCheckout { + /** Top-level directory of the checkout. */ + readonly root: string + /** Checked-out branch, or null when HEAD is detached. */ + readonly branch: string | null +} +``` + +```ts type-equiv +/** Inspection result for the Session's current directory. */ +type ForgeCheckoutState = + /** The first inspection has not finished. */ + | { readonly state: 'pending' } + /** The current directory is not inside a Git checkout. */ + | { readonly state: 'none' } + /** Git could not run or reported an error. */ + | { readonly state: 'failed'; readonly message: string } + /** The checkout facts. */ + | { readonly state: 'ready'; readonly checkout: ForgeCheckout } +``` + +```ts type-equiv +/** The worktree the panel created and entered for a Session before its first step. */ +interface ForgeSessionWorktree { + /** Canonical checkout directory. */ + readonly path: string + /** Branch created for the checkout. */ + readonly branch: string + /** Requested base branch, or null when the checkout started from HEAD of the source checkout. */ + readonly from: string | null + /** Commit object name the branch started from. */ + readonly baseCommit: string + /** Canonical root of the source checkout. */ + readonly repositoryRoot: string +} +``` + +```ts type-equiv +/** Payload of the `plugin:client-ui-forge/worktree` record. */ +type WorktreeRecord = + /** Create a worktree before the Session's first step, starting from `from`, or from HEAD when it is null. */ + | { readonly action: 'request'; readonly from: string | null } + /** Withdraw the open request. */ + | { readonly action: 'cancel' } + /** The requested worktree was created and became the Session's working directory. */ + | { readonly action: 'created'; readonly worktree: ForgeSessionWorktree } +``` + +```ts type-equiv +/** Why the Session's checkout cannot host a new worktree. */ +type ForgeWorktreeBlocker = + /** The current directory is not inside a Git checkout. */ + | 'not-git' + /** The Session's sandbox cannot write the checkout or its Git directory, as for a linked worktree outside the writable root. */ + | 'outside-writable-root' + /** The Session runs read-only. */ + | 'read-only' +``` + +```ts type-equiv +/** One running worktree creation. */ +interface ForgeWorktreeCreation { + /** Running step: `prepare` validates and creates the branch, then `checkout` writes the files. */ + readonly phase: 'prepare' | 'checkout' + /** Branch the attempt creates. */ + readonly branch: string + /** Base branch, or null for HEAD. */ + readonly from: string | null + /** + * Whether a message waits for the creation before it enters the Session. + * Only such a creation can be stopped, which returns the message to its + * sender; a creation at the first step serves a message the Session already holds. + */ + readonly held: boolean +} +``` + +```ts type-equiv +/** The last attempt to create the requested worktree for a message, which failed. */ +interface ForgeWorktreeFailure { + /** Branch the attempt created, or null when it failed before naming one. */ + readonly branch: string | null + /** Base branch of the attempt, or null for HEAD. */ + readonly from: string | null + /** Step that failed. */ + readonly phase: 'prepare' | 'checkout' + /** Failure text. */ + readonly message: string +} +``` + +```ts type-equiv +/** + * Why the panel refuses a message at Agent admission; the refusal reason a + * `session/prompt-rejected` error carries. The panel shows each refusal itself + * while it follows the Session. + */ +type ForgeAdmissionRefusal = + /** The requested worktree could not be created; the panel state carries the failure. */ + | 'forge/worktree-creation-failed' + /** The creation the message waited for was stopped. */ + | 'forge/worktree-creation-stopped' + /** The worktree the Session works in was removed. */ + | 'forge/worktree-missing' +``` + +```ts type-equiv +/** The worktree a Session works in, whose directory no longer exists. */ +interface ForgeMissingWorktree { + /** Whether the worktree's branch still exists; restoring creates a missing branch again at its base commit. */ + readonly branchExists: boolean +} +``` + +```ts type-equiv +/** Worktree facts of one Session in the panel state. */ +interface ForgePanelWorktree { + /** + * Whether the Session can still request or withdraw a worktree: a worktree + * service is mounted, no turn has started, and none was created. + */ + readonly editable: boolean + /** Why the checkout cannot host a worktree, or null when it can, before the first inspection, and after a failed one. */ + readonly blocker: ForgeWorktreeBlocker | null + /** Open request, or null without one. */ + readonly request: ForgeWorktreeRequest | null + /** Created worktree, or null before creation. */ + readonly created: ForgeSessionWorktree | null + /** Running creation, or null. */ + readonly creation: ForgeWorktreeCreation | null + /** + * The failed attempt that returned the last message to its sender, or null. + * It clears when another attempt starts and when the request changes or is withdrawn. + */ + readonly failure: ForgeWorktreeFailure | null + /** The created worktree the Session works in when its directory is gone, or null. */ + readonly missing: ForgeMissingWorktree | null +} +``` + +```ts type-equiv +/** One bound change request in the panel state. */ +interface ForgePanelChangeRequest extends SessionChangeRequest { + /** Latest snapshot a poll returned, kept while later polls fail, or null before the first successful poll. */ + readonly snapshot: ChangeRequestSnapshot | null +} +``` + +```ts type-equiv +/** Everything the panel shows for one Session. */ +interface ForgePanelState { + /** Current working directory of the Session. */ + readonly directory: string + /** Inspection result for `directory`. */ + readonly checkout: ForgeCheckoutState + /** Code host repository of the checkout's upstream remote or `origin`, or null when no provider serves them. */ + readonly repository: ForgeRepository | null + /** Sign-in state for the repository's host, or null before the first probe and without a repository. */ + readonly readiness: ForgeReadiness | null + /** Visible change request bindings with their latest polling results, in first-recorded order. */ + readonly changeRequests: readonly ForgePanelChangeRequest[] + /** Worktree request and creation state. */ + readonly worktree: ForgePanelWorktree +} +``` + +```ts type-equiv +/** Local branches offered as the base of a new worktree. */ +interface ForgeBranches { + /** Checked-out branch, or null when HEAD is detached. */ + readonly current: string | null + /** Local branch names, `current` first and then most recently committed first, at most `maxBranches`. */ + readonly branches: readonly string[] +} +``` + @@ -225,6 +425,15 @@ changeRequestUrl(ref: ChangeRequestRef): string */ async findByHead(repository: ForgeRepository, branch: string, signal: AbortSignal): Promise +/** + * Report whether the provider can reach the repository's host with current credentials. + * @param repository - repository served by a registered provider. + * @param signal - cancellation for the probe. + * @returns the provider's readiness for the host. + * @throws when no provider serves the repository's forge. + */ +async readiness(repository: ForgeRepository, signal: AbortSignal): Promise + /** * Watch one change request as an effect on the calling fiber. The first * watcher starts polling at once; listeners receive every later result, @@ -236,6 +445,14 @@ async findByHead(repository: ForgeRepository, branch: string, signal: AbortSigna */ watch(ref: ChangeRequestRef, listener: ChangeRequestListener): () => void +/** + * Poll a watched change request at once, including after a terminal state. + * A fetch already in flight for its forge serves the request; an unwatched + * change request is unchanged. + * @param ref - change request to poll. + */ +refresh(ref: ChangeRequestRef): void + /** * Read the latest polling result of a watched change request. * @param ref - change request to read. @@ -246,6 +463,121 @@ latest(ref: ChangeRequestRef): ChangeRequestUpdate | undefined Source: [`packages/experimental/forge/src/index.ts`](../../packages/experimental/forge/src/index.ts) + + +### `ctx.forgePanel` — `ForgePanelController` + +Serves the forge panel. Following a Session inspects the checkout of its current directory read-only in the Session's execution world, recognizes the code host of the upstream remote or `origin`, probes sign-in for that host, and polls the Session's bound change requests through `ctx.forge` until the last follower of that Session leaves. A worktree request makes the panel create a branch and checkout through an optional `ctx.worktrees` and enter it: at Agent admission of the Session's first message, which waits for the worktree, or at the first step proposal of a message that entered without admission. A failed or stopped creation returns the message to its sender. Admission also refuses messages while the worktree the Session works in is gone; that worktree can be checked out again or left for the source checkout. Changes to a Session's checkout run as Agent maintenance, so a message that arrives meanwhile waits for them. + +```ts cordis-catalog +/** + * Stream the panel state of a Session: the current state first, then each + * later state. Intermediate states may be skipped when the reader is slower + * than the updates. The stream ends when `signal` aborts or the Remote stops. + * @param session - Session to follow. + * @param signal - ends the stream. + * @returns panel states in publication order. + */ +@Remote({ mode: 'stream' }) async *follow(session: Session, signal: AbortSignal): AsyncIterable + +/** + * List the local branches that can start a worktree of the Session's current checkout. + * @param session - Session whose current directory is listed. + * @param signal - request cancellation. + * @returns the checked-out branch and at most `maxBranches` local branches, the checked-out one first. + * @throws when the current directory is not inside a checkout or Git fails. + */ +@Remote async branches(session: Session, signal: AbortSignal): Promise + +/** + * Bind a change request to a Session by URL, or restore a dismissed binding. + * @param session - Session that receives the binding. + * @param url - web URL of the change request. + * @param signal - request cancellation. + * @throws `forgePanel/unrecognized-url` when no registered provider serves the URL. + */ +@Remote async track(session: Session, url: string, signal: AbortSignal): Promise + +/** + * Hide a visible binding of a Session; other URLs leave the Session unchanged. + * @param session - Session that owns the binding. + * @param url - web URL of the change request. + * @param signal - request cancellation. + */ +@Remote async dismiss(session: Session, url: string, signal: AbortSignal): Promise + +/** + * Reinspect a followed Session's checkout, probe sign-in again, and poll its + * change requests at once; an unfollowed Session is unchanged. + * @param session - Session whose panel refreshes. + * @param signal - request cancellation. + */ +@Remote async refresh(session: Session, signal: AbortSignal): Promise + +/** + * Ask for a new worktree before the Session's first turn, replacing an open + * request and forgetting a failed attempt. Admission of the next message, or + * else the first step proposal, creates a branch from `from`, or from HEAD of + * the current checkout when it is null, and enters its checkout. + * @param session - Session without a started turn. + * @param from - local base branch, or null for HEAD. + * @param signal - request cancellation. + * @throws `forgePanel/invalid-branch` when `from` is empty; `forgePanel/worktrees-unavailable` + * without a worktree service; `forgePanel/session-started` after a turn started or the worktree was + * created; `forgePanel/session-busy` while the worktree is being created. + */ +@Remote async requestWorktree(session: Session, from: string | null, signal: AbortSignal): Promise + +/** + * Withdraw the open worktree request of a Session with its failed attempt; without a request the Session is unchanged. + * @param session - Session without a started turn. + * @param signal - request cancellation. + * @throws `forgePanel/worktrees-unavailable` without a worktree service; `forgePanel/session-started` + * after a turn started or the worktree was created; `forgePanel/session-busy` while the worktree is + * being created. + */ +@Remote async cancelWorktree(session: Session, signal: AbortSignal): Promise + +/** + * Stop the creation that holds a message of the Session at admission; the + * message goes back to its sender and the request stays open. Without such a + * creation the Session is unchanged. + * @param session - Session whose creation stops. + * @param signal - request cancellation. + */ +@Remote async stopCreation(session: Session, signal: AbortSignal): Promise + +/** + * Check out the created worktree of a Session again at its recorded + * directory when that directory is gone: its branch when the branch still + * exists, otherwise a new branch of the same name at the base commit. Changes + * that were never committed are not recovered. Runs as Agent maintenance; + * without a missing directory the Session is unchanged. + * @param session - Session whose worktree directory is gone. + * @param signal - request cancellation, which aborts the restoration. + * @throws `forgePanel/no-worktree` without a created worktree; `forgePanel/worktrees-unavailable` + * without a worktree service; `forgePanel/session-busy` while the Session's Agent is not loaded or + * has other work; `forgePanel/restore-failed` when Git fails. + */ +@Remote async restoreWorktree(session: Session, signal: AbortSignal): Promise + +/** + * Move a Session from its created worktree to the source checkout the + * worktree came from; the Session no longer works in the worktree. Runs as + * Agent maintenance. + * @param session - Session with a created worktree. + * @param signal - request cancellation. + * @throws `forgePanel/no-worktree` without a created worktree; `forgePanel/session-busy` while the + * Session's Agent is not loaded or has other work; the working-directory service's error when the + * source checkout no longer exists. + */ +@Remote async continueInProject(session: Session, signal: AbortSignal): Promise +``` + +Types: [Session](session.zh.md) + +Source: [`packages/experimental/client-ui-forge/src/index.ts`](../../packages/experimental/client-ui-forge/src/index.ts) + ### `ctx.forgeSession` — `ForgeSessionService` diff --git a/docs/subsystems/worktrees.i18n.yaml b/docs/subsystems/worktrees.i18n.yaml index 03544fbc81..f771dd3cf2 100644 --- a/docs/subsystems/worktrees.i18n.yaml +++ b/docs/subsystems/worktrees.i18n.yaml @@ -9,5 +9,5 @@ en: 53f643c5d30e00b2 zh: ba9e52efc62c7656 /git-worktrees/request-and-result: - en: 8391b091607600eb - zh: 6c735a337d60d566 + en: fbab866bf824d6e0 + zh: 73d2a1ee350e1434 diff --git a/docs/subsystems/worktrees.md b/docs/subsystems/worktrees.md index 14db6711bc..99f7a21391 100644 --- a/docs/subsystems/worktrees.md +++ b/docs/subsystems/worktrees.md @@ -14,7 +14,7 @@ Leaving through `working_directory({ cd: path })` retains the checkout and branc ## Request and result -The request selects only the new name and local revision. The result identifies the created checkout and exact commit; it does not change the Session's original project or write permissions. +The request selects only the new name and local revision. The result identifies the created checkout and exact commit; it does not change the Session's original project or write permissions. An optional callback receives each stage of the creation before it begins. A restoration checks a removed checkout out again at its recorded directory, from its branch or, when the branch is gone, from its base commit. ```ts type-equiv /** A new checkout from a locally available Git commit, branch, or tag. */ @@ -40,6 +40,39 @@ interface CreatedWorktree { } ``` +```ts type-equiv +/** + * Stage of one creation, reported before it begins: `prepare` validates the + * request, allocates the checkout directory, and creates the branch; + * `checkout` writes the files and enters the checkout. + */ +type CreateWorktreePhase = 'prepare' | 'checkout' +``` + +```ts type-equiv +/** A removed checkout to check out again at its recorded directory. */ +interface RestoreWorktreeRequest { + /** Canonical checkout directory the worktree had. */ + path: string + /** Branch the worktree had checked out. */ + branch: string + /** Commit the branch started from; a branch that no longer exists is created again here. */ + baseCommit: string + /** Canonical root of the source checkout. */ + repositoryRoot: string +} +``` + +```ts type-equiv +/** Outcome of one restoration. */ +interface RestoredWorktree { + /** Checkout directory, present again. */ + path: string + /** Whether the branch no longer existed and was created again at the base commit, without the commits it had gained. */ + branchRecreated: boolean +} +``` + @@ -65,9 +98,25 @@ Creates retained Git worktrees under the mounted filesystem and sandbox provider * @param agent - caller whose current directory selects the source repository and file policy. * @param request - optional new name and local revision; defaults are generated name and HEAD. * @param signal - cancellation of lookup, creation, and working-directory publication. + * @param onPhase - receives each stage before it begins; it must not throw. * @returns canonical checkout path, branch name, pinned commit, and source repository root. */ -async create(agent: Agent, request: CreateWorktreeRequest = {}, signal?: AbortSignal): Promise +async create( agent: Agent, request: CreateWorktreeRequest = {}, signal?: AbortSignal, onPhase?: (phase: CreateWorktreePhase) => void, ): Promise + +/** + * Check out a removed worktree again at its recorded directory: its branch + * when the branch still exists, otherwise a new branch of the same name at + * `baseCommit`. Stale worktree registrations are pruned first, and an + * existing directory fails. Changes that were never committed are not + * recovered. Checkout disables configured clean, smudge, and process filters, + * as creation does. The calling Session's working directory does not change. + * Failures may retain newly allocated Git/filesystem artifacts. + * @param agent - caller whose sandbox policy confines the commands. + * @param request - recorded directory, branch, base commit, and source repository root. + * @param signal - cancellation of the restoration. + * @returns the directory and whether the branch was created again. + */ +async restore(agent: Agent, request: RestoreWorktreeRequest, signal?: AbortSignal): Promise ``` Types: [Agent](core.md) diff --git a/docs/subsystems/worktrees.zh.md b/docs/subsystems/worktrees.zh.md index 719d99266b..764a4d804b 100644 --- a/docs/subsystems/worktrees.zh.md +++ b/docs/subsystems/worktrees.zh.md @@ -14,7 +14,7 @@ ## 请求与结果 -请求只选择新名称与本地版本。结果标识创建的检出目录与确切提交;它不会改变 Session 的原始项目或写权限。 +请求只选择新名称与本地版本。结果标识创建的检出目录与确切提交;它不会改变 Session 的原始项目或写权限。可选的回调会在创建的每个阶段开始之前收到该阶段。恢复会在记录的目录重新检出已删除的检出目录:从它的分支检出;分支也已删除时,从它的基准提交检出。 ```ts type-equiv /** A new checkout from a locally available Git commit, branch, or tag. */ @@ -40,6 +40,39 @@ interface CreatedWorktree { } ``` +```ts type-equiv +/** + * Stage of one creation, reported before it begins: `prepare` validates the + * request, allocates the checkout directory, and creates the branch; + * `checkout` writes the files and enters the checkout. + */ +type CreateWorktreePhase = 'prepare' | 'checkout' +``` + +```ts type-equiv +/** A removed checkout to check out again at its recorded directory. */ +interface RestoreWorktreeRequest { + /** Canonical checkout directory the worktree had. */ + path: string + /** Branch the worktree had checked out. */ + branch: string + /** Commit the branch started from; a branch that no longer exists is created again here. */ + baseCommit: string + /** Canonical root of the source checkout. */ + repositoryRoot: string +} +``` + +```ts type-equiv +/** Outcome of one restoration. */ +interface RestoredWorktree { + /** Checkout directory, present again. */ + path: string + /** Whether the branch no longer existed and was created again at the base commit, without the commits it had gained. */ + branchRecreated: boolean +} +``` + @@ -65,9 +98,25 @@ Creates retained Git worktrees under the mounted filesystem and sandbox provider * @param agent - caller whose current directory selects the source repository and file policy. * @param request - optional new name and local revision; defaults are generated name and HEAD. * @param signal - cancellation of lookup, creation, and working-directory publication. + * @param onPhase - receives each stage before it begins; it must not throw. * @returns canonical checkout path, branch name, pinned commit, and source repository root. */ -async create(agent: Agent, request: CreateWorktreeRequest = {}, signal?: AbortSignal): Promise +async create( agent: Agent, request: CreateWorktreeRequest = {}, signal?: AbortSignal, onPhase?: (phase: CreateWorktreePhase) => void, ): Promise + +/** + * Check out a removed worktree again at its recorded directory: its branch + * when the branch still exists, otherwise a new branch of the same name at + * `baseCommit`. Stale worktree registrations are pruned first, and an + * existing directory fails. Changes that were never committed are not + * recovered. Checkout disables configured clean, smudge, and process filters, + * as creation does. The calling Session's working directory does not change. + * Failures may retain newly allocated Git/filesystem artifacts. + * @param agent - caller whose sandbox policy confines the commands. + * @param request - recorded directory, branch, base commit, and source repository root. + * @param signal - cancellation of the restoration. + * @returns the directory and whether the branch was created again. + */ +async restore(agent: Agent, request: RestoreWorktreeRequest, signal?: AbortSignal): Promise ``` Types: [Agent](core.zh.md) diff --git a/packages/boot/app-boot/src/profile.ts b/packages/boot/app-boot/src/profile.ts index db23fbf232..14b24b40fb 100644 --- a/packages/boot/app-boot/src/profile.ts +++ b/packages/boot/app-boot/src/profile.ts @@ -234,6 +234,7 @@ export const OPTIONAL_BUNDLES: readonly string[] = [ '@deepseek-ai/dsh-experimental-inspector-profile', '@deepseek-ai/dsh-experimental-plugin-market-bundle', '@deepseek-ai/dsh-experimental-tool-worktree', + '@deepseek-ai/dsh-experimental-forge-bundle', ] const PROFILE_PATCH_TEMPLATE = `# Your patch layer for this dsh profile, applied after every bundle layer: diff --git a/packages/experimental/README.i18n.yaml b/packages/experimental/README.i18n.yaml index 051d8efc49..41165b13ec 100644 --- a/packages/experimental/README.i18n.yaml +++ b/packages/experimental/README.i18n.yaml @@ -18,11 +18,11 @@ en: b55aeb927737b90d zh: 0b215fcb5c2c7908 /packages-experimental/packages: - en: eea6339298b79e08 - zh: de048fe6a49c8a04 + en: e24b3cfea7fae889 + zh: 0a478989357dc799 /packages-experimental/related-documentation: - en: 3add3dbe9b625de7 - zh: cacba37c7834ff9d + en: 40797d4849455966 + zh: bb91a68dd995887c /packages-experimental/dev-note: en: 5de1d6304e00c58f zh: 8d8225361cfa29e5 diff --git a/packages/experimental/README.md b/packages/experimental/README.md index bef3e07f40..f3019e9c37 100644 --- a/packages/experimental/README.md +++ b/packages/experimental/README.md @@ -89,6 +89,8 @@ Promotion requires a named product role, documented public behavior and limitati | [`forge`](forge/README.md) | Code host provider registry with shared change request polling | `ctx.forge` | | [`forge-github`](forge-github/README.md) | GitHub provider that reads pull requests through the host GitHub CLI | registers a provider on `ctx.forge` | | [`forge-session`](forge-session/README.md) | Binds change requests to the Sessions that created, pushed, or tracked them | `ctx.forgeSession` | +| [`client-ui-forge`](client-ui-forge/README.md) | GUI panel for a Session's Git checkout, worktrees, and bound pull requests | `ctx.forgePanel` | +| [`forge-bundle`](forge-bundle/README.md) | Default-disabled pull request and worktree panel composition | — | ----- @@ -97,7 +99,7 @@ Promotion requires a named product role, documented public behavior and limitati - [Experimental publication reference](../../scripts/experimental-package-policy.ts) — public defaults and private exceptions. - [Worktrees](../../docs/subsystems/worktrees.md) — explicit branch and checkout creation. -- [Forges](../../docs/subsystems/forge.md) — change request providers, status polling, and Session bindings. +- [Forges](../../docs/subsystems/forge.md) — change request providers, status polling, Session bindings, and the panel. - [Computer use](../../docs/subsystems/computer-use.md) — desktop provider choices. - [Browser use](../../docs/subsystems/browser-use.md) — browser provider choices and Session ownership. - [Agent Teams subsystem](../../docs/subsystems/agent-team.md) — durable Team types and the `ctx.agentTeams` service API. diff --git a/packages/experimental/README.zh.md b/packages/experimental/README.zh.md index 119c7cbef1..b2214af182 100644 --- a/packages/experimental/README.zh.md +++ b/packages/experimental/README.zh.md @@ -89,6 +89,8 @@ kind: "package-group" | [`forge`](forge/README.zh.md) | 代码托管提供方注册表,共享变更请求轮询 | `ctx.forge` | | [`forge-github`](forge-github/README.zh.md) | 通过宿主 GitHub CLI 读取 pull request 的 GitHub 提供方 | 在 `ctx.forge` 上注册提供方 | | [`forge-session`](forge-session/README.zh.md) | 把变更请求绑定到创建、推送或跟踪它们的 Session | `ctx.forgeSession` | +| [`client-ui-forge`](client-ui-forge/README.zh.md) | 展示 Session 的 Git 检出、worktree 与已绑定 pull request 的 GUI 面板 | `ctx.forgePanel` | +| [`forge-bundle`](forge-bundle/README.zh.md) | 默认禁用的 pull request 与 worktree 面板组合 | — | ----- @@ -97,7 +99,7 @@ kind: "package-group" - [实验包发布说明](../../scripts/experimental-package-policy.ts)——默认公开与私有例外。 - [工作树](../../docs/subsystems/worktrees.zh.md)——显式创建分支与检出目录。 -- [Forges](../../docs/subsystems/forge.zh.md)——变更请求提供方、状态轮询与 Session 绑定。 +- [Forges](../../docs/subsystems/forge.zh.md)——变更请求提供方、状态轮询、Session 绑定与面板。 - [计算机操作](../../docs/subsystems/computer-use.zh.md)——桌面提供方选择。 - [浏览器操作](../../docs/subsystems/browser-use.zh.md)——浏览器提供方选择与 Session 所有权。 - [Agent Teams 子系统](../../docs/subsystems/agent-team.zh.md)——持久 Team 类型与 `ctx.agentTeams` 服务 API。 diff --git a/packages/experimental/client-ui-forge/README.i18n.yaml b/packages/experimental/client-ui-forge/README.i18n.yaml new file mode 100644 index 0000000000..612b9b0805 --- /dev/null +++ b/packages/experimental/client-ui-forge/README.i18n.yaml @@ -0,0 +1,37 @@ +# Bilingual-pair consistency record for README.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/experimental/client-ui-forge/README.md +/: + en: 6c9e355ea30ce2ce + zh: 19a587e050e65126 +/deepseek-ai-dsh-experimental-client-ui-forge: + en: 8ca3d802a44c6fd1 + zh: 2723c9e28ecea7f7 +/deepseek-ai-dsh-experimental-client-ui-forge/summary: + en: d164a212fc32f3da + zh: 39d7fbfb73503aa9 +/deepseek-ai-dsh-experimental-client-ui-forge/table-of-contents: + en: d152484eb41ac6b4 + zh: 2f8a621b991260a1 +/deepseek-ai-dsh-experimental-client-ui-forge/use-this-package: + en: 491e1b3af2a95c7c + zh: 110743d500fd25d7 +/deepseek-ai-dsh-experimental-client-ui-forge/understand-the-implementation: + en: 0b14b455d5cb74b1 + zh: 2832d37da95cd07d +/deepseek-ai-dsh-experimental-client-ui-forge/further-exploration: + en: 34db980ac035b73d + zh: 246cf0eb3e304f7f +/deepseek-ai-dsh-experimental-client-ui-forge/model-experience: + en: 30a7ffc18db56b62 + zh: ef8203c78c0b2c31 +/deepseek-ai-dsh-experimental-client-ui-forge/model-experience/kv-cache-effect: + en: dde484510338d9e9 + zh: 1107e4f78343e322 +/deepseek-ai-dsh-experimental-client-ui-forge/known-limitations-and-deferred-work: + en: d21772433c21d21c + zh: 7b242a37fe09d9d8 +/deepseek-ai-dsh-experimental-client-ui-forge/known-limitations-and-deferred-work/dev-note: + en: e67f633dc6fe7773 + zh: 3bb0c20c93f16f98 diff --git a/packages/experimental/client-ui-forge/README.md b/packages/experimental/client-ui-forge/README.md new file mode 100644 index 0000000000..639f5d658c --- /dev/null +++ b/packages/experimental/client-ui-forge/README.md @@ -0,0 +1,112 @@ +--- +description: "GUI panel for the pull requests a Session creates, and a worktree option for new Sessions, with the Host Remote that serves them." +kind: "package-reference" +--- + +# @deepseek-ai/dsh-experimental-client-ui-forge + +English | [中文](README.zh.md) + +## Summary + +This optional two-face package shows the pull requests a Session created or pushed, and lets a new Session start in its own Git worktree. The Host half is the `forgePanel` Remote: it inspects the Session's checkout read-only, recognizes the code host, checks sign-in, streams the Session's [bound change requests](../forge-session/README.md) with their polled status, and creates a requested worktree while the Session's first message waits at Agent admission. The Client half adds a right-sidebar tab, a Session header button and branch label, a New Session option, a creation card above the composer, and a toast for action outcomes. + +## Table of Contents + +- [Use this package](#use-this-package) +- [Understand the implementation](#understand-the-implementation) +- [Further Exploration](#further-exploration) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + +----- + + +## Use this package + +Enable the [forge bundle](../forge-bundle/README.md) on the Plugins page; it composes this package with the forge registry, the GitHub provider, the Session bindings, and its own [worktree service](../worktree/README.md). + +- **Worktree option** — on the New Session screen, Worktree sits at the right end of the row with the workspace and mode pickers. Checking it shows the base branch, the current branch by default; the menu lists recent local branches. The option is disabled, with the reason in its tooltip, outside a Git checkout, in a read-only Session, and when the checkout or its Git directory lies outside the Session's writable root, as for a project that is itself a linked worktree. Sending the first message keeps it in the composer on the New Session screen, shown as sending, while the branch `dsh/<8 hexadecimal digits>` is created from the base under the repository's `.agents/worktrees/` and its checkout becomes the Session's working directory; the Host accepts the message once the worktree exists. +- **Creation card** — while the worktree is created, a card above the composer shows its branch and the preparation and checkout steps; More details adds the branch and base. Cancel stops the creation and returns the message to editing with the option kept. A failed attempt returns the message to editing, and the card shows the reason: sending the message again tries again with a new branch name, and Continue without a worktree withdraws the request so the next send uses the current checkout. +- **Branch label** — the Session header shows the branch the Session works on; its tooltip names the worktree and its base, or the current checkout. +- **Removed worktree** — when the worktree the Session works in has been removed outside the Session, a card above the composer says so, and the Host returns each message to the composer until the worktree is restored or left. Restore worktree checks the branch out again at the same directory, or creates the branch again at the commit it started from when the branch is gone too; Continue in the project moves the Session to the source checkout. The branch label shows the warning tone meanwhile. +- **Header button** — appears when the Session has bound pull requests or its directory is inside a Git checkout, and shows or hides the tab. A red dot marks an open pull request with failed checks, requested changes, or conflicts. +- **Tab** — a row names the worktree the Session started in and its base, or the checked-out branch. Below it, each pull request card shows the state glyph, title, number, branch, changed lines, checks, and the most urgent of requested changes, conflicts, or draft; a merged or closed one shows when that happened. Expanding a card adds View pull request and Copy link, the state and how it was bound, the check list with durations, the review decision with unresolved threads, mergeability, Add to chat, which inserts the URL into the composer, and Remove from Session, whose toast offers Undo. Refresh inspects the checkout, checks sign-in, and polls every pull request again. A banner shows the `gh auth login` command when sign-in is missing. + +`gitCommand`, `gitTimeoutMs`, `graceMs`, `maxOutputBytes`, `readinessTimeoutMs`, `maxBranches`, and `worktreePrefix` are listed in the [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-experimental-client-ui-forge). + +----- + + +## Understand the implementation + +
+Implementation internals — click to expand + +`ForgePanelController` keeps one `SessionPanel` per followed Session and disposes it when the last follower leaves. A panel starts with the Session's working directory and bindings, re-inspects after each `working-directory/change`, `tool/result`, and `sandbox/mode` event, and publishes a new `ForgePanelState` for every change. Each change request keeps the latest snapshot a poll returned while later polls fail. Inspections run one at a time with one trailing rerun, and a result for a directory the Session has since left is dropped. The code host comes from the remotes in order: the upstream's remote, then `origin`, then the rest. A sign-in probe runs when the recognized host changes and on refresh, within `readinessTimeoutMs`. + +Inspection runs `rev-parse --show-toplevel`, `rev-parse --path-format=absolute --git-common-dir`, `symbolic-ref --short HEAD`, `rev-parse --abbrev-ref @{upstream}`, and `remote -v`; `branches` runs `symbolic-ref` and `for-each-ref --count=`. None reads the working tree. Every command runs under the Session's read-only sandbox policy with optional index writes, hooks, the filesystem monitor, prompts, and lazy object fetches disabled and the inherited Git selectors and secret-bearing variables removed, within its own `gitTimeoutMs` deadline and `maxOutputBytes` limit per stream. The top-level and common Git directories Git prints are resolved through `ctx.fs` into the canonical process paths that the working-directory service records. The worktree option is usable under the `danger-full-access` sandbox mode, never under `read-only`, and under `workspace-write` only when both directories lie inside the canonical workspace root of the Session's sandbox policy. + +A worktree request is a `plugin:client-ui-forge/worktree` record, folded by the host-only `forgeWorktree` projection: requests and withdrawals apply until the first `turn/start` or creation, and the first `created` record of an open request applies until the first `step/start`. `requestWorktree` and `cancelWorktree` refuse with `forgePanel/session-started` after a turn started or the worktree was created, with `forgePanel/worktrees-unavailable` without a `worktrees` service, and with `forgePanel/invalid-branch` for an empty branch; both forget a failed attempt. An `agent-controller/admission` listener holds a message of a Session whose request is open while one attempt with a new branch name runs as maintenance of the Session's Agent, so a message from another client waits for it; it publishes the running stage in the panel state with `held` set, records `created` with the source repository root, and then lets the message enter. A failed attempt stays in the panel state as `failure`, with its branch, base, failed step, and reason, until another attempt starts or the request changes, and the message is refused with `forge/worktree-creation-failed`. `stopCreation` and the Agent's cancellation refuse it with `forge/worktree-creation-stopped`, and the sender's cancellation passes through. Each message of a Session that works in its created worktree is refused with `forge/worktree-missing` while the worktree is missing; a check that cannot answer lets it pass. A refusal is marked `presented` while a panel follows the Session. The worktree service queues the working-directory notice, which the first step claims. An `agent/pre-step` listener creates the worktree at step 1 of a Session whose request is still open, which covers a message that entered without admission; it moves the notice into the proposed messages, and a failure ends the turn with the request kept. `restoreWorktree` and `continueInProject` also run as maintenance. The bundle isolates its `worktrees` service, so enabling Git Worktrees as well leaves two independent services. `track` rejects a URL no provider recognizes with `forgePanel/unrecognized-url`. + +Each inspection also decides whether the worktree is missing: the Session's directory lies inside the created worktree, `ctx.fs.lstat` finds no directory there, and `show-ref` in the source repository tells whether the branch still exists. + +The browser entry mounts the Remote, then the UI. One stream per Session feeds the tab, the header button, the branch label, the New Session option, and the creation card; it opens with the first subscriber, closes with the last, reopens after a transport reset, and keeps the last state for the next subscriber. The New Session option fills `conversation.hero.options`, the worktree card `conversation.input.dock`, and the branch label `conversation.session.header.actions`. The worktree card shows a running creation, with Cancel calling `stopCreation` while a message waits for it, or the last failure, with Continue without a worktree calling `cancelWorktree`; the composer itself shows the waiting message as sending and keeps a refused one. The tab reports its visibility to the header button's pressed state. The toast lives in a `shell.overlay` entry so it outlives the tab. The change request card takes a change request, its snapshot, and optional actions, so another surface can reuse it. + +| Source | Responsibility | +|---|---| +| [`src/index.ts`](src/index.ts) | `forgePanel` Remote and per-Session panels | +| [`src/panel.ts`](src/panel.ts) | Panel state, inspection scheduling, and sign-in probes | +| [`src/worktree.ts`](src/worktree.ts) | Worktree record, projection, and creation at admission and at the first step | +| [`src/git.ts`](src/git.ts) | Confined read-only Git commands | +| [`src/parse.ts`](src/parse.ts) | Git output parsing and remote order | +| [`src/client/mount.ts`](src/client/mount.ts) | Remote mount, slot entries, and action outcome mapping | +| [`src/client/ChangeRequestCard.tsx`](src/client/ChangeRequestCard.tsx) | Reusable change request card | +| [`src/client/WorktreeToggle.tsx`](src/client/WorktreeToggle.tsx) | New Session worktree option | +| [`src/client/WorktreeProgress.tsx`](src/client/WorktreeProgress.tsx) | Worktree creation and removed-worktree card | +| [`src/client/BranchChip.tsx`](src/client/BranchChip.tsx) | Session header branch label | + +
+ +----- + + +## Further Exploration + +- [Forge subsystem](../../../docs/subsystems/forge.md) — change request types and service reference. +- [Session bindings](../forge-session/README.md) — how pull requests bind to a Session. +- [Worktree service](../worktree/README.md) — how a worktree is created and entered. + +----- + + +## Model Experience + +Indirectly, through the working-directory service: its notice about a Session's new worktree joins the first request's messages, and Continue in the project queues the same kind of notice for the next request. Inspection, status, restoring, and the panel add nothing to model requests. + +#### KV Cache effect + +None for a new worktree, as its notice belongs to the first request of the Session, before any cached prefix exists. A notice after Continue in the project is appended after the cached prefix. + +## Known Limitations and Deferred Work + + + +- **Panel-only status** — status is polled only while a surface of this package is mounted for the Session; there are no notifications for checks that finish while none is. +- **Inspection triggers** — the checkout is inspected after Session tool results and directory changes; a change made outside the Session appears after Refresh or the next tool result. +- **Creation only** — the panel creates a worktree only before a Session's first step and never removes or prunes one; a failed or cancelled attempt can leave its branch or checkout behind. Branch names are random, not derived from the Session title, which does not exist yet. +- **Senders without admission** — only the Web Session Remote delivers messages through the agent controller's `agent-controller/admission` stage. A first message from another sender starts the turn, and the worktree is created at its first step; a message to a removed worktree enters, and its tools fail on the missing directory. +- **Restoring** — restoring recreates the checkout from the branch, or from the commit the branch started from when the branch is gone; changes that were never committed are lost with the removed directory. +- **Linked-worktree projects** — the option is unavailable when the project is itself a linked worktree: under workspace-write its Git directory lies outside the writable root, so neither the new branch nor later commits could be written. +- **Review page** — deferred: a left-navigation page that lists the user's pull requests from the selected provider; the [decision note](../../../.agents/notes/implemented/feature/2026-10-09-forge-panel.md) records where it attaches. + + +### Dev Note + +
+Working context for maintainers — click to expand + +None. + +
diff --git a/packages/experimental/client-ui-forge/README.zh.md b/packages/experimental/client-ui-forge/README.zh.md new file mode 100644 index 0000000000..0c1aa41099 --- /dev/null +++ b/packages/experimental/client-ui-forge/README.zh.md @@ -0,0 +1,112 @@ +--- +description: "展示 Session 创建的 pull request、并让新 Session 以 worktree 开始的 GUI 面板,以及为它们提供数据的宿主 Remote。" +kind: "package-reference" +--- + +# @deepseek-ai/dsh-experimental-client-ui-forge + +[English](README.md) | 中文 + +## 概述 + +这个可选的双端包展示 Session 创建或推送的 pull request,并让新 Session 在自己的 Git worktree 中开始。宿主端是 `forgePanel` Remote:它以只读方式检查 Session 的检出、识别代码托管平台、检查登录状态,以流的形式推送 Session 的[已绑定变更请求](../forge-session/README.zh.md)及其轮询状态,并在 Session 的第一条消息于 Agent 接纳阶段等待时创建所请求的 worktree。客户端增加一个右侧边栏标签页、Session 标题栏中的一个按钮与分支标签、一个新会话选项、输入框上方的创建卡片,以及报告操作结果的 toast 提示。 + +## 目录 + +- [使用本包](#use-this-package) +- [了解实现](#understand-the-implementation) +- [深入探索](#further-exploration) +- [模型体验](#model-experience) +- [已知限制与待办工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 使用本包 + +在插件页启用 [forge 套件](../forge-bundle/README.zh.md);它把本包与 forge 注册表、GitHub 提供方、Session 绑定以及它自己的 [worktree 服务](../worktree/README.zh.md)组合在一起。 + +- **工作树选项**——在新会话页面上,“工作树”位于工作区与模式选择器那一行的右端。勾选后显示基准分支,默认为当前分支;菜单列出最近的本地分支。不在 Git 检出中、Session 为只读,或检出及其 Git 目录不在 Session 的可写根内(例如项目本身就是链接 worktree)时,该选项不可用,提示中说明原因。发送第一条消息后,消息以“正在发送”的状态留在新会话页的输入框里,同时从基准分支在仓库的 `.agents/worktrees/` 下创建分支 `dsh/<8 位十六进制数>`,并把它的检出设为 Session 的工作目录;worktree 建好后宿主才接收这条消息。 +- **创建卡片**——创建 worktree 期间,输入框上方的卡片显示它的分支以及“准备工作空间”“检出文件”两个步骤;“更多详情”会列出分支与基准。“取消”停止创建,让消息回到可编辑状态,勾选保留。某次尝试失败时,消息回到可编辑状态,卡片显示原因:再次发送消息即换一个新分支名重试;“不使用工作树”撤回请求,下一次发送就在当前检出中进行。 +- **分支标签**——Session 标题栏显示 Session 所在的分支;它的提示说明所在的 worktree 及其基准,或当前检出。 +- **工作树已被删除**——Session 所在的 worktree 在 Session 之外被删除后,输入框上方的卡片会说明这一点;在恢复或离开该 worktree 之前,宿主会把每条消息退回输入框。“恢复工作树”在原目录重新检出该分支;分支也已删除时,从它开始时的提交重新创建分支。“在项目目录继续”把 Session 移回源检出。此期间分支标签显示警示色。 +- **标题栏按钮**——Session 有已绑定的 pull request、或其目录位于 Git 检出中时出现,用来显示或隐藏标签页。打开状态的 pull request 存在检查失败、被要求修改或冲突时,按钮带红点。 +- **标签页**——顶部一行说明 Session 开始时所在的 worktree 及其基准,或当前检出的分支。其下每张 pull request 卡片显示状态图标、标题、编号、分支、改动行数、检查,以及被要求修改、冲突、草稿中最需要注意的一项;已合并或已关闭的显示发生时间。展开卡片会增加“查看 PR”与复制链接、状态与绑定方式、带耗时的检查列表、评审结论与未解决讨论数、能否合并、“添加到聊天”(把 URL 插入输入框),以及“从会话中移除 PR”,其 toast 提供“撤销”。刷新会重新检查检出与登录状态,并立即再次轮询每个 pull request。缺少登录时,横幅显示 `gh auth login` 命令。 + +`gitCommand`、`gitTimeoutMs`、`graceMs`、`maxOutputBytes`、`readinessTimeoutMs`、`maxBranches` 与 `worktreePrefix` 列在[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-experimental-client-ui-forge)中。 + +----- + + +## 了解实现 + +
+实现细节——点击展开 + +`ForgePanelController` 为每个被关注的 Session 保留一个 `SessionPanel`,最后一个关注方离开时销毁它。面板从 Session 的工作目录与绑定开始,在每个 `working-directory/change`、`tool/result` 与 `sandbox/mode` 事件之后重新检查,每次变化都发布新的 `ForgePanelState`。每个变更请求在后续轮询失败时保留最近一次轮询得到的快照。检查一次只运行一个,最多再追加一次;Session 已离开的目录的结果会被丢弃。代码托管平台按远端顺序识别:先是上游所在的远端,然后是 `origin`,最后是其余远端。识别出的托管平台变化时以及刷新时,会在 `readinessTimeoutMs` 内检查登录状态。 + +检查运行 `rev-parse --show-toplevel`、`rev-parse --path-format=absolute --git-common-dir`、`symbolic-ref --short HEAD`、`rev-parse --abbrev-ref @{upstream}` 与 `remote -v`;`branches` 运行 `symbolic-ref` 与 `for-each-ref --count=`。这些命令都不读取工作树。每个命令都在 Session 的只读沙箱策略下运行,禁用可选的索引写入、钩子、文件系统监视器、提示与对象的延迟获取,移除继承的 Git 选择器与携带密钥的变量,并各自受 `gitTimeoutMs` 时限与每个输出流的 `maxOutputBytes` 上限约束。Git 打印的顶层目录与公共 Git 目录经 `ctx.fs` 解析为工作目录服务记录的规范进程路径。工作树选项在 `danger-full-access` 沙箱模式下可用,在 `read-only` 下不可用,在 `workspace-write` 下只有两个目录都位于 Session 沙箱策略的规范工作区根内时才可用。 + +worktree 请求是一条 `plugin:client-ui-forge/worktree` 记录,由仅宿主侧的 `forgeWorktree` 投影折叠:请求与撤回在第一个 `turn/start` 或创建之前生效;在第一个 `step/start` 之前,未完成请求的第一条 `created` 记录生效。一轮开始或 worktree 已创建之后,`requestWorktree` 与 `cancelWorktree` 以 `forgePanel/session-started` 拒绝;没有 `worktrees` 服务时以 `forgePanel/worktrees-unavailable` 拒绝;分支名为空时以 `forgePanel/invalid-branch` 拒绝;两者都会忘掉失败的尝试。一个 `agent-controller/admission` 监听器在 Session 有未完成请求时暂留消息,同时以 Session 所属 Agent 的维护任务运行一次尝试,使用新分支名,因此其他客户端此时发来的消息会等它完成;它在面板状态中发布正在进行的阶段并设置 `held`,记录带源仓库根目录的 `created`,然后放行消息。失败的尝试以 `failure` 留在面板状态中,带有分支、基准、失败步骤与原因,直到下一次尝试开始或请求变更,消息则以 `forge/worktree-creation-failed` 被拒绝。`stopCreation` 与 Agent 的取消以 `forge/worktree-creation-stopped` 拒绝消息,发送方的取消原样传出。在所创建 worktree 中工作的 Session,其每条消息在 worktree 缺失时以 `forge/worktree-missing` 被拒绝;检查无法给出结果时放行。有面板跟随该 Session 时,拒绝标记为 `presented`。worktree 服务把工作目录通知排进队列,由第一步认领。一个 `agent/pre-step` 监听器在请求仍未完成的 Session 的第 1 步创建 worktree,覆盖未经接纳就进入的消息;它把通知移入本步提议的消息,失败时这一轮结束、请求保留。`restoreWorktree` 与 `continueInProject` 同样以维护任务运行。套件把它的 `worktrees` 服务隔离,因此同时启用 Git Worktrees 时会有两个互不影响的服务。`track` 以 `forgePanel/unrecognized-url` 拒绝没有提供方识别的 URL。 + +每次检查还会判断 worktree 是否缺失:Session 的目录位于所创建的 worktree 内,`ctx.fs.lstat` 在那里找不到目录,再由源仓库中的 `show-ref` 判断分支是否还在。 + +浏览器入口先挂载 Remote,再挂载界面。每个 Session 一条流,同时供给标签页、标题栏按钮、分支标签、新会话选项与创建卡片;第一个订阅方出现时打开,最后一个离开时关闭,传输重置后重新打开,并为下一个订阅方保留最后的状态。新会话选项填入 `conversation.hero.options`,worktree 卡片填入 `conversation.input.dock`,分支标签填入 `conversation.session.header.actions`。worktree 卡片显示正在进行的创建,有消息在等待时“取消”调用 `stopCreation`;或显示最近一次失败,“不使用工作树”调用 `cancelWorktree`。等待中的消息由输入框自己显示为正在发送,被拒绝的消息留在输入框里。标签页把自己是否可见报告给标题栏按钮的按下状态。toast 位于 `shell.overlay` 条目中,因此比标签页存在得更久。变更请求卡片接收变更请求、它的快照与可选操作,其他界面可以复用。 + +| 源码 | 职责 | +|---|---| +| [`src/index.ts`](src/index.ts) | `forgePanel` Remote 与按 Session 的面板 | +| [`src/panel.ts`](src/panel.ts) | 面板状态、检查调度与登录检查 | +| [`src/worktree.ts`](src/worktree.ts) | worktree 记录、投影,以及在接纳阶段与第一步创建 worktree | +| [`src/git.ts`](src/git.ts) | 受限的只读 Git 命令 | +| [`src/parse.ts`](src/parse.ts) | Git 输出解析与远端顺序 | +| [`src/client/mount.ts`](src/client/mount.ts) | Remote 挂载、slot 条目与操作结果映射 | +| [`src/client/ChangeRequestCard.tsx`](src/client/ChangeRequestCard.tsx) | 可复用的变更请求卡片 | +| [`src/client/WorktreeToggle.tsx`](src/client/WorktreeToggle.tsx) | 新会话的工作树选项 | +| [`src/client/WorktreeProgress.tsx`](src/client/WorktreeProgress.tsx) | worktree 创建卡片与 worktree 缺失卡片 | +| [`src/client/BranchChip.tsx`](src/client/BranchChip.tsx) | Session 标题栏的分支标签 | + +
+ +----- + + +## 深入探索 + +- [Forge 子系统](../../../docs/subsystems/forge.zh.md)——变更请求类型与服务参考。 +- [Session 绑定](../forge-session/README.zh.md)——pull request 如何绑定到 Session。 +- [Worktree 服务](../worktree/README.zh.md)——worktree 如何创建并进入。 + +----- + + +## 模型体验 + +间接影响,经由工作目录服务:它关于 Session 新 worktree 的通知会加入第一次请求的消息,“在项目目录继续”会为下一次请求排入同类通知。检查、状态、恢复与面板都不会给模型请求增加内容。 + +#### KV Cache 影响 + +新 worktree 的通知没有影响,因为它属于 Session 的第一次请求,那时还没有任何缓存前缀。“在项目目录继续”之后的通知追加在缓存前缀之后。 + +## 已知限制与待办工作 + + + +- **状态只在面板中轮询**——只有本包为该 Session 挂载了某个界面时才会轮询状态;都未挂载时检查结束不会有通知。 +- **检查触发时机**——检出只在 Session 的工具结果与目录变化之后检查;Session 之外的改动要等到刷新或下一个工具结果才会出现。 +- **只负责创建**——面板只在 Session 的第一步之前创建 worktree,从不删除或清理;失败或被取消的尝试可能留下分支或检出。分支名是随机的,不取自 Session 标题,因为那时标题还不存在。 +- **不经接纳的发送方**——只有 Web Session Remote 经 agent controller 的 `agent-controller/admission` 阶段投递消息。其他发送方发出的第一条消息会直接开始这一轮,worktree 在第一步创建;发往已删除 worktree 的消息照常进入,其中的工具会因目录不存在而失败。 +- **恢复**——恢复会从分支重新检出;分支也已删除时,从它开始时的提交重新创建。从未提交的修改随被删除的目录一起丢失。 +- **链接 worktree 项目**——项目本身是链接 worktree 时该选项不可用:在 workspace-write 下它的 Git 目录位于可写根之外,新分支与之后的提交都无法写入。 +- **代码审查页**——待办:在左侧导航中新增一个页面,列出用户在所选提供方上的 pull request;[决策笔记](../../../.agents/notes/implemented/feature/2026-10-09-forge-panel.zh.md)记录了它的接入位置。 + + +### 开发备注 + +
+维护者的工作上下文——点击展开 + +无。 + +
diff --git a/packages/experimental/client-ui-forge/locale/en.json b/packages/experimental/client-ui-forge/locale/en.json new file mode 100644 index 0000000000..5eea7dfe8e --- /dev/null +++ b/packages/experimental/client-ui-forge/locale/en.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Pull request panel", + "description": "Shows a Session's pull requests in a sidebar tab and lets a new Session start in a worktree." + } +} diff --git a/packages/experimental/client-ui-forge/locale/zh.json b/packages/experimental/client-ui-forge/locale/zh.json new file mode 100644 index 0000000000..d496c0f34e --- /dev/null +++ b/packages/experimental/client-ui-forge/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "PR 面板", + "description": "在侧栏标签页中显示会话的 Pull Request,并让新会话可以在工作树中开始。" + } +} diff --git a/packages/experimental/client-ui-forge/package.json b/packages/experimental/client-ui-forge/package.json new file mode 100644 index 0000000000..b61008267f --- /dev/null +++ b/packages/experimental/client-ui-forge/package.json @@ -0,0 +1,129 @@ +{ + "name": "@deepseek-ai/dsh-experimental-client-ui-forge", + "description": "Sidebar panel for a Session's pull requests, with a worktree option for new Sessions and its Host Remote", + "version": "0.2.1-rc.2", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/experimental/client-ui-forge" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./remote": { + "types": "./lib/typert.remote-client.d.ts", + "default": "./lib/typert.remote-client.js" + }, + "./typert": { + "types": "./lib/typert.host.d.ts", + "default": "./lib/typert.host.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json", + "./locale/*.json": "./locale/*.json", + "./types": { + "types": "./lib/types/types.d.ts" + } + }, + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-api-remotes", + "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-client-ui-conversation", + "@deepseek-ai/dsh-client-ui-layout", + "@deepseek-ai/dsh-client-ui-sidebar-right", + "@deepseek-ai/dsh-client-ui-workspace" + ], + "platform": "web" + } + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-agent": "workspace:*", + "@deepseek-ai/dsh-agent-controller": "workspace:*", + "@deepseek-ai/dsh-experimental-forge": "workspace:*", + "@deepseek-ai/dsh-experimental-forge-session": "workspace:*", + "@deepseek-ai/dsh-experimental-worktree": "workspace:*", + "@deepseek-ai/dsh-fs": "workspace:*", + "@deepseek-ai/dsh-sandbox": "workspace:*", + "@deepseek-ai/dsh-sandbox-policy": "workspace:*", + "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/dsh-session-projection": "workspace:*", + "@deepseek-ai/dsh-subprocess": "workspace:*", + "@deepseek-ai/dsh-typert-protocol": "workspace:*", + "@deepseek-ai/dsh-working-directory": "workspace:*" + }, + "dependencies": { + "@deepseek-ai/schemastery": "workspace:~", + "@deepseek-ai/dsh-timeout": "workspace:*", + "@deepseek-ai/dsh-util-values": "workspace:*", + "zod": "^4.4.3" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-agent": "workspace:*", + "@deepseek-ai/dsh-agent-controller": "workspace:*", + "@deepseek-ai/dsh-agent-loop-testkit": "workspace:*", + "@deepseek-ai/dsh-api-remotes": "workspace:*", + "@deepseek-ai/dsh-api-session-controller": "workspace:*", + "@deepseek-ai/dsh-client-locale": "workspace:*", + "@deepseek-ai/dsh-client-store": "workspace:*", + "@deepseek-ai/dsh-client-test-runtime": "workspace:*", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:*", + "@deepseek-ai/dsh-client-ui-layout": "workspace:*", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:*", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:*", + "@deepseek-ai/dsh-client-ui-session": "workspace:*", + "@deepseek-ai/dsh-client-ui-sidebar-right": "workspace:*", + "@deepseek-ai/dsh-client-ui-slots": "workspace:*", + "@deepseek-ai/dsh-client-ui-workspace": "workspace:*", + "@deepseek-ai/dsh-experimental-forge": "workspace:*", + "@deepseek-ai/dsh-experimental-forge-session": "workspace:*", + "@deepseek-ai/dsh-experimental-worktree": "workspace:*", + "@deepseek-ai/dsh-fs": "workspace:*", + "@deepseek-ai/dsh-fs-local": "workspace:*", + "@deepseek-ai/dsh-llm": "workspace:*", + "@deepseek-ai/dsh-sandbox": "workspace:*", + "@deepseek-ai/dsh-sandbox-local": "workspace:*", + "@deepseek-ai/dsh-sandbox-policy": "workspace:*", + "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/dsh-session-projection": "workspace:*", + "@deepseek-ai/dsh-subprocess": "workspace:*", + "@deepseek-ai/dsh-subprocess-local": "workspace:*", + "@deepseek-ai/dsh-system-prompt": "workspace:*", + "@deepseek-ai/dsh-typert-protocol": "workspace:*", + "@deepseek-ai/dsh-working-directory": "workspace:*", + "@testing-library/react": "^16.3.3", + "clsx": "^2.1.1", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "files": [ + "locale/*.json", + "lib/index.js", + "lib/client.js", + "lib/types/**/*.d.ts", + "lib/typert.host.js", + "lib/typert.host.d.ts", + "lib/typert.remote-client.js", + "lib/typert.remote-client.d.ts" + ] +} diff --git a/packages/experimental/client-ui-forge/src/client/ActionToast.tsx b/packages/experimental/client-ui-forge/src/client/ActionToast.tsx new file mode 100644 index 0000000000..c2430c2f8d --- /dev/null +++ b/packages/experimental/client-ui-forge/src/client/ActionToast.tsx @@ -0,0 +1,38 @@ +/** App-wide notice for forge panel actions, rendered outside the panel so it outlives it. */ + +import type { ReactNode } from 'react' +import { IconWarningOutlineRegular, Toast } from '@deepseek-ai/dsh-client-ui-primitives' +import type { InjectFace, PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' +import type { NS } from './locales.ts' +import type { NoticeSource } from './source.ts' + +/** Full-opacity hold of a notice that offers to undo its action, long enough to reach the action. */ +const UNDO_HOLD_MS = 6000 + +/** Full props of the `shell.overlay` entry. */ +export type ActionToastProps = PropsLocale & InjectFace> + +/** + * Render the current notice: a success notice with its undo action, or a failure warning. + * @param props - notice hook, dismissal, and copy. + * @returns the toast, or nothing while no notice shows. + */ +export function ActionToast({ useNotice, dismiss, t }: ActionToastProps): ReactNode { + const notice = useNotice(current => current) + if (notice === null) return null + const { undo } = notice + return ( + }} + {...undo === undefined + ? {} + : { + holdMs: UNDO_HOLD_MS, + actions: [{ label: t('toast.undo'), onClick: () => { dismiss(); undo() } }], + }} + onDone={dismiss} + /> + ) +} diff --git a/packages/experimental/client-ui-forge/src/client/BranchChip.module.css b/packages/experimental/client-ui-forge/src/client/BranchChip.module.css new file mode 100644 index 0000000000..d5a19a295d --- /dev/null +++ b/packages/experimental/client-ui-forge/src/client/BranchChip.module.css @@ -0,0 +1,39 @@ +/* Branch label beside the Session title: a quiet filled chip in code type. */ +.chip { + --forge-fill: var(--dsw-static-neutral-50); + display: inline-flex; + flex: 0 1 auto; + align-items: center; + gap: 6px; + min-width: 0; + max-width: 240px; + height: 22px; + box-sizing: border-box; + padding: 0 8px; + border-radius: 6px; + background: var(--forge-fill); + color: var(--dsw-alias-label-secondary); + font-size: 12px; + line-height: 18px; +} + +:global(body[data-ds-dark-theme]) .chip { + --forge-fill: var(--dsw-static-neutral-850); +} + +/* The worktree the Session works in is gone. */ +.chip[data-missing] { + color: var(--dsw-alias-state-warn-primary); +} + +.glyph { + flex: none; +} + +.text { + min-width: 0; + overflow: hidden; + font-family: var(--ds-font-family-code); + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/packages/experimental/client-ui-forge/src/client/BranchChip.tsx b/packages/experimental/client-ui-forge/src/client/BranchChip.tsx new file mode 100644 index 0000000000..bfbe31958b --- /dev/null +++ b/packages/experimental/client-ui-forge/src/client/BranchChip.tsx @@ -0,0 +1,62 @@ +/** Session header label of the branch the Session works on, and of the worktree it started in. */ + +import type { ReactNode } from 'react' +import { IconBranchOutlineRegular, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' +import type { HostObservable, InjectFace, PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' +import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { ForgePanelState } from '../types.ts' +import type { NS } from './locales.ts' +import type { PanelSnapshot } from './source.ts' +import css from './BranchChip.module.css' + +/** Business face of the header label. */ +export interface BranchChipInjected { + /** Panel snapshot of the Session; the renderer binds it as `usePanel`. */ + readonly hooks: { readonly panel: HostObservable } +} + +/** Full props of the header label. */ +export type BranchChipProps = InjectFace & PropsLocale + +type Translate = BranchChipProps['t'] + +/** What the label shows: the branch, its explanation, and whether its worktree is gone. */ +interface BranchLabel { + readonly branch: string + readonly detail: string + readonly missing: boolean +} + +/** The label, or undefined outside a checkout with a known branch. */ +function labelOf(state: ForgePanelState | null, t: Translate): BranchLabel | undefined { + const created = state?.worktree.created ?? null + if (state !== null && state.worktree.missing !== null && created !== null) { + return { branch: created.branch, detail: t('branch.missing', { path: created.path }), missing: true } + } + if (state?.checkout.state !== 'ready') return undefined + const { root, branch } = state.checkout.checkout + if (created !== null && created.path === root) { + const detail = t('branch.worktree', { path: created.path, base: created.from ?? created.baseCommit.slice(0, 7) }) + return { branch: created.branch, detail, missing: false } + } + return branch === null ? undefined : { branch, detail: t('branch.current', { root }), missing: false } +} + +/** + * Render the branch label beside the Session title, in the warning tone while its worktree is gone. + * @param props - panel snapshot hook and copy. + * @returns the label, or nothing outside a checkout with a known branch. + */ +export function BranchChip({ usePanel, t }: BranchChipProps): ReactNode { + const state = usePanel(snapshot => snapshot.state) + const label = labelOf(state, t) + if (label === undefined) return null + return ( + + + + {label.branch} + + + ) +} diff --git a/packages/experimental/client-ui-forge/src/client/ChangeRequestCard.tsx b/packages/experimental/client-ui-forge/src/client/ChangeRequestCard.tsx new file mode 100644 index 0000000000..f60edaf25c --- /dev/null +++ b/packages/experimental/client-ui-forge/src/client/ChangeRequestCard.tsx @@ -0,0 +1,328 @@ +/** One change request as a collapsible card: a status summary, and its details and actions when expanded. */ + +import { useState, type ReactNode } from 'react' +import clsx from 'clsx' +import { + Button, IconBranchOutlineRegular, IconCheckOutlineRegular, IconChecklistOutlineRegular, IconChevronDownOutlineRegular, + IconCloseOutlineRegular, IconContextInjectionOutlineRegular, IconCopyOutlineRegular, IconInfoOutlineRegular, + IconRightUpOutlineRegular, IconTrashOutlineRegular, IconUsersOutlineRegular, StateDot, Tooltip, +} from '@deepseek-ai/dsh-client-ui-primitives' +import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' +import { assertNever } from '@deepseek-ai/dsh-util-values' +import type { ChangeRequestCheck, ChangeRequestSnapshot, ForgeFailure } from '@deepseek-ai/dsh-experimental-forge/types' +import type { BindingOrigin } from '@deepseek-ai/dsh-experimental-forge-session/types' +import type { ForgePanelChangeRequest } from '../types.ts' +import { formatAgo, formatDuration } from './format.ts' +import type { ForgePanelKey, NS } from './locales.ts' +import { checkCounts, checkDuration, ciSummary, flagOf, numberOf, toneOf } from './status.ts' +import css from './ForgeTab.module.css' + +type Translate = PropsLocale['t'] + +/** What a card can do; a surface without a composer or Session binding omits the optional actions. */ +export interface ChangeRequestActions { + /** Open a URL outside the app. */ + readonly openLink: (url: string) => void + /** Copy a URL to the clipboard. */ + readonly copyLink: (url: string) => void + /** Read the status again after a failed read. */ + readonly retry: () => void + /** Insert the change request URL into the composer. */ + readonly addToChat?: ((url: string) => void) | undefined + /** Remove the change request from its Session. */ + readonly remove?: ((entry: ForgePanelChangeRequest) => void) | undefined +} + +/** Props of one change request card. */ +export interface ChangeRequestCardProps { + /** Change request with its latest snapshot and polling result. */ + readonly entry: ForgePanelChangeRequest + /** Whether the details show. */ + readonly expanded: boolean + /** Show or hide the details. */ + readonly onToggle: () => void + /** Current epoch milliseconds for relative times. */ + readonly now: number + /** Card actions. */ + readonly actions: ChangeRequestActions + /** Forge panel copy. */ + readonly t: Translate +} + +const ORIGIN: Record = { + created: 'change.origin.created', + discovered: 'change.origin.discovered', + tracked: 'change.origin.tracked', +} + +const REVIEW: Record = { + 'approved': 'change.review.approved', + 'changes-requested': 'change.review.changesRequested', + 'review-required': 'change.review.required', + 'none': 'change.review.none', +} + +const MERGE: Record = { + mergeable: 'change.merge.mergeable', + conflicting: 'change.merge.conflicting', + unknown: 'change.merge.unknown', +} + +const FAILURE: Record = { + 'not-found': 'change.failure.notFound', + 'unauthenticated': 'change.failure.unauthenticated', + 'unavailable': 'change.failure.unavailable', +} + +function stateKey(snapshot: ChangeRequestSnapshot): ForgePanelKey { + if (snapshot.state === 'open') return snapshot.draft ? 'change.state.draft' : 'change.state.open' + return snapshot.state === 'merged' ? 'change.state.merged' : 'change.state.closed' +} + +function failureOf(entry: ForgePanelChangeRequest): ForgeFailure | undefined { + return entry.update !== null && 'failure' in entry.update ? entry.update.failure : undefined +} + +function Diff({ snapshot }: { readonly snapshot: ChangeRequestSnapshot }): ReactNode { + return ( + + {`+${String(snapshot.additions)}`} + {`−${String(snapshot.deletions)}`} + + ) +} + +function Ci({ snapshot, t }: { readonly snapshot: ChangeRequestSnapshot; readonly t: Translate }): ReactNode { + const ci = ciSummary(snapshot) + switch (ci.kind) { + case 'none': return {t('change.ci.none')} + case 'running': return {t('change.ci.running')} + case 'passed': return {t('change.ci.passed')} + case 'failed': return ( + + + {ci.count > 0 ? t('change.ci.failed', { count: ci.count }) : t('change.ci.failedUnlisted')} + + ) + /* v8 ignore next -- closed CiSummary kind union exhaustiveness guard */ + default: return assertNever(ci, 'CiSummary.kind') + } +} + +/** Status line inside the toggle: diff size, then checks and the open-state flag, or the merge or close time. */ +function Summary({ entry, now, t }: { readonly entry: ForgePanelChangeRequest; readonly now: number; readonly t: Translate }): ReactNode { + const snapshot = entry.snapshot + if (entry.ref === null || failureOf(entry) !== undefined) return null + if (snapshot === null) { + return {t('change.loading')} + } + if (snapshot.state !== 'open') { + const ago = snapshot.closedAt === null ? undefined : formatAgo(snapshot.closedAt, now, t) + const state = t(stateKey(snapshot)) + return ( + + + {ago === undefined ? state : t('time.finished', { state, time: ago })} + + ) + } + const flag = flagOf(snapshot) + return ( + + + + {flag !== undefined && {t(`change.flag.${flag}`)}} + + ) +} + +function CheckIcon({ check }: { readonly check: ChangeRequestCheck }): ReactNode { + switch (check.state) { + case 'success': return + case 'failure': return + case 'pending': return + case 'neutral': + case 'skipped': + case 'cancelled': return + /* v8 ignore next -- closed CheckState union exhaustiveness guard */ + default: return assertNever(check.state, 'ChangeRequestCheck.state') + } +} + +const CHECK_RANK: Record = { + failure: 0, pending: 1, success: 2, neutral: 3, skipped: 3, cancelled: 3, +} + +/** Failed checks first, then running ones, then the rest, each group in host order. */ +function orderedChecks(checks: readonly ChangeRequestCheck[]): ChangeRequestCheck[] { + return checks.toSorted((left, right) => CHECK_RANK[left.state] - CHECK_RANK[right.state]) +} + +function checkTime(check: ChangeRequestCheck, t: Translate): string { + if (check.state === 'pending') return t(check.startedAt === null ? 'change.check.queued' : 'change.check.running') + const elapsed = checkDuration(check) + return elapsed === undefined ? '' : formatDuration(elapsed, t) +} + +function Checks({ snapshot, openLink, t }: { + readonly snapshot: ChangeRequestSnapshot + readonly openLink: (url: string) => void + readonly t: Translate +}): ReactNode { + const [open, setOpen] = useState(false) + const counts = checkCounts(snapshot.checks) + if (snapshot.checks.length === 0) { + return } label={t('change.row.checks')} value={t('change.ci.none')} /> + } + const summary = [ + ...counts.failed > 0 ? [t('change.checks.failed', { count: counts.failed })] : [], + ...counts.running > 0 ? [t('change.checks.running', { count: counts.running })] : [], + ...counts.passed > 0 ? [t('change.checks.passed', { count: counts.passed })] : [], + ...counts.other > 0 ? [t('change.checks.other', { count: counts.other })] : [], + ].join(' · ') + return ( + <> + + {open && ( +
    + {orderedChecks(snapshot.checks).map((check, index) => { + const body = ( + <> + + {check.name} + {checkTime(check, t)} + + ) + const url = check.url + return ( +
  • + {url === null + ?
    {body}
    + : } +
  • + ) + })} + {snapshot.checksTruncated &&
  • {t('change.checks.truncated', { count: snapshot.checks.length })}
  • } +
+ )} + + ) +} + +function Row({ icon, label, value, tone }: { + readonly icon: ReactNode + readonly label: string + readonly value: string + readonly tone?: 'error' | undefined +}): ReactNode { + return ( +
+ {icon} + {label} + {value} +
+ ) +} + +function Details({ entry, url, actions, t }: { + readonly entry: ForgePanelChangeRequest + readonly url: string + readonly actions: ChangeRequestActions + readonly t: Translate +}): ReactNode { + const snapshot = entry.snapshot + const { addToChat, remove } = actions + const review = snapshot === null ? '' : t(REVIEW[snapshot.reviewDecision]) + return ( +
+
+ + + + +
+ {snapshot !== null && ( + <> + } label={t('change.row.state')} value={`${t(stateKey(snapshot))} · ${t(ORIGIN[entry.origin])}`} /> + + } + label={t('change.row.review')} + value={snapshot.unresolvedThreads > 0 ? t('change.review.threads', { review, count: snapshot.unresolvedThreads }) : review} + tone={snapshot.reviewDecision === 'changes-requested' ? 'error' : undefined} + /> + } + label={t('change.row.merge')} + value={snapshot.state === 'open' ? t(MERGE[snapshot.mergeability]) : t(stateKey(snapshot))} + tone={snapshot.state === 'open' && snapshot.mergeability === 'conflicting' ? 'error' : undefined} + /> + + )} + {(addToChat !== undefined || remove !== undefined) &&
} + {addToChat !== undefined && ( + + )} + {remove !== undefined && ( + + )} +
+ ) +} + +/** + * Render one change request. + * @param props - change request, expansion, current time, actions, and copy. + * @returns the card. + */ +export function ChangeRequestCard({ entry, expanded, onToggle, now, actions, t }: ChangeRequestCardProps): ReactNode { + const snapshot = entry.snapshot + const number = numberOf(entry) + const url = snapshot?.url ?? entry.url + const failure = failureOf(entry) + const notice = entry.ref === null ? 'change.unserved' : failure === undefined ? undefined : FAILURE[failure.code] + return ( +
  • + + {notice !== undefined && ( +
    + {t(notice)} + {entry.ref === null + ? + : } +
    + )} + {expanded &&
    } +
  • + ) +} diff --git a/packages/experimental/client-ui-forge/src/client/ForgeTab.module.css b/packages/experimental/client-ui-forge/src/client/ForgeTab.module.css new file mode 100644 index 0000000000..b87a4d71ae --- /dev/null +++ b/packages/experimental/client-ui-forge/src/client/ForgeTab.module.css @@ -0,0 +1,490 @@ +/* Forge tab body: one scrolling column with the checkout row on top and the + Session's change request cards below, at the sidebar's 13px row size. */ +.root { + --forge-fill: var(--dsw-static-neutral-50); + display: flex; + flex-direction: column; + gap: 10px; + height: 100%; + min-height: 0; + box-sizing: border-box; + padding: 12px; + overflow-y: auto; + color: var(--dsw-alias-label-primary); + font-size: var(--dsh-content-font-size-secondary, 13px); + line-height: 20px; +} + +:global(body[data-ds-dark-theme]) .root { + --forge-fill: var(--dsw-static-neutral-850); +} + +/* Loading and disconnected states of a panel without state yet. */ +.centered { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + height: 100%; + box-sizing: border-box; + padding: 24px; + color: var(--dsw-alias-label-secondary); + font-size: var(--dsh-content-font-size-secondary, 13px); + text-align: center; +} + +.disconnected { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + color: var(--dsw-alias-label-secondary); +} + +.checkout { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + padding: 8px 10px; + border-radius: var(--dsw-radius-sm); + background: var(--forge-fill); + color: var(--dsw-alias-label-secondary); + font-size: 12px; + line-height: 18px; +} + +.checkout:focus-visible { + outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); + outline-offset: 2px; +} + +.checkoutIcon { + flex: none; +} + +.checkoutBranch { + min-width: 0; + overflow: hidden; + color: var(--dsw-alias-label-primary); + font-family: var(--ds-font-family-code); + text-overflow: ellipsis; + white-space: nowrap; +} + +.checkoutNote { + flex: none; + margin-left: auto; +} + +.section { + display: flex; + flex-direction: column; + gap: 8px; + min-width: 0; +} + +.sectionHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 4px 2px 0; +} + +.sectionTitle { + margin: 0; + min-width: 0; + overflow: hidden; + color: var(--dsw-alias-label-secondary); + font-size: 13px; + font-weight: 500; + line-height: 20px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.iconButton { + display: inline-flex; + flex: none; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + border: 0; + border-radius: var(--dsw-radius-sm); + background: transparent; + color: var(--dsw-alias-label-tertiary); + cursor: pointer; +} + +.iconButton:hover, +.iconButton:focus-visible { + background: var(--dsw-alias-interactive-bg-hover); + color: var(--dsw-alias-label-primary); +} + +.banner { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 8px; + padding: 10px 12px; + border-radius: var(--dsw-radius-sm); + background: var(--forge-fill); + color: var(--dsw-alias-label-primary); +} + +.bannerDetail { + color: var(--dsw-alias-label-secondary); + font-size: 12px; + line-height: 18px; + overflow-wrap: anywhere; +} + +.bannerCommand { + max-width: 100%; + color: var(--dsw-alias-label-secondary); + font-family: var(--ds-font-family-code); + font-size: 12px; + line-height: 18px; + overflow-wrap: anywhere; +} + +.bannerAction { + flex: none; +} + +.empty { + padding: 32px 16px; + text-align: center; +} + +.emptyTitle { + margin: 0; + color: var(--dsw-alias-label-primary); +} + +.emptyHint { + margin: 4px 0 0; + color: var(--dsw-alias-label-tertiary); + font-size: 12px; + line-height: 18px; +} + +.list { + display: flex; + flex-direction: column; + gap: 8px; + margin: 0; + padding: 0; + list-style: none; +} + +.change { + min-width: 0; + border: 0.5px solid var(--dsw-alias-border-l2); + border-radius: var(--dsw-radius-md); + background: var(--dsw-alias-bg-layer-1); +} + +/* The whole summary is the expansion toggle. */ +.changeHead { + display: flex; + flex-direction: column; + gap: 4px; + box-sizing: border-box; + width: 100%; + min-width: 0; + padding: 10px 10px 10px 12px; + border: 0; + border-radius: var(--dsw-radius-md); + background: transparent; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; +} + +.changeHead:hover { + background: var(--dsw-alias-interactive-bg-hover); +} + +.changeHead:focus-visible { + outline: none; + box-shadow: inset 0 0 0 2px var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); +} + +.headRow { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + min-width: 0; +} + +/* Lifecycle tone of the change request glyph, which draws in the current color. */ +.glyph { + display: inline-flex; + flex: none; + color: var(--dsw-alias-label-tertiary); +} + +.glyph[data-tone='open'] { + color: var(--dsw-alias-state-success-primary); +} + +.glyph[data-tone='merged'] { + color: var(--dsw-alias-state-business-primary); +} + +.glyph[data-tone='closed'] { + color: var(--dsw-alias-state-error-primary); +} + +.changeTitle { + flex: 1; + min-width: 0; + overflow: hidden; + font-weight: 500; + text-overflow: ellipsis; + white-space: nowrap; +} + +.chevron { + flex: none; + color: var(--dsw-alias-label-tertiary); + transition: transform 120ms ease; +} + +.chevronOpen { + transform: rotate(180deg); +} + +/* Number, branch, and status lines align with the title past the 15px glyph and its 8px gap. */ +.metaRow, +.summary { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; + padding-left: 23px; + color: var(--dsw-alias-label-tertiary); + font-size: 12px; + line-height: 18px; +} + +.summary { + flex-wrap: wrap; + gap: 4px 10px; +} + +.code { + font-family: var(--ds-font-family-code); +} + +.ellipsis { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.diff { + display: inline-flex; + gap: 6px; + font-family: var(--ds-font-family-code); +} + +.added { + color: var(--dsw-alias-state-success-primary); +} + +.deleted { + color: var(--dsw-alias-state-error-primary); +} + +.ci { + display: inline-flex; + align-items: center; + gap: 5px; + color: var(--dsw-alias-label-secondary); +} + +.ci[data-tone='error'], +.flag[data-flag='changesRequested'], +.flag[data-flag='conflicting'] { + color: var(--dsw-alias-state-error-primary); +} + +.flag { + color: var(--dsw-alias-label-tertiary); +} + +.finished { + color: var(--dsw-alias-label-secondary); +} + +.finished[data-tone='merged'] { + color: var(--dsw-alias-state-business-primary); +} + +/* Read failure or unsupported host below the toggle, with its one action. */ +.notice { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 0 10px 10px 35px; + color: var(--dsw-alias-label-secondary); + font-size: 12px; + line-height: 18px; +} + +.details { + display: flex; + flex-direction: column; + padding: 4px 4px 6px; + border-top: 0.5px solid var(--dsw-alias-border-l2); +} + +.linkRow { + display: flex; + align-items: center; + gap: 2px; +} + +.linkRow .item { + flex: 1; +} + +/* One detail row: a 15px glyph, the label, and a right-aligned value. */ +.item { + display: flex; + align-items: center; + gap: 10px; + box-sizing: border-box; + width: 100%; + min-width: 0; + min-height: 34px; + padding: 0 10px; + border: 0; + border-radius: var(--dsw-radius-sm); + background: transparent; + color: var(--dsw-alias-label-primary); + font: inherit; + text-align: left; +} + +button.item { + cursor: pointer; +} + +button.item:hover, +button.item:focus-visible { + background: var(--dsw-alias-interactive-bg-hover); +} + +.item svg { + flex: none; +} + +.item[data-tone='error'] { + color: var(--dsw-alias-state-error-primary); +} + +button.item[data-tone='error']:hover, +button.item[data-tone='error']:focus-visible { + background: var(--dsw-alias-interactive-bg-hover-danger); +} + +.value { + min-width: 0; + margin-left: auto; + overflow: hidden; + color: var(--dsw-alias-label-secondary); + text-overflow: ellipsis; + white-space: nowrap; +} + +.value[data-tone='error'] { + color: var(--dsw-alias-state-error-primary); +} + +.separator { + height: 0.5px; + margin: 4px 6px; + background: var(--dsw-alias-border-l2); +} + +.checks { + display: flex; + flex-direction: column; + margin: 0 6px 4px 34px; + padding: 2px 0; + border-left: 0.5px solid var(--dsw-alias-border-l2); + list-style: none; +} + +.check { + display: flex; + align-items: center; + gap: 8px; + box-sizing: border-box; + width: 100%; + min-height: 30px; + padding: 0 10px; + border: 0; + border-radius: var(--dsw-radius-sm); + background: transparent; + color: var(--dsw-alias-label-primary); + font: inherit; + font-size: 12px; + text-align: left; +} + +button.check { + cursor: pointer; +} + +button.check:hover, +button.check:focus-visible { + background: var(--dsw-alias-interactive-bg-hover); +} + +.check svg { + flex: none; +} + +.check .value { + flex: none; + color: var(--dsw-alias-label-tertiary); +} + +.pass { + color: var(--dsw-alias-state-success-primary); +} + +.fail { + color: var(--dsw-alias-state-error-primary); +} + +.truncated { + padding: 4px 10px; + color: var(--dsw-alias-label-tertiary); + font-size: 12px; +} + +.titleIcon { + flex: none; + color: var(--dsw-alias-label-secondary); +} + +.titleText { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/packages/experimental/client-ui-forge/src/client/ForgeTab.tsx b/packages/experimental/client-ui-forge/src/client/ForgeTab.tsx new file mode 100644 index 0000000000..8e24f334b1 --- /dev/null +++ b/packages/experimental/client-ui-forge/src/client/ForgeTab.tsx @@ -0,0 +1,176 @@ +/** Body of the forge sidebar tab: where the Session works and the pull requests it created or pushed. */ + +import { useEffect, useState, type ReactNode } from 'react' +import { + Button, IconBranchOutlineRegular, IconRefreshOutlineRegular, StateDot, Tooltip, +} from '@deepseek-ai/dsh-client-ui-primitives' +import type { HostObservable, InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' +import type {} from '@deepseek-ai/dsh-client-ui-sidebar-right/client' +import type { ForgePanelChangeRequest, ForgePanelState } from '../types.ts' +import { ChangeRequestCard, type ChangeRequestActions } from './ChangeRequestCard.tsx' +import type { ForgePanelKey, NS } from './locales.ts' +import type { PanelSnapshot } from './source.ts' +import css from './ForgeTab.module.css' + +/** Business face of the tab body. */ +export interface ForgeTabInjected { + /** Panel snapshot of the Session; the renderer binds it as `usePanel`. */ + readonly hooks: { readonly panel: HostObservable } + /** Open the stream again after it ended. */ + readonly reconnect: () => void + /** Reinspect the checkout, probe sign-in, and poll the change requests. */ + readonly refresh: () => void + /** Remove a change request from the Session, offering to undo it. */ + readonly remove: (entry: ForgePanelChangeRequest) => void + /** Open a URL outside the app. */ + readonly openLink: (url: string) => void + /** Copy text to the clipboard and report the outcome. */ + readonly copyText: (text: string) => void + /** Show one notice. */ + readonly report: (key: ForgePanelKey) => void + /** Record whether the tab is visible, for the header entry's pressed state. */ + readonly reportVisible: (visible: boolean) => void +} + +/** Full props of the tab body. */ +export type ForgeTabProps = + Pick, 'useTabInfo' | 'inputActions'> + & InjectFace + & PropsLocale + +type Translate = ForgeTabProps['t'] + +function Checkout({ state, t }: { readonly state: ForgePanelState; readonly t: Translate }): ReactNode { + const checkout = state.checkout + if (checkout.state === 'pending') return null + if (checkout.state === 'none') return
    {t('checkout.none')}
    + if (checkout.state === 'failed') { + return ( + +
    {t('checkout.failed')}
    +
    + ) + } + const created = state.worktree.created + const worktree = created !== null && created.path === checkout.checkout.root ? created : null + const branch = worktree?.branch ?? checkout.checkout.branch + return ( + +
    + + {t(worktree === null ? 'checkout.branch' : 'checkout.worktree')} + {branch ?? t('checkout.detached')} + + {worktree === null + ? t('checkout.current') + : t('checkout.basedOn', { base: worktree.from ?? worktree.baseCommit.slice(0, 7) })} + +
    +
    + ) +} + +function SignIn({ state, copyText, t }: { + readonly state: ForgePanelState + readonly copyText: (text: string) => void + readonly t: Translate +}): ReactNode { + const { repository, readiness } = state + if (repository === null || readiness === null || readiness.state === 'ready') return null + const host = repository.host + if (readiness.state === 'unavailable') { + return
    {t('signin.unavailable', { host, message: readiness.message ?? '' })}
    + } + const command = repository.forge === 'github' ? `gh auth login --hostname ${host}` : undefined + return ( +
    + {t('signin.title', { host })} + {command === undefined + ? readiness.message !== null && {readiness.message} + : ( + <> + {command} + + + )} +
    + ) +} + +/** + * Render the forge tab of one Session. + * @param props - tab information, composer actions, panel snapshot hook, actions, and copy. + * @returns the tab body. + */ +export function ForgeTab({ + useTabInfo, inputActions, usePanel, reconnect, refresh, remove, openLink, copyText, report, reportVisible, t, +}: ForgeTabProps): ReactNode { + const snapshot = usePanel(current => current) + const visible = useTabInfo().tab.visible + const [expanded, setExpanded] = useState(null) + useEffect(() => { + reportVisible(visible) + }, [reportVisible, visible]) + useEffect(() => () => { reportVisible(false) }, [reportVisible]) + const state = snapshot.state + const disconnected = snapshot.disconnected && ( +
    + {t('stream.failed')} + +
    + ) + if (state === null) return
    {disconnected || }
    + const actions: ChangeRequestActions = { + openLink, + copyLink: copyText, + retry: refresh, + addToChat: (url) => { + if (!inputActions.insertText(`${url} `, inputActions.captureInsertion())) report('toast.chatFailed') + }, + remove, + } + const now = Date.now() + const count = state.changeRequests.length + return ( +
    + {disconnected} + +
    +
    +

    {t('changes.title', { count })}

    + + + +
    + + {count === 0 + ? ( +
    +

    {t('changes.empty')}

    +

    {t('changes.emptyHint')}

    +
    + ) + : ( +
      + {state.changeRequests.map(entry => ( + { setExpanded(expanded === entry.url ? null : entry.url) }} + now={now} + actions={actions} + t={t} + /> + ))} +
    + )} +
    +
    + ) +} diff --git a/packages/experimental/client-ui-forge/src/client/ForgeTabTitle.tsx b/packages/experimental/client-ui-forge/src/client/ForgeTabTitle.tsx new file mode 100644 index 0000000000..cd3a10d5de --- /dev/null +++ b/packages/experimental/client-ui-forge/src/client/ForgeTabTitle.tsx @@ -0,0 +1,21 @@ +/** Chip title of the forge tab: the branch glyph and the localized tab name. */ + +import type { ReactNode } from 'react' +import { IconBranchOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' +import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' +import type { NS } from './locales.ts' +import css from './ForgeTab.module.css' + +/** + * Render the tab chip title in the current UI language. + * @param props - forge panel copy. + * @returns the glyph followed by the tab name. + */ +export function ForgeTabTitle({ t }: PropsLocale): ReactNode { + return ( + <> + + {t('tab.title')} + + ) +} diff --git a/packages/experimental/client-ui-forge/src/client/HeaderButton.module.css b/packages/experimental/client-ui-forge/src/client/HeaderButton.module.css new file mode 100644 index 0000000000..5d6fffdf1a --- /dev/null +++ b/packages/experimental/client-ui-forge/src/client/HeaderButton.module.css @@ -0,0 +1,35 @@ +/* Icon-only header entry: the 28px round target of the header's other icon + buttons, with the status dot at the glyph's top-right corner. */ +.trigger { + position: relative; + display: inline-flex; + flex: none; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + border: 0; + border-radius: 28px; + background: transparent; + color: var(--dsw-alias-label-tertiary); + cursor: pointer; +} + +.trigger:hover, +.trigger:focus-visible { + color: var(--dsw-alias-label-secondary); + background: var(--dsw-alias-interactive-bg-hover); +} + +/* The forge tab is visible. */ +.trigger[aria-pressed='true'] { + color: var(--dsw-alias-label-primary); + background: var(--dsw-alias-interactive-bg-active); +} + +.dot { + position: absolute; + top: 6px; + right: 6px; +} diff --git a/packages/experimental/client-ui-forge/src/client/HeaderButton.tsx b/packages/experimental/client-ui-forge/src/client/HeaderButton.tsx new file mode 100644 index 0000000000..cd265a9f58 --- /dev/null +++ b/packages/experimental/client-ui-forge/src/client/HeaderButton.tsx @@ -0,0 +1,67 @@ +/** Session header entry that shows or hides the forge tab and marks pull requests that need attention. */ + +import type { ReactNode } from 'react' +import { IconBranchOutlineRegular, StateDot, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' +import type { HostObservable, InjectFace, PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' +import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { NS } from './locales.ts' +import type { PanelSnapshot } from './source.ts' +import { needsAttention } from './status.ts' +import css from './HeaderButton.module.css' + +/** Business face of the header entry. */ +export interface HeaderButtonInjected { + /** Panel snapshot and tab visibility of the Session; the renderer binds them as `usePanel` and `useVisible`. */ + readonly hooks: { readonly panel: HostObservable; readonly visible: HostObservable } + /** + * Hide the forge tab when it is visible, otherwise open or reveal it. + * @param visible - whether the tab is visible now. + */ + readonly toggle: (visible: boolean) => void +} + +/** Full props of the header entry. */ +export type HeaderButtonProps = InjectFace & PropsLocale + +/** + * Show the entry once the Session has bound change requests or its directory + * is known to be inside a Git checkout: after an inspection found one or + * failed, or while a later inspection runs for a directory whose repository + * is already known. + * @param snapshot - panel snapshot. + * @returns whether the entry shows. + */ +function shown(snapshot: PanelSnapshot): boolean { + const state = snapshot.state + if (state === null) return false + return state.changeRequests.length > 0 || state.checkout.state === 'ready' || state.checkout.state === 'failed' + || (state.checkout.state === 'pending' && state.repository !== null) +} + +/** + * Render the header entry. + * @param props - panel snapshot and visibility hooks, the toggle, and copy. + * @returns the entry, or nothing outside a Git checkout without bound change requests. + */ +export function HeaderButton({ usePanel, useVisible, toggle, t }: HeaderButtonProps): ReactNode { + const visible = usePanel(shown) + const attention = usePanel(snapshot => snapshot.state?.changeRequests.some(needsAttention) === true) + const pressed = useVisible(current => current) + if (!visible) return null + const label = t(attention ? 'header.attention' : 'header.label') + return ( + + + + ) +} diff --git a/packages/experimental/client-ui-forge/src/client/WorktreeProgress.module.css b/packages/experimental/client-ui-forge/src/client/WorktreeProgress.module.css new file mode 100644 index 0000000000..26b0d3a374 --- /dev/null +++ b/packages/experimental/client-ui-forge/src/client/WorktreeProgress.module.css @@ -0,0 +1,220 @@ +/* Worktree creation card in the composer context stack: the dock column and + panel surface of the Goal, Todo, and Queue cards. */ +.dock { + box-sizing: border-box; + width: calc( + 100% - + var(--dsh-composer-side-clearance) - + var(--dsh-composer-side-clearance) - + var(--dsh-composer-dock-inset) - + var(--dsh-composer-dock-inset) - + var(--dsh-composer-dock-inset) - + var(--dsh-composer-dock-inset) + ); + margin: 0 auto; +} + +.card { + position: relative; + isolation: isolate; + box-sizing: border-box; + display: flex; + flex-direction: column; + gap: 6px; + width: 100%; + max-width: calc(var(--dsh-composer-card-max-width) - 4 * var(--dsh-composer-dock-inset)); + margin: 0 auto; + padding: 10px 8px 6px 12px; + border: 0; + --dsw-elevation-stroke-color: var(--dsw-alias-border-l1); + border-radius: var(--dsw-radius-md); + box-shadow: var(--dsw-elevation-panel); + color: var(--dsw-alias-label-primary); + font-size: 13px; + line-height: 20px; + /* Elevated surface, as the menu background below makes it: the failure text + scrolls once it reaches its height cap, so the thumb takes the l2 pair. + Declared on the card, whose custom properties inherit down to the box that + scrolls (see ui-theme styles/scrollbar.css for the rebinding contract). */ + --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2); + --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); +} + +.card::before { + position: absolute; + z-index: -1; + inset: 0; + border-radius: inherit; + background: var(--dsw-specific-menu); + backdrop-filter: var(--dsw-menu-backdrop-filter); + content: ''; + pointer-events: none; +} + +.header { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + padding-right: 4px; +} + +.glyph { + flex: none; + color: var(--dsw-alias-label-tertiary); +} + +.warning { + flex: none; + color: var(--dsw-alias-state-warn-primary); +} + +.title { + flex: none; + font-weight: 500; +} + +.branch, +.code { + min-width: 0; + overflow: hidden; + color: var(--dsw-alias-label-tertiary); + font-family: var(--ds-font-family-code); + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.steps { + display: flex; + flex-direction: column; + gap: 2px; + margin: 0; + padding: 0 0 0 22px; + list-style: none; +} + +.step { + display: flex; + align-items: center; + gap: 8px; + min-height: 24px; + color: var(--dsw-alias-label-secondary); +} + +.step[data-state='running'] { + color: var(--dsw-alias-label-primary); +} + +.step[data-state='failed'] { + color: var(--dsw-alias-state-error-primary); +} + +.step svg { + flex: none; +} + +.done { + color: var(--dsw-alias-state-success-primary); +} + +.failed { + color: var(--dsw-alias-state-error-primary); +} + +/* An empty ring for a step that has not started, the size of the step glyphs. */ +.waiting { + flex: none; + box-sizing: border-box; + width: 14px; + height: 14px; + border: 1.5px solid var(--dsw-alias-label-dimmed); + border-radius: 50%; + corner-shape: round; +} + +.details { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 2px 12px; + margin: 2px 0 0 22px; + color: var(--dsw-alias-label-secondary); + font-size: 12px; + line-height: 18px; +} + +.details dt { + color: var(--dsw-alias-label-tertiary); +} + +.details dd { + margin: 0; +} + +.message { + max-height: 120px; + overflow-y: auto; + overflow-wrap: anywhere; + white-space: pre-wrap; +} + +.footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + min-height: 28px; +} + +.disclosure { + display: inline-flex; + align-items: center; + gap: 4px; + height: 28px; + padding: 0 6px; + border: 0; + border-radius: var(--dsw-radius-sm); + background: transparent; + color: var(--dsw-alias-label-tertiary); + font: inherit; + font-size: 12px; + cursor: pointer; +} + +.disclosure:hover, +.disclosure:focus-visible { + background: var(--dsw-alias-interactive-bg-hover); + color: var(--dsw-alias-label-secondary); +} + +.chevron, +.chevronOpen { + flex: none; + transition: transform 120ms ease; +} + +.chevronOpen { + transform: rotate(90deg); +} + +.actions { + display: inline-flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 4px; + margin-left: auto; +} + +/* Explanation and restore failure under the header of the removed-worktree card. */ +.note, +.error { + margin: 0 0 0 22px; + color: var(--dsw-alias-label-secondary); + font-size: 12px; + line-height: 18px; +} + +.error { + color: var(--dsw-alias-state-error-primary); + overflow-wrap: anywhere; +} diff --git a/packages/experimental/client-ui-forge/src/client/WorktreeProgress.tsx b/packages/experimental/client-ui-forge/src/client/WorktreeProgress.tsx new file mode 100644 index 0000000000..911f08f5d4 --- /dev/null +++ b/packages/experimental/client-ui-forge/src/client/WorktreeProgress.tsx @@ -0,0 +1,210 @@ +/** Card above the composer while a Session's worktree is created, after a failed creation, and when its worktree is gone. */ + +import { useState, type ReactNode } from 'react' +import { + Button, IconBranchOutlineRegular, IconCheckOutlineRegular, IconChevronRightOutlineRegular, IconCloseOutlineRegular, StateDot, +} from '@deepseek-ai/dsh-client-ui-primitives' +import type { HostObservable, InjectFace, PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' +import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' +import { assertNever } from '@deepseek-ai/dsh-util-values' +import type { ForgeMissingWorktree, ForgePanelWorktree, ForgeSessionWorktree } from '../types.ts' +import type { ForgePanelKey, NS } from './locales.ts' +import type { PanelSnapshot } from './source.ts' +import css from './WorktreeProgress.module.css' + +/** Business face of the card. */ +export interface WorktreeProgressInjected { + /** Panel snapshot of the Session; the renderer binds it as `usePanel`. */ + readonly hooks: { readonly panel: HostObservable } + /** Stop the creation that holds the Session's message, which returns the message to the composer. */ + readonly stop: () => void + /** Withdraw the worktree request after a failed creation, so the message can go without a worktree. */ + readonly withdraw: () => void + /** + * Check out the removed worktree again. + * @returns the failure text, or undefined once it is restored. + */ + readonly restore: () => Promise + /** Move the Session back to the source checkout of its removed worktree. */ + readonly leave: () => void +} + +/** Full props of the card. */ +export type WorktreeProgressProps = InjectFace & PropsLocale + +type Translate = WorktreeProgressProps['t'] + +type StepState = 'done' | 'running' | 'waiting' | 'failed' + +const STEPS: readonly { readonly phase: 'prepare' | 'checkout'; readonly label: ForgePanelKey }[] = [ + { phase: 'prepare', label: 'creation.prepare' }, + { phase: 'checkout', label: 'creation.checkout' }, +] + +function stepState(current: 'prepare' | 'checkout', failed: boolean, phase: 'prepare' | 'checkout'): StepState { + if (phase === current) return failed ? 'failed' : 'running' + return phase === 'prepare' ? 'done' : 'waiting' +} + +function StepIcon({ state }: { readonly state: StepState }): ReactNode { + switch (state) { + case 'done': return + case 'running': return + case 'failed': return + case 'waiting': return