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', ] : []