Merge remote-tracking branch 'origin/worktree/forge-change-request-bindings' into worktree/forge-pr-worktree-panel

# Conflicts:
#	docs/config-catalog.md
#	docs/config-catalog.zh.md
#	docs/event-producer-consumer.md
#	docs/event-producer-consumer.zh.md
#	packages/experimental/forge-github/README.i18n.yaml
#	packages/experimental/forge-github/README.md
#	packages/experimental/forge-github/README.zh.md
#	packages/experimental/forge-github/src/provider.ts
#	packages/experimental/forge-session/README.i18n.yaml
#	packages/experimental/forge-session/README.md
#	packages/experimental/forge-session/README.zh.md
#	packages/experimental/forge-session/src/index.ts
#	packages/experimental/forge/README.i18n.yaml
#	packages/experimental/forge/README.md
#	packages/experimental/forge/README.zh.md
#	packages/experimental/forge/src/index.ts
This commit is contained in:
Yichen Jiang committed 2026-10-26 18:14:53 +08:00
commit bbb031654e
33 files changed
+759 -99

No files matched your search

+1 -1
View File
@@ -1154,7 +1154,7 @@ export interface Config {
## `@deepseek-ai/dsh-experimental-forge-session`
- `inject`: `forge` · `sessionProjections`
- `inject`: `forge` · `sessions` · `sessionProjections`
- `source`: [`packages/experimental/forge-session/src/types.ts:91`](../packages/experimental/forge-session/src/types.ts)
```ts config-catalog
+1 -1
View File
@@ -1156,7 +1156,7 @@ export interface Config {
## `@deepseek-ai/dsh-experimental-forge-session`
- `inject`: `forge` · `sessionProjections`
- `inject`: `forge` · `sessions` · `sessionProjections`
- `source`: [`packages/experimental/forge-session/src/types.ts:91`](../packages/experimental/forge-session/src/types.ts)
```ts config-catalog
+2 -1
View File
@@ -47,6 +47,7 @@ Application listeners are outside this package matrix. Desktop Host subscribes t
| `deepseek-account/signed-out` | `emit` | [`packages/credentials/deepseek-account/src/index.ts:12`](../packages/credentials/deepseek-account/src/index.ts) | [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform) (`emit`) | [`deepseek-account`](../packages/credentials/deepseek-account) |
| `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | `workspace-controller` |
| `feedback/committed` | `parallel` | [`packages/feedback/message-feedback/src/index.ts:58`](../packages/feedback/message-feedback/src/index.ts) | [`message-feedback`](../packages/feedback/message-feedback) (`parallel`) | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) |
| `forge/providers-changed` | `parallel` | [`packages/experimental/forge/src/index.ts:27`](../packages/experimental/forge/src/index.ts) | `forge` (`parallel`) | `forge-session` |
| `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:67`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`workspace-changes`](../packages/deliverables/workspace-changes) |
| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:77`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`skill-filesystem`](../packages/skill/skill-filesystem), `workspace-files` |
| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:59`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`workspace-changes`](../packages/deliverables/workspace-changes) |
@@ -67,7 +68,7 @@ Application listeners are outside this package matrix. Desktop Host subscribes t
| `schedule/resolve-agent` | `waterfall` | [`packages/schedule/schedule/src/index.ts:74`](../packages/schedule/schedule/src/index.ts) | [`schedule`](../packages/schedule/schedule) (`waterfall`) | `server` |
| `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:56`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`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:65`](../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/disposed` | `emit` | [`packages/core/session/src/index.ts:65`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `forge-session`, `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:76`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `agent-team`, `claude-code-mods`, `client-ui-forge`, [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, `forge-session`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`loader-smoke`](../packages/test-support/loader-smoke), `product-analytics`, [`ptc-runtime-node`](../packages/ptc-runtime/ptc-runtime-node), `server`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter), [`tool-executor-ptc`](../packages/core/tool-executor-ptc), [`tool-jobs`](../packages/jobs/tool-jobs), [`user-questions`](../packages/interaction/user-questions), [`workspace-changes`](../packages/deliverables/workspace-changes) |
| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:84`](../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` |
+2 -1
View File
@@ -49,6 +49,7 @@
| `deepseek-account/signed-out` | `emit` | [`packages/credentials/deepseek-account/src/index.ts:12`](../packages/credentials/deepseek-account/src/index.ts) | [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform) (`emit`) | [`deepseek-account`](../packages/credentials/deepseek-account) |
| `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | `workspace-controller` |
| `feedback/committed` | `parallel` | [`packages/feedback/message-feedback/src/index.ts:58`](../packages/feedback/message-feedback/src/index.ts) | [`message-feedback`](../packages/feedback/message-feedback) (`parallel`) | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) |
| `forge/providers-changed` | `parallel` | [`packages/experimental/forge/src/index.ts:27`](../packages/experimental/forge/src/index.ts) | `forge` (`parallel`) | `forge-session` |
| `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:67`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`workspace-changes`](../packages/deliverables/workspace-changes) |
| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:77`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`skill-filesystem`](../packages/skill/skill-filesystem), `workspace-files` |
| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:59`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`workspace-changes`](../packages/deliverables/workspace-changes) |
@@ -69,7 +70,7 @@
| `schedule/resolve-agent` | `waterfall` | [`packages/schedule/schedule/src/index.ts:74`](../packages/schedule/schedule/src/index.ts) | [`schedule`](../packages/schedule/schedule) (`waterfall`) | `server` |
| `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:56`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`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:65`](../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/disposed` | `emit` | [`packages/core/session/src/index.ts:65`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `forge-session`, `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:76`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `agent-team`, `claude-code-mods`, `client-ui-forge`, [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, `forge-session`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`loader-smoke`](../packages/test-support/loader-smoke), `product-analytics`, [`ptc-runtime-node`](../packages/ptc-runtime/ptc-runtime-node), `server`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter), [`tool-executor-ptc`](../packages/core/tool-executor-ptc), [`tool-jobs`](../packages/jobs/tool-jobs), [`user-questions`](../packages/interaction/user-questions), [`workspace-changes`](../packages/deliverables/workspace-changes) |
| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:84`](../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` |
+76 -5
View File
@@ -32,6 +32,11 @@ interface ChangeRequestRef {
}
```
```ts type-equiv
/** Canonical identity of a change request across providers; built by `changeRequestKey`. */
type ChangeRequestKey = Branded<'ChangeRequestKey'>
```
```ts type-equiv
/**
* Host-specific access registered with `ctx.forge`. One provider serves one
@@ -82,6 +87,49 @@ interface ForgeReadiness {
`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
/** Lifecycle state of a change request. */
type ChangeRequestState = 'open' | 'closed' | 'merged'
```
```ts type-equiv
/** Normalized outcome of one status check. */
type CheckState = 'pending' | 'success' | 'failure' | 'neutral' | 'skipped' | 'cancelled'
```
```ts type-equiv
/** One status check reported for the head commit of a change request. */
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
/** ISO 8601 time the check started, or null before it starts and when the host reports none. */
readonly startedAt: string | null
/** ISO 8601 time the check completed, or null while it runs and when the host reports none. */
readonly completedAt: string | null
}
```
```ts type-equiv
/** Combined outcome of all checks on the head commit. */
type ChecksRollup = 'none' | 'pending' | 'success' | 'failure'
```
```ts type-equiv
/** Whether the change request can merge into its base without conflicts. */
type Mergeability = 'mergeable' | 'conflicting' | 'unknown'
```
```ts type-equiv
/** Aggregate review outcome required by the host's merge rules. */
type ReviewDecision = 'approved' | 'changes-requested' | 'review-required' | 'none'
```
```ts type-equiv
/** Status of one change request at the time its provider read it. */
interface ChangeRequestSnapshot {
@@ -242,7 +290,7 @@ interface SessionChangeRequest {
```ts type-equiv
/**
* Receives the complete visible list after a binding change or a polling result.
* Receives the complete visible list after a binding or provider change, or a polling result.
* @param changeRequests - every visible binding of the watched Session, in first-recorded order.
*/
type SessionChangeRequestsListener = (changeRequests: readonly SessionChangeRequest[]) => void
@@ -592,12 +640,13 @@ Registry of forge providers. Watchers of one change request share a single polli
/**
* 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.
* registration ends. Unregistering cancels and awaits its in-flight calls;
* results from the withdrawn registration are discarded.
* @param provider - provider to serve `provider.forge`.
* @returns the disposer that unregisters the provider.
* @returns the disposer that unregisters the provider and waits for its calls to finish.
* @throws when another provider already serves the same forge.
*/
register(provider: ForgeProvider): () => void
register(provider: ForgeProvider): () => Promise<void>
/**
* Map a Git remote URL to a repository through the first provider that serves it.
@@ -978,7 +1027,7 @@ 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
* complete list after each later binding or provider 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.
@@ -991,4 +1040,26 @@ 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)
<a id="forge-events"></a>
### `forge/*` events
<a id="forgeproviders-changed--parallel"></a>
#### `forge/providers-changed` — parallel
Announces a provider registration or withdrawal after the registry changes. Listener failures are logged without preventing other listeners from running.
```ts cordis-catalog
/**
* Announces a provider registration or withdrawal after the registry changes.
* Listener failures are logged without preventing other listeners from running.
* @param forge - family whose provider changed.
* @mode parallel
*/
'forge/providers-changed'(forge: string): void | Promise<void>
```
Source: [`packages/experimental/forge/src/index.ts`](../../packages/experimental/forge/src/index.ts)
<!-- END GENERATED cordis-surface -->
+76 -5
View File
@@ -32,6 +32,11 @@ interface ChangeRequestRef {
}
```
```ts type-equiv
/** Canonical identity of a change request across providers; built by `changeRequestKey`. */
type ChangeRequestKey = Branded<'ChangeRequestKey'>
```
```ts type-equiv
/**
* Host-specific access registered with `ctx.forge`. One provider serves one
@@ -82,6 +87,49 @@ interface ForgeReadiness {
`ctx.forge.watch` 让同一变更请求的所有监听方共享一个轮询循环,并把每次结果作为更新送达。每个 forge 同时最多只有一次读取在进行。轮询节奏、退避与超时规则见[注册表 README](../../packages/experimental/forge/README.zh.md#use-this-package)。
```ts type-equiv
/** Lifecycle state of a change request. */
type ChangeRequestState = 'open' | 'closed' | 'merged'
```
```ts type-equiv
/** Normalized outcome of one status check. */
type CheckState = 'pending' | 'success' | 'failure' | 'neutral' | 'skipped' | 'cancelled'
```
```ts type-equiv
/** One status check reported for the head commit of a change request. */
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
/** ISO 8601 time the check started, or null before it starts and when the host reports none. */
readonly startedAt: string | null
/** ISO 8601 time the check completed, or null while it runs and when the host reports none. */
readonly completedAt: string | null
}
```
```ts type-equiv
/** Combined outcome of all checks on the head commit. */
type ChecksRollup = 'none' | 'pending' | 'success' | 'failure'
```
```ts type-equiv
/** Whether the change request can merge into its base without conflicts. */
type Mergeability = 'mergeable' | 'conflicting' | 'unknown'
```
```ts type-equiv
/** Aggregate review outcome required by the host's merge rules. */
type ReviewDecision = 'approved' | 'changes-requested' | 'review-required' | 'none'
```
```ts type-equiv
/** Status of one change request at the time its provider read it. */
interface ChangeRequestSnapshot {
@@ -242,7 +290,7 @@ interface SessionChangeRequest {
```ts type-equiv
/**
* Receives the complete visible list after a binding change or a polling result.
* Receives the complete visible list after a binding or provider change, or a polling result.
* @param changeRequests - every visible binding of the watched Session, in first-recorded order.
*/
type SessionChangeRequestsListener = (changeRequests: readonly SessionChangeRequest[]) => void
@@ -592,12 +640,13 @@ Registry of forge providers. Watchers of one change request share a single polli
/**
* 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.
* registration ends. Unregistering cancels and awaits its in-flight calls;
* results from the withdrawn registration are discarded.
* @param provider - provider to serve `provider.forge`.
* @returns the disposer that unregisters the provider.
* @returns the disposer that unregisters the provider and waits for its calls to finish.
* @throws when another provider already serves the same forge.
*/
register(provider: ForgeProvider): () => void
register(provider: ForgeProvider): () => Promise<void>
/**
* Map a Git remote URL to a repository through the first provider that serves it.
@@ -978,7 +1027,7 @@ 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
* complete list after each later binding or provider 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.
@@ -991,4 +1040,26 @@ 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)
<a id="forge-events"></a>
### `forge/*` events
<a id="forgeproviders-changed--parallel"></a>
#### `forge/providers-changed` — parallel
Announces a provider registration or withdrawal after the registry changes. Listener failures are logged without preventing other listeners from running.
```ts cordis-catalog
/**
* Announces a provider registration or withdrawal after the registry changes.
* Listener failures are logged without preventing other listeners from running.
* @param forge - family whose provider changed.
* @mode parallel
*/
'forge/providers-changed'(forge: string): void | Promise<void>
```
Source: [`packages/experimental/forge/src/index.ts`](../../packages/experimental/forge/src/index.ts)
<!-- END GENERATED cordis-surface -->
@@ -15,8 +15,8 @@
en: d152484eb41ac6b4
zh: 2f8a621b991260a1
/deepseek-ai-dsh-experimental-forge-github/use-this-package:
en: 9ab889ec7981beeb
zh: f72aff19b4139831
en: ed1eb18704c54406
zh: 717c303b2d9d7244
/deepseek-ai-dsh-experimental-forge-github/understand-the-implementation:
en: 194951c8b5093f7d
zh: 9c5ffaa9e531bd1f
@@ -30,8 +30,8 @@
en: 26b946af4b742e17
zh: 00b34f0721ac72f6
/deepseek-ai-dsh-experimental-forge-github/known-limitations-and-deferred-work:
en: b69656c7c346cba7
zh: d96e72e6c16dd828
en: 14408605565310b8
zh: 750c163b9af160ce
/deepseek-ai-dsh-experimental-forge-github/known-limitations-and-deferred-work/dev-note:
en: e67f633dc6fe7773
zh: 3bb0c20c93f16f98
+4 -4
View File
@@ -34,11 +34,11 @@ Mount the provider after the forge registry and list every GitHub Enterprise hos
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 <host>`. 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 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 <host>`. 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. `maxOutputBytes` must be a positive safe integer; only `timeoutMs` is bounded by the runtime timer limit. `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 `[email protected]:owner/name.git`, when its host is configured. It serves pull request URLs of the form `https://<host>/<owner>/<name>/pull/<number>` with any trailing path, query, or fragment, and builds that form without the suffix as the canonical URL. A readiness probe runs `gh auth status --hostname <host>`: exit status 0 is `ready`, another exit status is `unauthenticated` with the first diagnostic line, and a `gh` that cannot start is `unavailable`. 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.
The provider serves a remote URL in `https`, `http`, `ssh`, or `git` form, or in scp form such as `[email protected]:owner/name.git`, when its host is configured. HTTP(S) remotes retain custom ports in the host identity; SSH and Git transport ports are excluded. It serves pull request URLs of the form `https://<host>/<owner>/<name>/pull/<number>` with any trailing path, query, or fragment, and builds that form without the suffix as the canonical URL. A readiness probe runs `gh auth status --hostname <host>`: exit status 0 is `ready`, another exit status is `unauthenticated` with the first diagnostic line, and a `gh` that cannot start is `unavailable`. 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`. Check runs keep their start and completion times. Commit status contexts map `SUCCESS` to `success`, `FAILURE` and `ERROR` to `failure`, and every other state to `pending`, and carry no times. 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`.
Check runs map to `pending` until completed, then to `success`, `failure` (failure, timed out, startup failure, or action required), `cancelled`, `skipped`, or `neutral`. Check runs keep their start and completion times. Commit status contexts map `SUCCESS` to `success`, `FAILURE` and `ERROR` to `failure`, and every other state to `pending`, and carry no times. A GraphQL `NOT_FOUND` error becomes a `not-found` failure. A response without GraphQL data or errors, including an HTTP error JSON body, uses the first stderr diagnostic or the body's message. Authentication diagnostics become `unauthenticated`; rate limits and other failures become `unavailable`. Head lookups preserve the same diagnostic in their rejection.
A pull request is `mergeReady` when it is open, not a draft, GitHub reports its merge state as `CLEAN`, `HAS_HOOKS`, or `UNSTABLE`, and the signed-in user has write, maintain, or admin permission. `mergeMethods` lists the squash, merge-commit, and rebase methods the repository allows, the user's default merge method first. Marking ready runs `gh pr ready <number> --repo <host>/<owner>/<name>`. Merging calls `PUT repos/<owner>/<name>/pulls/<number>/merge` through `gh api` with the method and the expected head SHA, so GitHub refuses once new commits arrived, which is reported as `stale`. Unlike `gh pr merge`, it never enables auto-merge, never joins a merge queue, and never deletes the head branch. A refused action reports GitHub's message, and a diagnostic that mentions authentication becomes `unauthenticated`.
@@ -88,7 +88,7 @@ None. Provider output reaches only host-side watchers.
<a id="known-limitations-and-deferred-work"></a>
- **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.
- **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, including references retained across configuration changes. A status fetch returns `unavailable` for those references; a head lookup rejects before starting `gh`.
- **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.
@@ -34,11 +34,11 @@ kind: "package-reference"
hosts: [github.com, ghe.example.com]
```
即使 Session 在其他执行环境中运行命令,提供方也以宿主进程的形式运行 `gh`。每个主机执行一次 `gh auth login --hostname <host>` 完成登录。子进程继承的宿主环境去掉了 `DSH_*` 变量以及名称包含 `KEY`、`PASSWORD`、`SECRET` 或 `TOKEN` 的变量,因此 `GH_TOKEN` 与 `GITHUB_TOKEN` 不会被转发,`gh` 使用自己的凭据存储。交互提示、更新提醒、分页器与颜色输出均被关闭。`ghCommand`、`hosts`、`timeoutMs` 与 `maxOutputBytes` 见[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-experimental-forge-github)。
即使 Session 在其他执行环境中运行命令,提供方也以宿主进程的形式运行 `gh`。每个主机执行一次 `gh auth login --hostname <host>` 完成登录。子进程继承的宿主环境去掉了 `DSH_*` 变量以及名称包含 `KEY`、`PASSWORD`、`SECRET` 或 `TOKEN` 的变量,因此 `GH_TOKEN` 与 `GITHUB_TOKEN` 不会被转发,`gh` 使用自己的凭据存储。交互提示、更新提醒、分页器与颜色输出均被关闭。`maxOutputBytes` 必须是正安全整数;只有 `timeoutMs` 受运行时定时器上限约束。`ghCommand`、`hosts`、`timeoutMs` 与 `maxOutputBytes` 见[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-experimental-forge-github)。
当主机在配置中时,提供方服务 `https`、`http`、`ssh`、`git` 形式的远端 URL,以及 `[email protected]:owner/name.git` 这类 scp 形式。它服务 `https://<host>/<owner>/<name>/pull/<number>` 形式的 pull request URL,允许其后附带任意路径、查询或片段,并以去掉这些后缀的形式作为规范 URL。就绪探测运行 `gh auth status --hostname <host>`:退出码为 0 时为 `ready`,其他退出码为 `unauthenticated` 并带第一行诊断,`gh` 无法启动时为 `unavailable`。按 head 分支查找时,最多返回 20 个以该分支为 head 的打开状态 pull request,按创建时间从新到旧排列,不包含来自 fork 的 pull request;查找默认分支时返回空结果。
当主机在配置中时,提供方服务 `https`、`http`、`ssh`、`git` 形式的远端 URL,以及 `[email protected]:owner/name.git` 这类 scp 形式。HTTP(S) 远端在主机标识中保留自定义端口;SSH 和 Git 传输端口不计入主机标识。它服务 `https://<host>/<owner>/<name>/pull/<number>` 形式的 pull request URL,允许其后附带任意路径、查询或片段,并以去掉这些后缀的形式作为规范 URL。就绪探测运行 `gh auth status --hostname <host>`:退出码为 0 时为 `ready`,其他退出码为 `unauthenticated` 并带第一行诊断,`gh` 无法启动时为 `unavailable`。按 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`。
检查运行(check run)在完成前映射为 `pending`,完成后映射为 `success`、`failure`(失败、超时、启动失败或需要操作)、`cancelled`、`skipped` 或 `neutral`。检查运行保留其开始与完成时间。提交状态上下文把 `SUCCESS` 映射为 `success`,把 `FAILURE` 与 `ERROR` 映射为 `failure`,其余状态均映射为 `pending`,且不带时间。GraphQL 的 `NOT_FOUND` 错误转为 `not-found` 失败。响应不含 GraphQL data 或 errors 时(包括 HTTP 错误 JSON),使用 stderr 的首条诊断或响应体的 message。认证诊断转为 `unauthenticated`;限流与其他失败转为 `unavailable`。head 查找在拒绝时保留相同诊断。
pull request 处于打开状态、不是草稿、GitHub 报告其合并状态为 `CLEAN`、`HAS_HOOKS` 或 `UNSTABLE`,且已登录用户具有 write、maintain 或 admin 权限时,`mergeReady` 为真。`mergeMethods` 列出仓库允许的压缩合并、合并提交与变基合并方式,用户默认的合并方式排在最前。标记为可评审运行 `gh pr ready <number> --repo <host>/<owner>/<name>`。合并经 `gh api` 调用 `PUT repos/<owner>/<name>/pulls/<number>/merge`,带上合并方式与预期的 head SHA,因此有新提交到达后 GitHub 会拒绝,报告为 `stale`。与 `gh pr merge` 不同,它从不启用自动合并、从不加入合并队列,也从不删除 head 分支。被拒绝的操作报告 GitHub 的原因,提到身份验证的诊断转为 `unauthenticated`。
@@ -88,7 +88,7 @@ pull request 处于打开状态、不是草稿、GitHub 报告其合并状态为
<a id="known-limitations-and-deferred-work"></a>
- **宿主需要 GitHub CLI**——必须安装 `gh` 并为每个已配置主机登录;环境变量中的令牌不会被转发。不在 `hosts` 中的主机永远不会被读取。
- **宿主需要 GitHub CLI**——必须安装 `gh` 并为每个已配置主机登录;环境变量中的令牌不会被转发。不在 `hosts` 中的主机永远不会被读取,包括配置变更后保留的引用。状态读取为这些引用返回 `unavailable`;head 查找会在启动 `gh` 前拒绝。
- **前 100 个检查与评审讨论**——head 提交的检查超过上限时以 `checksTruncated` 标记;前 100 个之后的未解决讨论不计入。
- **不发现 fork**——按 head 分支查找时,不包含 head 位于其他仓库的 pull request。
- **速率限制**——每次轮询都会以已登录账号的速率配额为每个主机消耗一次 GraphQL 查询。
@@ -95,7 +95,16 @@ const responseSchema = z.object({
path: z.array(z.union([z.string(), z.number()])).optional(),
message: z.string(),
})).optional(),
})
}).refine(hasGraphqlFields)
/**
* Distinguish GraphQL response fields from HTTP error bodies.
* @param body - parsed JSON returned by GitHub.
* @returns whether the object carries GraphQL data or errors; field validation remains with the query mapper.
*/
export function hasGraphqlFields(body: unknown): boolean {
return typeof body === 'object' && body !== null && ('data' in body || 'errors' in body)
}
const headSchema = z.object({
data: z.object({
@@ -38,11 +38,11 @@ export function apply(ctx: Context, config: Config): void {
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}`)
}
if (!Number.isSafeInteger(resolved.timeoutMs) || resolved.timeoutMs <= 0 || resolved.timeoutMs > MAX_TIMER_DELAY_MS) {
throw new Error(`forge-github: timeoutMs must be a positive integer no greater than ${MAX_TIMER_DELAY_MS}`)
}
if (!Number.isSafeInteger(resolved.maxOutputBytes) || resolved.maxOutputBytes <= 0) {
throw new Error('forge-github: maxOutputBytes must be a positive safe integer')
}
ctx.forge.register(new GitHubProvider(ghRunner(resolved), resolved.hosts))
}
@@ -7,7 +7,7 @@ import type {
} from '@deepseek-ai/dsh-experimental-forge'
import { GhOutputLimitError, GhTimeoutError, GhUnavailableError } from './gh.ts'
import type { GhOutput, GhRunner } from './gh.ts'
import { HEAD_QUERY, mapHeadResponse, mapSnapshotResponse, snapshotQuery } from './graphql.ts'
import { hasGraphqlFields, HEAD_QUERY, mapHeadResponse, mapSnapshotResponse, snapshotQuery } from './graphql.ts'
import { checkedRepository, parsePullRequestUrl, parseRemoteUrl } from './urls.ts'
const AUTHENTICATION = /gh auth login|authentication|bad credentials|HTTP 401/i
@@ -59,12 +59,17 @@ export class GitHubProvider implements ForgeProvider {
signal: AbortSignal,
): Promise<ReadonlyMap<ChangeRequestKey, ChangeRequestSnapshot | ForgeFailure>> {
const byHost = new Map<string, ChangeRequestRef[]>()
const results = new Map<ChangeRequestKey, ChangeRequestSnapshot | ForgeFailure>()
for (const ref of refs) {
const failure = this.hostFailure(ref.repository.host)
if (failure !== undefined) {
results.set(changeRequestKey(ref), failure)
continue
}
const group = byHost.get(ref.repository.host)
if (group === undefined) byHost.set(ref.repository.host, [ref])
else group.push(ref)
}
const results = new Map<ChangeRequestKey, ChangeRequestSnapshot | ForgeFailure>()
for (const [host, group] of byHost) {
for (const [key, result] of await this.fetchHost(host, group, signal)) results.set(key, result)
}
@@ -72,12 +77,14 @@ export class GitHubProvider implements ForgeProvider {
}
async findByHead(repository: ForgeRepository, branch: string, signal: AbortSignal): Promise<readonly ChangeRequestRef[]> {
const failure = this.hostFailure(repository.host)
if (failure !== undefined) throw new Error(failure.message)
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}`}`)
if (!hasGraphqlFields(body)) throw new Error(`forge-github: head branch lookup failed: ${responseFailure(output, body).message}`)
return mapHeadResponse(body, repository, branch)
}
@@ -87,6 +94,8 @@ export class GitHubProvider implements ForgeProvider {
*/
async markReady(ref: ChangeRequestRef, signal: AbortSignal): Promise<ForgeActionFailure | undefined> {
const { host, owner, name } = checkedRepository(ref)
const failure = this.hostFailure(host)
if (failure !== undefined) return { code: 'failed', message: failure.message }
return await this.act(['pr', 'ready', String(ref.number), '--repo', `${host}/${owner}/${name}`], signal)
}
@@ -98,6 +107,8 @@ export class GitHubProvider implements ForgeProvider {
*/
async merge(ref: ChangeRequestRef, request: MergeRequest, signal: AbortSignal): Promise<ForgeActionFailure | undefined> {
const { host, owner, name } = checkedRepository(ref)
const failure = this.hostFailure(host)
if (failure !== undefined) return { code: 'failed', message: failure.message }
if (!COMMIT.test(request.headSha)) throw new Error(`forge-github: invalid head commit ${JSON.stringify(request.headSha)}`)
return await this.act([
'api', '--hostname', host, '--method', 'PUT', `repos/${owner}/${name}/pulls/${ref.number}/merge`,
@@ -108,12 +119,16 @@ export class GitHubProvider implements ForgeProvider {
/** Read the pull request's diff with `gh pr diff`, which GitHub computes against the merge base. */
async diff(ref: ChangeRequestRef, signal: AbortSignal): Promise<string | ForgeFailure> {
const { host, owner, name } = checkedRepository(ref)
const failure = this.hostFailure(host)
if (failure !== undefined) return failure
return await this.read(['pr', 'diff', String(ref.number), '--repo', `${host}/${owner}/${name}`, '--color', 'never'], signal)
}
/** Read one file at a commit through the contents API in its raw form. */
async fileText(ref: ChangeRequestRef, commit: string, path: string, signal: AbortSignal): Promise<string | ForgeFailure> {
const { host, owner, name } = checkedRepository(ref)
const failure = this.hostFailure(host)
if (failure !== undefined) return failure
if (!COMMIT.test(commit)) throw new Error(`forge-github: invalid commit ${JSON.stringify(commit)}`)
return await this.read([
'api', '--hostname', host, '-H', 'Accept: application/vnd.github.raw+json',
@@ -127,6 +142,8 @@ export class GitHubProvider implements ForgeProvider {
}
async readiness(host: string, signal: AbortSignal): Promise<ForgeReadiness> {
const failure = this.hostFailure(host)
if (failure !== undefined) return { state: 'unavailable', message: failure.message }
try {
const output = await this.gh(['auth', 'status', '--hostname', host], signal)
if (output.exitCode === 0) return { state: 'ready', message: null }
@@ -179,6 +196,12 @@ export class GitHubProvider implements ForgeProvider {
}
}
private hostFailure(host: string): ForgeFailure | undefined {
return this.hosts.has(host.toLowerCase()) ? undefined : {
code: 'unavailable', message: `forge-github: host ${JSON.stringify(host)} is not configured`,
}
}
private async fetchHost(
host: string,
refs: readonly ChangeRequestRef[],
@@ -187,11 +210,8 @@ export class GitHubProvider implements ForgeProvider {
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}`,
}
if (hasGraphqlFields(body)) return mapSnapshotResponse(body, query)
const failure = responseFailure(output, body)
return new Map([...query.targets.values()].map(ref => [changeRequestKey(ref), failure] as const))
}
}
@@ -216,6 +236,14 @@ function encodedPath(path: string): string {
return path.split('/').map(segment => encodeURIComponent(segment)).join('/')
}
function responseFailure(output: GhOutput, body: unknown): ForgeFailure {
const detail = typeof body === 'object' && body !== null && 'message' in body && typeof body.message === 'string'
? firstLine(body.message) : undefined
const message = firstLine(output.stderr) ?? detail
?? (output.exitCode === 0 ? 'unexpected GitHub response' : `gh exited with ${output.exitCode}`)
return { code: AUTHENTICATION.test(message) ? 'unauthenticated' : 'unavailable', message }
}
function parseJson(text: string): unknown {
let value: unknown
try {
@@ -42,13 +42,15 @@ export function checkedRepository(ref: ChangeRequestRef): ForgeRepository {
* 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.
* trailing slash. HTTP(S) ports belong to the host identity; SSH and Git
* transport ports do not.
* @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 scheme = /^https?:\/\/(?:[^@/]+@)?([^/:]+(?::\d+)?)\/(.+)$/i.exec(trimmed)
?? /^(?:ssh|git):\/\/(?:[^@/]+@)?([^/:]+)(?::\d+)?\/(.+)$/i.exec(trimmed)
const scp = scheme === null ? /^(?:[^@/:]+@)?([^/:]+):(?!\/\/)(.+)$/.exec(trimmed) : null
const match = scheme ?? scp
if (match === null) return undefined
@@ -231,8 +231,8 @@ describe('snapshot mapping', () => {
{ 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' },
{ code: 'unavailable', message: 'unexpected GitHub response' },
{ code: 'unavailable', message: 'unexpected GitHub response' },
])
expect([...mapSnapshotResponse('not json', query).values()]).toEqual([
{ code: 'unavailable', message: 'unexpected GitHub response' },
@@ -51,10 +51,20 @@ describe('forge-github plugin', () => {
[{ 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'],
[{ timeoutMs: 2_147_483_648 }, 'forge-github: timeoutMs must be a positive integer no greater than 2147483647'],
[{ timeoutMs: 1.5 }, 'forge-github: timeoutMs must be a positive integer no greater than 2147483647'],
[{ maxOutputBytes: 1.5 }, 'forge-github: maxOutputBytes must be a positive safe integer'],
[{ maxOutputBytes: 0 }, 'forge-github: maxOutputBytes must be a positive safe integer'],
[{ maxOutputBytes: Number.MAX_SAFE_INTEGER + 1 }, 'forge-github: maxOutputBytes must be a positive safe integer'],
])('rejects %j at load', async (config, message) => {
const context = await forge()
await expect(context.plugin(ForgeGitHub, config)).rejects.toThrow(message)
})
it('does not apply timer limits to the output byte budget', async () => {
const context = await forge()
await context.plugin(ForgeGitHub, { maxOutputBytes: 2_147_483_648 })
expect(context.forge.repositoryFromRemote('https://github.com/octo/demo.git')).toBeDefined()
})
})
@@ -43,9 +43,52 @@ describe('GitHub provider addressing', () => {
it('builds canonical pull request URLs', () => {
expect(subject.changeRequestUrl(pull(4, enterprise))).toBe('https://ghe.corp.example/team/svc/pull/4')
})
it('uses a configured HTTP port for URL mapping and GitHub queries', async () => {
const gh = fakeGh(() => output({ data: { r0: { p0: pullRequestNode(1) } } }))
const subject = new GitHubProvider(gh.run, ['ghe.example.com:8443'])
const repository = subject.repositoryFromRemote('https://ghe.example.com:8443/team/repo.git')!
const ref = { repository, number: 1 }
expect(subject.changeRequestFromUrl(subject.changeRequestUrl(ref))).toEqual(ref)
await subject.fetch([ref], signal)
expect(gh.calls[0]?.[3]).toBe('ghe.example.com:8443')
})
})
describe('GitHub provider fetch', () => {
it.each([
[{ message: 'Bad credentials' }, 1, 'gh: Bad credentials (HTTP 401)', 'unauthenticated', 'gh: Bad credentials (HTTP 401)'],
[{ message: 'API rate limit exceeded' }, 1, 'gh: API rate limit exceeded (HTTP 403)', 'unavailable', 'gh: API rate limit exceeded (HTTP 403)'],
[{ message: 'Bad credentials' }, 1, '', 'unauthenticated', 'Bad credentials'],
[{ message: '' }, 0, '', 'unavailable', 'unexpected GitHub response'],
[null, 1, '', 'unavailable', 'gh exited with 1'],
[[], 0, '', 'unavailable', 'unexpected GitHub response'],
])('preserves HTTP failure diagnostics for %j', async (body, exitCode, stderr, code, message) => {
const gh = fakeGh(() => output(body, exitCode, stderr))
const subject = provider(gh.run)
expect([...(await subject.fetch([pull(1)], signal)).values()]).toEqual([{ code, message }])
await expect(subject.findByHead(demo, 'topic', signal)).rejects.toThrow(`head branch lookup failed: ${message}`)
})
it('rejects unconfigured hosts without querying them and still fetches allowed hosts', async () => {
const gh = fakeGh(() => output({ data: { r0: { p0: pullRequestNode(1) } } }))
const denied = pull(2, { ...enterprise, host: 'unconfigured.example' })
const results = await provider(gh.run).fetch([denied, pull(1)], signal)
expect(gh.calls.map(call => call[3])).toEqual(['github.com'])
expect(results.get(changeRequestKey(denied))).toEqual({ code: 'unavailable', message: 'forge-github: host "unconfigured.example" is not configured' })
expect(results.get(changeRequestKey(pull(1)))).toMatchObject({ title: 'Change 1' })
})
it('rejects references retained after their host was removed from configuration', async () => {
const gh = fakeGh(() => output(''))
const retained = provider(gh.run).changeRequestFromUrl('https://ghe.corp.example/team/svc/pull/2')!
const replacement = new GitHubProvider(gh.run, ['github.com'])
const results = await replacement.fetch([retained], signal)
await expect(replacement.findByHead(retained.repository, 'topic', signal)).rejects.toThrow('is not configured')
expect(results.get(changeRequestKey(retained))).toMatchObject({ code: 'unavailable' })
expect(gh.calls).toEqual([])
})
it('runs one GraphQL request per host and merges the results', async () => {
const gh = fakeGh((args) => {
const host = args[3]
@@ -265,3 +308,16 @@ describe('GitHub provider reads', () => {
.resolves.toEqual({ code: 'unavailable', message: 'gh api timed out after 30000ms' })
})
})
it('rejects readiness, actions, and content reads for an unconfigured host without starting gh', async () => {
const gh = fakeGh(() => output('unexpected'))
const subject = provider(gh.run)
const target = pull(7, { ...demo, host: 'unconfigured.example' })
const message = 'forge-github: host "unconfigured.example" is not configured'
await expect(subject.readiness(target.repository.host, signal)).resolves.toEqual({ state: 'unavailable', message })
await expect(subject.markReady(target, signal)).resolves.toEqual({ code: 'failed', message })
await expect(subject.merge(target, { method: 'squash', headSha: 'a'.repeat(40) }, signal)).resolves.toEqual({ code: 'failed', message })
await expect(subject.diff(target, signal)).resolves.toEqual({ code: 'unavailable', message })
await expect(subject.fileText(target, 'a'.repeat(40), 'file.txt', signal)).resolves.toEqual({ code: 'unavailable', message })
expect(gh.calls).toEqual([])
})
@@ -9,6 +9,9 @@ describe('remote URLs', () => {
['https://user:[email protected]/octo/demo', demo],
['http://github.com/octo/demo/', demo],
['ssh://[email protected]:22/octo/demo.git', demo],
['https://ghe.example.com:8443/team/repo.git', { host: 'ghe.example.com:8443', owner: 'team', name: 'repo' }],
['http://ghe.example.com:8080/team/repo', { host: 'ghe.example.com:8080', owner: 'team', name: 'repo' }],
['SSH://[email protected]:7999/team/repo.git', { host: 'ghe.example.com', owner: 'team', name: 'repo' }],
['git://github.com/octo/demo', demo],
['[email protected]:octo/demo.git', demo],
['github.com:octo/demo.git', demo],
@@ -40,6 +43,10 @@ describe('remote URLs', () => {
})
describe('pull request URLs', () => {
it('uses the same host identity as an HTTP remote with a custom port', () => {
const remote = parseRemoteUrl('https://ghe.example.com:8443/team/repo.git')
expect(parsePullRequestUrl('https://ghe.example.com:8443/team/repo/pull/1')).toEqual({ ...remote, number: 1 })
})
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 }],
@@ -15,8 +15,8 @@
en: d152484eb41ac6b4
zh: 2f8a621b991260a1
/deepseek-ai-dsh-experimental-forge-session/use-this-package:
en: 62679d4aaeae5e2c
zh: 2f88937670fa64d0
en: d9de38d0cc22c1bd
zh: 6116653176a9fea7
/deepseek-ai-dsh-experimental-forge-session/understand-the-implementation:
en: ccc7ad69f5837528
zh: 369c08bf4f7e6e4d
@@ -30,8 +30,8 @@
en: 1bdaeb7d102c23d8
zh: 37a3f0bd58f68363
/deepseek-ai-dsh-experimental-forge-session/known-limitations-and-deferred-work:
en: 6b3be538bd434258
zh: bb790733a0fbb114
en: 87c7672b6dc886a0
zh: 864ef4403a3e7fea
/deepseek-ai-dsh-experimental-forge-session/known-limitations-and-deferred-work/dev-note:
en: e67f633dc6fe7773
zh: 3bb0c20c93f16f98
@@ -34,9 +34,9 @@ Mount the service with the forge registry, a provider, and the Session projectio
- 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`, `referenced`, `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. `discoverBranch(session, remote, branch)` runs the lookup that follows a push for a branch on the remote at `remote` and resolves once it has recorded its bindings or failed; a remote that no provider serves changes nothing.
`ctx.forgeSession.changeRequests(session)` returns the visible bindings in first-recorded order. Each entry carries the URL, its `origin` (`created`, `referenced`, `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, provider registration change, or polling result. Provider changes resynchronize existing watches, including URLs that were previously unserved. `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. `discoverBranch(session, remote, branch)` runs the lookup that follows a push for a branch on the remote at `remote` and resolves once it has recorded its bindings or failed; a remote that no provider serves changes nothing.
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/<number>` URL in its text output. A command containing `gh pr view`, `gh pr edit`, or `gh pr checkout` binds, as `referenced`, a `/pull/<number>` URL selected by the command, or a numeric selector with an explicit repository and host, plus the URL its output prints for its own change request: gh's `url:` field or a URL alone on a line before the `--` line that starts a description, and JSON `url` members. Links in a description or in other fields do not bind, and a command that also contains `gh pr create` binds its URLs as `created`. A command containing `git push`, optionally with leading `-C`, `-c`, or long Git options, records each branch listed under a `To <remote>` 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.
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/<number>` URL in its text output. A command containing `gh pr view`, `gh pr edit`, or `gh pr checkout` binds, as `referenced`, a `/pull/<number>` URL selected by the command, or a numeric selector with an explicit repository and host, plus the URL its output prints for its own change request: gh's `url:` field or a URL alone on a line before the `--` line that starts a description, and JSON `url` members. Links in a description or in other fields do not bind, and a command that also contains `gh pr create` binds its URLs as `created`. A command containing `git push`, optionally with leading `-C`, `-c`, or long Git options, records each branch listed under a `To <remote>` line as created, updated, forced, or already up to date; rejected and deleted refs, new tags, and explicit refs outside `refs/heads/` 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. Dismissed bindings remain bound and are skipped; only `track` restores them. Every push repeats the lookup; failures are logged as warnings. Closing a Session cancels its unfinished branch lookups without adding bindings. Reopening it does not repeat those lookups; another push, `discoverBranch`, or `track` can bind the change request. URLs that differ only in scheme, letter case, query, fragment, or trailing slashes name the same binding.
Numeric `gh pr view`, `edit`, and `checkout` selectors accept `--repo OWNER/REPO`, `--repo=OWNER/REPO`, and `-R OWNER/REPO` in either argument order. A repository containing its host, or a literal `GH_HOST` assignment, supplies a URL directly. For a single command, optionally piped through `head`, `tail`, or `cat`, a completed foreground Bash call without a host runs a read-only `gh pr view <number> --repo <repository> --json url` through the same shell service, cwd, literal environment assignments, managed environment, and standing sandbox policy. The CLI resolves its configured host. A successful lookup appends a `reference` binding record; failures leave bindings unchanged and log a warning. This live observer requires `tools`, `shell`, and `shellEnv`; projection-only compositions still recover recorded URLs. Queries share `discoveryTimeoutMs`, stop on caller cancellation, and are aborted and awaited on service disposal.
@@ -89,6 +89,8 @@ None. Binding records are ignorable plugin records outside the model-visible con
- **Limited shell recognition** — Numeric selectors require a literal repository. Expansion, substitutions, multiline scripts, unknown options, aliases, and other CLIs are excluded. Unqualified repositories in compound commands or PowerShell require an output URL or `track`; the live resolver cannot recover their original environment. Replay restores confirmed reference records and explicit-host selectors without running `gh`; old unqualified calls without a recorded URL need another invocation or `track`. Branch selectors and `git push --porcelain` remain unsupported.
- **Discovery timing** — a change request opened elsewhere for a branch appears after the next push of that branch, after a caller such as the forge panel reports the branch again, or after a `track`.
- **No model tool** — the model cannot read or change bindings yet.
- **Background output** — `run_in_background: true` returns a job id without binding evidence. A foreground command moved to the background contributes only the output in its first result; later job output does not add bindings. Use `track` for change requests missing from that result.
- **Unqualified tag updates** — a forced or up-to-date tag printed without `refs/tags/` is treated as a branch. Discovery may bind an open change request for a branch with the same name; use `dismiss` to hide an incorrect binding.
- **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.
@@ -34,9 +34,9 @@ kind: "package-reference"
- name: '@deepseek-ai/dsh-experimental-forge-session'
```
`ctx.forgeSession.changeRequests(session)` 按首次记录的顺序返回可见的绑定。每一项包含 URL、其 `origin`(`created`、`referenced`、`discovered` 或 `tracked`)、提供方引用(没有提供方服务该 URL 时为 `null`)以及最近一次轮询结果(尚无结果时为 `null`)。`watch(session, listener)` 是调用方 fiber 上的 effect:Session 有监听方期间,提供方服务的每个可见绑定都会被轮询;此后每次绑定变化或轮询结果到达时,监听方都会收到完整列表。`track(session, url)` 以提供方的规范形式绑定 URL,或恢复已隐藏的绑定;没有提供方识别该 URL 时抛错。`dismiss(session, url)` 隐藏一个可见绑定;其他 URL 不会改变日志。`discoverBranch(session, remote, branch)` 为位于 `remote` 远端的分支执行推送之后的那次查找,记录完绑定或失败后 resolve;没有提供方服务的远端不会带来任何变化。
`ctx.forgeSession.changeRequests(session)` 按首次记录的顺序返回可见的绑定。每一项包含 URL、其 `origin`(`created`、`referenced`、`discovered` 或 `tracked`)、提供方引用(没有提供方服务该 URL 时为 `null`)以及最近一次轮询结果(尚无结果时为 `null`)。`watch(session, listener)` 是调用方 fiber 上的 effect:Session 有监听方期间,提供方服务的每个可见绑定都会被轮询;此后每次绑定变化、提供方注册变化或轮询结果到达时,监听方都会收到完整列表。提供方变化会重新同步已有监听,包括此前没有提供方服务的 URL。`track(session, url)` 以提供方的规范形式绑定 URL,或恢复已隐藏的绑定;没有提供方识别该 URL 时抛错。`dismiss(session, url)` 隐藏一个可见绑定;其他 URL 不会改变日志。`discoverBranch(session, remote, branch)` 为位于 `remote` 远端的分支执行推送之后的那次查找,记录完绑定或失败后 resolve;没有提供方服务的远端不会带来任何变化。
`bash` 或 `pwsh` 调用只从其第一个结果中提取证据,且仅当工具执行本身成功时才提取;命令的退出状态不影响结果。包含 `gh pr create` 或 `gh pr new` 的命令会绑定其文本输出中的每个 `/pull/<number>` URL。包含 `gh pr view`、`gh pr edit` 或 `gh pr checkout` 的命令以 `referenced` 绑定:命令选中的 `/pull/<number>` URL,或带有明确仓库和主机的数字编号,以及输出为它所操作的变更请求本身打印的 URL,即 gh 的 `url:` 字段、开始描述正文的 `--` 行之前单独成行的 URL,以及 JSON 的 `url` 成员。描述正文或其他字段里的链接不会绑定;同一命令里还包含 `gh pr create` 时,其 URL 以 `created` 绑定。包含 `git push` 的命令(可带前置的 `-C`、`-c` 或 Git 长选项)会记录 `To <remote>` 行下列出的每个已创建、已更新、已强制推送或已是最新的分支;被拒绝、已删除、标签以及非分支的引用会被跳过。对远端由提供方服务的每个被推送分支,服务在 `discoveryTimeoutMs` 内调用 `ctx.forge.findByHead`,并为返回的每个尚未绑定(包括已隐藏)的变更请求追加一条 `discover` 记录。每次推送都会重新查找;失败记录为警告日志。仅在协议、大小写、查询、片段或末尾斜杠上不同的 URL 指向同一个绑定。
`bash` 或 `pwsh` 调用只从其第一个结果中提取证据,且仅当工具执行本身成功时才提取;命令的退出状态不影响结果。包含 `gh pr create` 或 `gh pr new` 的命令会绑定其文本输出中的每个 `/pull/<number>` URL。包含 `gh pr view`、`gh pr edit` 或 `gh pr checkout` 的命令以 `referenced` 绑定:命令选中的 `/pull/<number>` URL,或带有明确仓库和主机的数字编号,以及输出为它所操作的变更请求本身打印的 URL,即 gh 的 `url:` 字段、开始描述正文的 `--` 行之前单独成行的 URL,以及 JSON 的 `url` 成员。描述正文或其他字段里的链接不会绑定;同一命令里还包含 `gh pr create` 时,其 URL 以 `created` 绑定。包含 `git push` 的命令(可带前置的 `-C`、`-c` 或 Git 长选项)会记录 `To <remote>` 行下列出的每个已创建、已更新、已强制推送或已是最新的分支;被拒绝、已删除的引用、新标签,以及明确位于 `refs/heads/` 之外的引用会被跳过。对远端由提供方服务的每个被推送分支,服务在 `discoveryTimeoutMs` 内调用 `ctx.forge.findByHead`,并为返回的每个尚未绑定的变更请求追加一条 `discover` 记录。已隐藏的绑定仍视为已绑定而被跳过;只有 `track` 会恢复它们。每次推送都会重新查找;失败记录为警告日志。关闭 Session 会取消其未完成的分支查找,不追加绑定。重新打开不会补做这些查找;可再次推送、调用 `discoverBranch` 或用 `track` 绑定变更请求。仅在协议、大小写、查询、片段或末尾斜杠上不同的 URL 指向同一个绑定。
`gh pr view`、`edit` 和 `checkout` 的数字编号支持 `--repo OWNER/REPO`、`--repo=OWNER/REPO` 和 `-R OWNER/REPO`,参数可位于编号前后。仓库参数中的主机或字面量 `GH_HOST` 赋值可直接确定 URL;对于单条命令(可通过管道传给 `head`、`tail` 或 `cat`),未写明主机且已完成的前台 Bash 调用会通过相同的 shell 服务、工作目录、字面量环境赋值、托管环境和常设沙箱策略,执行只读的 `gh pr view <number> --repo <repository> --json url` 查询,由 CLI 解析其配置的主机。查询成功后追加 `reference` 绑定记录;失败时保留现有绑定并记录警告。此实时观察器需要 `tools`、`shell` 和 `shellEnv`,仅含投影的组合仍可恢复已记录的 URL。查询共用 `discoveryTimeoutMs`,调用方取消会停止查询,销毁服务会中止并等待查询结束。
@@ -89,6 +89,8 @@ kind: "package-reference"
- **有限的 shell 识别**——数字编号需要字面量仓库参数。不识别变量展开、替换、多行脚本、未知选项、别名和其他 CLI。复合命令或 PowerShell 中未写明主机的仓库需要输出 URL 或使用 `track`,实时解析器无法恢复它们原本的环境。重放会恢复已确认的引用记录和主机明确的编号参数,不会执行 `gh`;没有记录 URL 的旧调用若未写明主机,需要再次调用或使用 `track`。分支选择器和 `git push --porcelain` 仍不受支持。
- **发现时机**——在别处为某个分支打开的变更请求,要等到该分支的下一次推送、forge 面板等调用方再次报告该分支,或 `track` 之后才会出现。
- **没有模型工具**——模型目前还不能读取或修改绑定。
- **后台输出**——`run_in_background: true` 只返回作业 id,不含绑定证据。转入后台的前台命令只贡献首个结果中的输出;后续作业输出不会补充绑定。首个结果中未出现的变更请求需用 `track` 绑定。
- **未带完整引用的标签更新**——强制更新或已是最新的标签,如果输出未带 `refs/tags/`,会被当成分支。发现过程可能绑定同名分支的打开状态变更请求;可用 `dismiss` 隐藏错误绑定。
- **按 Session 绑定**——subagent 的 Session 保有自己的绑定;父 Session 不会汇总它们。
- **查找失败不可见**——查找失败只写入宿主日志;Session 与其监听方都看不到。
@@ -92,8 +92,9 @@ export function ownChangeRequestUrls(text: string): string[] {
/**
* 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 <remote>` line. Rejected and deleted refs, tags, and refs outside
* `refs/heads/` are excluded.
* `To <remote>` line. Rejected and deleted refs, new tags, and explicit refs
* outside `refs/heads/` are excluded. Unqualified tag updates cannot be
* distinguished from branch updates and are included.
* @param text - rendered shell result, stdout and stderr.
* @returns distinct pushed branches in order of first appearance.
*/
@@ -40,7 +40,7 @@ interface SessionWatch {
* `ctx.forge`.
*/
export class ForgeSessionService extends Service {
static inject = ['forge', 'sessionProjections']
static inject = ['forge', 'sessions', 'sessionProjections']
static Config: z<Config> = z.object({
discoveryTimeoutMs: z.number().default(30_000),
@@ -48,8 +48,9 @@ export class ForgeSessionService extends Service {
private readonly home: Context
private readonly discoveryTimeoutMs: number
private readonly watches = new WeakMap<Session, SessionWatch>()
private readonly watches = new Map<Session, SessionWatch>()
private readonly discoveries = new Set<Promise<void>>()
private readonly sessionLifetimes = new WeakMap<Session, AbortController>()
private readonly lifetime = new AbortController()
private projection!: ProjectionHandle<ForgeChangeRequestsState>
@@ -122,7 +123,7 @@ export class ForgeSessionService extends Service {
* 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
* complete list after each later binding or provider 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.
@@ -161,6 +162,17 @@ export class ForgeSessionService extends Service {
appendPluginRecord(session, BINDING_RECORD, { action: 'reference', url })
}
})
this.home.on('session/disposed', (session: Session) => {
this.sessionLifetimes.get(session)?.abort(new Error('forge-session: Session was disposed'))
this.sessionLifetimes.delete(session)
})
this.home.on('forge/providers-changed', () => {
for (const [session, watch] of [...this.watches]) {
if (this.watches.get(session) !== watch) continue
this.sync(watch)
this.notify(watch)
}
})
}
private state(session: Session): ForgeChangeRequestsState {
@@ -222,8 +234,14 @@ export class ForgeSessionService extends Service {
private discover(session: Session, remote: string, branch: string): Promise<void> {
const repository = this.home.forge.repositoryFromRemote(remote)
if (repository === undefined) return Promise.resolve()
const task = this.lookup(session, repository, branch).catch((error: unknown) => {
if (this.lifetime.signal.aborted) return
let lifetime = this.sessionLifetimes.get(session)
if (lifetime === undefined) {
lifetime = new AbortController()
this.sessionLifetimes.set(session, lifetime)
}
const signal = AbortSignal.any([this.lifetime.signal, lifetime.signal])
const task = this.lookup(session, repository, branch, signal).catch((error: unknown) => {
if (signal.aborted) return
this.home.logger.warn(`forge-session: change request lookup for ${branch} failed: ${error instanceof Error ? error.message : String(error)}`)
})
this.discoveries.add(task)
@@ -231,11 +249,11 @@ export class ForgeSessionService extends Service {
return task
}
private async lookup(session: Session, repository: ForgeRepository, branch: string): Promise<void> {
using timeout = deadline(this.lifetime.signal, this.discoveryTimeoutMs, 'FORGE_DISCOVERY_TIMEOUT')
private async lookup(session: Session, repository: ForgeRepository, branch: string, signal: AbortSignal): Promise<void> {
using timeout = deadline(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) {
if (signal.aborted || this.home.sessions.get(session.id) !== session) return
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 })
@@ -82,7 +82,7 @@ export interface SessionChangeRequest {
}
/**
* Receives the complete visible list after a binding change or a polling result.
* Receives the complete visible list after a binding or provider 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
@@ -7,7 +7,7 @@ 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 { bench, demo, FakeProvider, pull, pullUrl, pushOutput, shellTurn, snapshot } from './fixtures.ts'
import type { Bench } from './fixtures.ts'
const contexts: Context[] = []
@@ -66,6 +66,49 @@ describe('forgeSession bindings', () => {
})
describe('forgeSession discovery', () => {
it('cancels a closed Session lookup and never writes its result into a same-id replacement', async () => {
const { ctx, provider } = await open()
const owner = ctx.plugin({ inject: ['sessions'], apply() {} })
await owner
const session = owner.ctx.sessions.create()
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
const result = Promise.withResolvers<readonly ReturnType<typeof pull>[]>()
provider.findImpl = () => result.promise
try {
push(session, 'push', 'feat')
expect(provider.lookups).toHaveLength(1)
await owner.dispose()
expect(provider.lookups[0]?.signal.aborted).toBe(true)
const replacement = ctx.sessions.create(session.id)
result.resolve([pull(7)])
await settle()
expect(records(session)).toEqual([])
expect(records(replacement)).toEqual([])
expect(warn).not.toHaveBeenCalled()
} finally {
result.resolve([])
}
})
it('stops publishing lookup results when the Session closes after the first binding', async () => {
const { ctx, provider } = await open()
const session = ctx.sessions.prepare()
const detach = ctx.sessions.enter(session)
ctx.sessions.announce(session)
ctx.on('session/event', (changed, event) => {
if (changed === session && pluginRecordOf(event)?.type === 'plugin:forge-session/binding') detach()
})
provider.heads.set('feat', [pull(7), pull(8)])
try {
push(session, 'push', 'feat')
await settle()
expect(records(session)).toEqual([{ action: 'discover', url: pullUrl(7) }])
expect(ctx.sessions.get(session.id)).toBeUndefined()
} finally {
detach()
}
})
it('records change requests found for a pushed branch', async () => {
const { ctx, provider, session } = await open()
provider.heads.set('feat', [pull(7)])
@@ -203,6 +246,44 @@ describe('forgeSession track and dismiss', () => {
})
describe('forgeSession watch', () => {
it('resynchronizes existing watches when providers arrive, leave, and return', async () => {
const ctx = new Context()
contexts.push(ctx)
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(ForgeService)
await ctx.plugin(ForgeSessionService)
const session = ctx.sessions.create()
create(session, 'create', pullUrl(7))
const listener = vi.fn<SessionChangeRequestsListener>()
const stop = ctx.forgeSession.watch(session, listener)
expect(ctx.forgeSession.changeRequests(session)[0]?.ref).toBeNull()
const other = ctx.sessions.create()
create(other, 'other', pullUrl(8))
const stopOther = ctx.forgeSession.watch(other, () => {})
listener.mockImplementation((entries) => {
if (entries[0]?.ref !== null) stopOther()
})
const provider = new FakeProvider()
const register = () => ctx.plugin({ inject: ['forge'], apply(child: Context) { child.forge.register(provider) } })
const first = register()
await first
await vi.waitFor(() => { expect(lastList(listener)[0]?.update).toMatchObject({ snapshot: { ref: pull(7) } }) })
expect(provider.fetches).toEqual([[7]])
await first.dispose()
expect(lastList(listener)[0]).toMatchObject({ ref: null, update: null })
await register()
await vi.waitFor(() => { expect(provider.fetches).toEqual([[7], [7]]) })
expect(lastList(listener)[0]?.ref).toEqual(pull(7))
stop()
const calls = listener.mock.calls.length
ctx.emit('forge/providers-changed', 'fake')
expect(listener).toHaveBeenCalledTimes(calls)
expect(ctx.forge.latest(pull(7))).toBeUndefined()
})
function lastList(listener: Mock<SessionChangeRequestsListener>): readonly SessionChangeRequest[] {
return listener.mock.lastCall?.[0] ?? []
}
+2 -2
View File
@@ -15,8 +15,8 @@
en: d152484eb41ac6b4
zh: 2f8a621b991260a1
/deepseek-ai-dsh-experimental-forge/use-this-package:
en: a92423ce03ea1b83
zh: f90d8784b715996e
en: f716be24e1e3bada
zh: be76688ec6fab236
/deepseek-ai-dsh-experimental-forge/understand-the-implementation:
en: e193cb547c47069b
zh: faa517522402ea41
+4 -2
View File
@@ -32,13 +32,15 @@ Mount the registry with at least one provider package, such as the GitHub provid
- 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)`, `findByHead(repository, branch, signal)`, and `readiness(repository, signal)` delegate to the provider of the reference's forge and throw when none is registered; `readiness` reports whether the provider can reach the host with current credentials.
`ctx.forge.register(provider)` is an effect on the calling fiber and returns an async disposer that cancels and drains that registration's calls. Results arriving after cancellation are discarded. One provider serves one forge family; registering a second provider for the same family throws. Registration changes announce `forge/providers-changed`; registering a provider polls its existing watches immediately. `repositoryFromRemote(url)` and `changeRequestFromUrl(url)` ask providers in registration order and return the first match. `changeRequestUrl(ref)`, `findByHead(repository, branch, signal)`, and `readiness(repository, signal)` delegate to the provider of the reference's forge and throw when none is registered; `readiness` reports whether the provider can reach the host with current credentials.
`markReady(ref, signal)` marks a draft ready for review, and `merge(ref, { method, headSha }, signal)` merges a change request only while its head is still `headSha`. Both delegate to the provider of the reference's forge, throw when none is registered, and resolve to undefined once the host accepted the change or to a `ForgeActionFailure`: `unauthenticated`, `stale` when the head moved past `headSha`, or `failed` with the host's reason. After either settles, a watched change request is polled at once. Snapshots carry `mergeReady`, whether the host would merge the change request now for the signed-in user, and `mergeMethods`, the methods the repository accepts with that user's usual one first.
`diff(ref, signal)` reads the change request's unified patch as the host computes it, with `a/` and `b/` path prefixes, and `fileText(ref, commit, path, signal)` reads one file of its repository at a commit. Both delegate to the provider of the reference's forge, throw when none is registered, and resolve to the text or to a `ForgeFailure`, which is `too-large` when the host or the provider refuses an answer over its size limit. `fileUrl(ref, commit, path)` returns the web page of that file at that commit.
`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. `refresh(ref)` polls a watched change request at once, including after a terminal state; if a fetch is already in flight for its forge, another fetch starts immediately after it settles. 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.
`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. `refresh(ref)` polls a watched change request at once, including after a terminal state; if a fetch is already in flight for its forge, another fetch starts immediately after it settles. A failed read repeats after `activeIntervalMs` doubled for each consecutive failure, up to `maxBackoffMs`, which must be at least `activeIntervalMs`. 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.
Reaching `fetchTimeoutMs` aborts the fetch signal with `FORGE_FETCH_TIMEOUT`. Providers must stop their work and settle when the signal aborts; rejected fetches become `unavailable` failures. A provider that ignores cancellation can stall polling and service disposal.
-----
+4 -2
View File
@@ -32,13 +32,15 @@ kind: "package-reference"
- 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)` 与 `readiness(repository, signal)` 委托给引用所属 forge 的提供方,没有注册提供方时抛错;`readiness` 报告提供方能否用当前凭据访问该主机。
`ctx.forge.register(provider)` 是调用方 fiber 上的 effect,返回异步 disposer,取消并等待该次注册的调用结束。取消后到达的结果会被丢弃。一个提供方服务一个 forge 家族;为同一家族注册第二个提供方会抛错。注册变化会发出 `forge/providers-changed`;注册提供方会立即轮询其已有监听。`repositoryFromRemote(url)` 与 `changeRequestFromUrl(url)` 按注册顺序询问提供方并返回第一个匹配结果。`changeRequestUrl(ref)`、`findByHead(repository, branch, signal)` 与 `readiness(repository, signal)` 委托给引用所属 forge 的提供方,没有注册提供方时抛错;`readiness` 报告提供方能否用当前凭据访问该主机。
`markReady(ref, signal)` 把草稿标记为可评审;`merge(ref, { method, headSha }, signal)` 只在 head 仍是 `headSha` 时合并变更请求。两者都委托给引用所属 forge 的提供方,没有注册提供方时抛错;主机接受时 resolve 为 undefined,否则为 `ForgeActionFailure`:`unauthenticated`、head 已越过 `headSha` 时的 `stale`,或带主机原因的 `failed`。两者结束后,被监听的变更请求会立即重新轮询。快照带有 `mergeReady`(主机现在是否会为已登录用户合并该变更请求)与 `mergeMethods`(仓库接受的合并方式,该用户常用的方式排在最前)。
`diff(ref, signal)` 读取托管平台计算出的变更请求统一补丁,路径带 `a/` 与 `b/` 前缀;`fileText(ref, commit, path, signal)` 读取其仓库中某个提交下的一个文件。两者都委托给引用所属 forge 的提供方,没有注册提供方时抛错,resolve 为文本或 `ForgeFailure`;托管平台或提供方因结果超过其大小上限而拒绝时,失败为 `too-large`。`fileUrl(ref, commit, path)` 返回该文件在该提交下的网页地址。
`ctx.forge.watch(ref, listener)` 同样是 effect。变更请求的第一个监听方会立即触发轮询;`latest(ref)` 返回最近一次结果。每个 forge 同时最多只有一次读取在进行,这次读取包含该 forge 所有到期的变更请求。打开状态的变更请求在检查未完成或可合并性未知时每 `activeIntervalMs` 轮询一次,否则每 `idleIntervalMs` 轮询一次。已关闭和已合并的变更请求不再轮询。`refresh(ref)` 立即轮询一个被监听的变更请求,已结束的也包括在内;该 forge 已有读取在进行时,等待其结束后立即再读一次。读取失败后,重试间隔为 `activeIntervalMs` 按连续失败次数翻倍,最长 `maxBackoffMs`。读取超过 `fetchTimeoutMs` 时,以消息中包含 `FORGE_FETCH_TIMEOUT` 的 `unavailable` 失败结束。注销提供方会让其被监听的变更请求以 `unavailable` 结束。最后一个监听方离开后轮询停止。默认值见[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-experimental-forge)。
`ctx.forge.watch(ref, listener)` 同样是 effect。变更请求的第一个监听方会立即触发轮询;`latest(ref)` 返回最近一次结果。每个 forge 同时最多只有一次读取在进行,这次读取包含该 forge 所有到期的变更请求。打开状态的变更请求在检查未完成或可合并性未知时每 `activeIntervalMs` 轮询一次,否则每 `idleIntervalMs` 轮询一次。已关闭和已合并的变更请求不再轮询。`refresh(ref)` 立即轮询一个被监听的变更请求,已结束的也包括在内;该 forge 已有读取在进行时,等待其结束后立即再读一次。读取失败后,重试间隔为 `activeIntervalMs` 按连续失败次数翻倍,最长 `maxBackoffMs`;该值必须大于或等于 `activeIntervalMs`。注销提供方会让其被监听的变更请求以 `unavailable` 结束。最后一个监听方离开后轮询停止。默认值见[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-experimental-forge)。
达到 `fetchTimeoutMs` 时,读取信号会以 `FORGE_FETCH_TIMEOUT` 中止。提供方必须在信号中止时停止工作并结束请求;被拒绝的读取会转为 `unavailable` 失败。忽略取消的提供方可能阻塞轮询和服务销毁。
-----
+95 -22
View File
@@ -17,10 +17,25 @@ declare module '@deepseek-ai/cordis' {
interface Context {
forge: ForgeService
}
interface Events {
/**
* Announces a provider registration or withdrawal after the registry changes.
* Listener failures are logged without preventing other listeners from running.
* @param forge - family whose provider changed.
* @mode parallel
*/
'forge/providers-changed'(forge: string): void | Promise<void>
}
}
type FetchResults = ReadonlyMap<ChangeRequestKey, ChangeRequestSnapshot | ForgeFailure>
interface Registration {
readonly provider: ForgeProvider
readonly lifetime: AbortController
readonly pending: Set<Promise<void>>
}
interface Watch {
readonly key: ChangeRequestKey
readonly ref: ChangeRequestRef
@@ -45,7 +60,7 @@ export class ForgeService extends Service {
})
private readonly config: Required<Config>
private readonly providers = new Map<string, ForgeProvider>()
private readonly providers = new Map<string, Registration>()
private readonly watches = new Map<ChangeRequestKey, Watch>()
private readonly fetching = new Set<string>()
private readonly pending = new Set<Promise<void>>()
@@ -71,6 +86,9 @@ export class ForgeService extends Service {
throw new Error(`forge: ${key} must be a positive integer no greater than ${MAX_TIMER_DELAY_MS}`)
}
}
if (this.config.maxBackoffMs < this.config.activeIntervalMs) {
throw new Error('forge: maxBackoffMs must be greater than or equal to activeIntervalMs')
}
ctx.effect(() => async () => {
this.lifetime.abort(new Error('forge service disposed'))
clearTimeout(this.timer)
@@ -82,24 +100,34 @@ export class ForgeService extends Service {
/**
* 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.
* registration ends. Unregistering cancels and awaits its in-flight calls;
* results from the withdrawn registration are discarded.
* @param provider - provider to serve `provider.forge`.
* @returns the disposer that unregisters the provider.
* @returns the disposer that unregisters the provider and waits for its calls to finish.
* @throws when another provider already serves the same forge.
*/
register(provider: ForgeProvider): () => void {
register(provider: ForgeProvider): () => Promise<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 () => {
const registration: Registration = { provider, lifetime: new AbortController(), pending: new Set() }
this.providers.set(provider.forge, registration)
for (const watch of this.watches.values()) {
if (watch.ref.repository.forge === provider.forge) watch.dueAt = Date.now()
}
this.providersChanged(provider.forge)
this.schedule()
return async () => {
this.providers.delete(provider.forge)
for (const watch of this.watches.values()) {
registration.lifetime.abort(new Error(`forge: provider "${provider.forge}" was unregistered`))
for (const watch of [...this.watches.values()]) {
if (watch.ref.repository.forge === provider.forge) this.settle(watch, this.missing(provider.forge), undefined)
}
this.providersChanged(provider.forge)
this.schedule()
await Promise.allSettled(registration.pending)
}
}, `forge.register(${provider.forge})`)
return () => void dispose()
return async () => { await dispose() }
}
/**
@@ -108,7 +136,7 @@ export class ForgeService extends Service {
* @returns the repository, or undefined when no provider serves the URL.
*/
repositoryFromRemote(url: string): ForgeRepository | undefined {
for (const provider of this.providers.values()) {
for (const { provider } of this.providers.values()) {
const repository = provider.repositoryFromRemote(url)
if (repository !== undefined) return repository
}
@@ -121,7 +149,7 @@ export class ForgeService extends Service {
* @returns the change request, or undefined when no provider serves the URL.
*/
changeRequestFromUrl(url: string): ChangeRequestRef | undefined {
for (const provider of this.providers.values()) {
for (const { provider } of this.providers.values()) {
const ref = provider.changeRequestFromUrl(url)
if (ref !== undefined) return ref
}
@@ -147,7 +175,8 @@ export class ForgeService extends Service {
* @throws when no provider serves the repository's forge or the lookup fails.
*/
async findByHead(repository: ForgeRepository, branch: string, signal: AbortSignal): Promise<readonly ChangeRequestRef[]> {
return await this.provider(repository.forge).findByHead(repository, branch, signal)
return await this.invoke(this.registration(repository.forge), signal,
(provider, operation) => provider.findByHead(repository, branch, operation))
}
/**
@@ -158,7 +187,8 @@ export class ForgeService extends Service {
* @throws when no provider serves the repository's forge.
*/
async readiness(repository: ForgeRepository, signal: AbortSignal): Promise<ForgeReadiness> {
return await this.provider(repository.forge).readiness(repository.host, signal)
return await this.invoke(this.registration(repository.forge), signal,
(provider, operation) => provider.readiness(repository.host, operation))
}
/**
@@ -169,7 +199,8 @@ export class ForgeService extends Service {
* @throws when no provider serves the change request's forge, or with `signal.reason` when it aborts.
*/
async markReady(ref: ChangeRequestRef, signal: AbortSignal): Promise<ForgeActionFailure | undefined> {
const failure = await this.provider(ref.repository.forge).markReady(ref, signal)
const failure = await this.invoke(this.registration(ref.repository.forge), signal,
(provider, operation) => provider.markReady(ref, operation))
this.refresh(ref)
return failure
}
@@ -184,7 +215,8 @@ export class ForgeService extends Service {
* @throws when no provider serves the change request's forge, or with `signal.reason` when it aborts.
*/
async merge(ref: ChangeRequestRef, request: MergeRequest, signal: AbortSignal): Promise<ForgeActionFailure | undefined> {
const failure = await this.provider(ref.repository.forge).merge(ref, request, signal)
const failure = await this.invoke(this.registration(ref.repository.forge), signal,
(provider, operation) => provider.merge(ref, request, operation))
this.refresh(ref)
return failure
}
@@ -197,7 +229,8 @@ export class ForgeService extends Service {
* @throws when no provider serves the change request's forge, or with `signal.reason` when it aborts.
*/
async diff(ref: ChangeRequestRef, signal: AbortSignal): Promise<string | ForgeFailure> {
return await this.provider(ref.repository.forge).diff(ref, signal)
return await this.invoke(this.registration(ref.repository.forge), signal,
(provider, operation) => provider.diff(ref, operation))
}
/**
@@ -210,7 +243,8 @@ export class ForgeService extends Service {
* @throws when no provider serves the change request's forge, or with `signal.reason` when it aborts.
*/
async fileText(ref: ChangeRequestRef, commit: string, path: string, signal: AbortSignal): Promise<string | ForgeFailure> {
return await this.provider(ref.repository.forge).fileText(ref, commit, path, signal)
return await this.invoke(this.registration(ref.repository.forge), signal,
(provider, operation) => provider.fileText(ref, commit, path, operation))
}
/**
@@ -279,9 +313,43 @@ export class ForgeService extends Service {
}
private provider(forge: string): ForgeProvider {
const provider = this.providers.get(forge)
if (provider === undefined) throw new Error(`forge: ${this.missing(forge).message}`)
return provider
return this.registration(forge).provider
}
private providersChanged(forge: string): void {
if (this.lifetime.signal.aborted) return
const task = this.ctx.parallel('forge/providers-changed', forge).catch((error: unknown) => {
this.ctx.logger.warn(`forge: provider change listener failed: ${String(error)}`)
})
this.pending.add(task)
void task.then(() => this.pending.delete(task))
}
private registration(forge: string): Registration {
const registration = this.providers.get(forge)
if (registration === undefined) throw new Error(`forge: ${this.missing(forge).message}`)
return registration
}
private async invoke<T>(
registration: Registration,
signal: AbortSignal,
action: (provider: ForgeProvider, signal: AbortSignal) => Promise<T>,
): Promise<T> {
const operation = AbortSignal.any([signal, this.lifetime.signal, registration.lifetime.signal])
operation.throwIfAborted()
const completion = Promise.withResolvers<void>()
registration.pending.add(completion.promise)
this.pending.add(completion.promise)
try {
const result = await action(registration.provider, operation)
operation.throwIfAborted()
return result
} finally {
registration.pending.delete(completion.promise)
this.pending.delete(completion.promise)
completion.resolve()
}
}
private missing(forge: string): ForgeFailure {
@@ -325,13 +393,14 @@ export class ForgeService extends Service {
for (const watch of batch) watch.dueAt = undefined
let results: FetchResults | undefined
let failure: ForgeFailure | undefined
const registration = this.providers.get(forge)
try {
const provider = this.providers.get(forge)
if (provider === undefined) {
if (registration === 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)
results = await this.invoke(registration, timeout.signal,
(provider, operation) => provider.fetch(batch.map(watch => watch.ref), operation))
}
} catch (error) {
failure = { code: 'unavailable', message: error instanceof Error ? error.message : String(error) }
@@ -339,6 +408,10 @@ export class ForgeService extends Service {
this.fetching.delete(forge)
}
if (this.lifetime.signal.aborted) return
if (registration !== this.providers.get(forge)) {
this.schedule()
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}` }
@@ -0,0 +1,167 @@
import { afterEach, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import type { ChangeRequestUpdate, ForgeProvider } from '../src/types.ts'
import ForgeService, { changeRequestKey } from '../src/index.ts'
import { demo, FakeProvider, forgeContext, provide, ref, snapshot } from './fake-provider.ts'
let ctx: Context | undefined
afterEach(async () => {
await ctx?.fiber.dispose()
ctx = undefined
vi.useRealTimers()
})
it('cancels and drains a provider fetch before its fiber finishes unloading', async () => {
vi.useFakeTimers()
ctx = await forgeContext()
const provider = new FakeProvider()
const result = Promise.withResolvers<Awaited<ReturnType<ForgeProvider['fetch']>>>()
const aborted = Promise.withResolvers<undefined>()
provider.fetchImpl = (_refs, signal) => {
signal.addEventListener('abort', () => { aborted.resolve(undefined) }, { once: true })
return result.promise
}
const fiber = await provide(ctx, provider)
const updates: ChangeRequestUpdate[] = []
ctx.forge.watch(ref(1), (update) => { updates.push(update) })
let disposed = false
try {
await vi.advanceTimersByTimeAsync(0)
const disposal = fiber.dispose().then(() => { disposed = true })
await aborted.promise
expect(disposed).toBe(false)
expect(provider.signals[0]?.aborted).toBe(true)
expect(updates).toHaveLength(1)
expect(updates[0]).toMatchObject({ failure: { code: 'unavailable' } })
result.resolve(new Map([[changeRequestKey(ref(1)), snapshot(ref(1), { state: 'closed' })]]))
await disposal
await vi.advanceTimersByTimeAsync(0)
expect(updates).toHaveLength(1)
expect(ctx.forge.latest(ref(1))).toMatchObject({ failure: { code: 'unavailable' } })
} finally {
result.resolve(new Map())
}
})
it.each(['provider', 'service'] as const)('cancels and drains head lookups when the %s stops', async (owner) => {
ctx = await forgeContext()
const provider: ForgeProvider = new FakeProvider()
const result = Promise.withResolvers<Awaited<ReturnType<ForgeProvider['findByHead']>>>()
const aborted = Promise.withResolvers<undefined>()
vi.spyOn(provider, 'findByHead').mockImplementation((_repository, _branch, signal) => {
signal.addEventListener('abort', () => { aborted.resolve(undefined) }, { once: true })
return result.promise
})
const fiber = await provide(ctx, provider)
const lookup = ctx.forge.findByHead(demo, 'topic', new AbortController().signal)
const rejected = expect(lookup).rejects.toThrow(owner === 'provider' ? 'was unregistered' : /disposed|unregistered/)
let disposed = false
try {
const disposal = (owner === 'provider' ? fiber : ctx.fiber).dispose().then(() => { disposed = true })
await aborted.promise
expect(disposed).toBe(false)
result.resolve([ref(3)])
await rejected
await disposal
expect(disposed).toBe(true)
} finally {
result.resolve([])
await rejected
}
})
it('does not invoke a provider for an already cancelled lookup', async () => {
ctx = await forgeContext()
const provider = new FakeProvider()
const lookup = vi.spyOn(provider, 'findByHead')
await provide(ctx, provider)
await expect(ctx.forge.findByHead(demo, 'topic', AbortSignal.abort(new Error('caller stopped')))).rejects.toThrow('caller stopped')
expect(lookup).not.toHaveBeenCalled()
})
it('contains failed registry observers and still notifies other observers', async () => {
ctx = await forgeContext()
const warning = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
const notified = Promise.withResolvers<string>()
ctx.on('forge/providers-changed', () => { throw new Error('observer failed') })
ctx.on('forge/providers-changed', (forge) => { notified.resolve(forge) })
await provide(ctx, new FakeProvider())
expect(await notified.promise).toBe('fake')
await vi.waitFor(() => { expect(warning).toHaveBeenCalledWith(expect.stringContaining('provider change listener failed')) })
})
it('immediately polls existing watches only for the provider being registered', async () => {
vi.useFakeTimers()
ctx = await forgeContext()
const updates: ChangeRequestUpdate[] = []
ctx.forge.watch(ref(1), (update) => { updates.push(update) })
ctx.forge.watch(ref(2, { ...demo, forge: 'other' }), () => {})
await vi.advanceTimersByTimeAsync(0)
expect(updates.at(-1)).toMatchObject({ failure: { code: 'unavailable' } })
const provider = new FakeProvider()
provider.results.set(changeRequestKey(ref(1)), snapshot(ref(1)))
await provide(ctx, provider)
await vi.advanceTimersByTimeAsync(0)
expect(provider.fetches).toEqual([[ref(1)]])
expect(updates.at(-1)).toMatchObject({ snapshot: snapshot(ref(1)) })
})
it('does not announce provider withdrawal after its registry has stopped', async () => {
ctx = new Context()
const service = ctx.plugin(ForgeService)
await service
const observer = vi.fn()
ctx.on('forge/providers-changed', observer)
const unregister = ctx.forge.register(new FakeProvider())
await service.dispose()
await unregister()
expect(observer).toHaveBeenCalledTimes(1)
})
it.each(['readiness', 'markReady', 'merge', 'diff', 'fileText'] as const)(
'cancels and drains %s when its provider stops', async (method) => {
ctx = await forgeContext()
const provider: ForgeProvider = new FakeProvider()
const entered = Promise.withResolvers<undefined>()
const aborted = Promise.withResolvers<undefined>()
const release = Promise.withResolvers<undefined>()
const hold = async (signal: AbortSignal): Promise<void> => {
signal.addEventListener('abort', () => { aborted.resolve(undefined) }, { once: true })
entered.resolve(undefined)
await release.promise
}
vi.spyOn(provider, 'readiness').mockImplementation(async (_host, signal) => {
await hold(signal); return { state: 'ready', message: null }
})
vi.spyOn(provider, 'markReady').mockImplementation(async (_ref, signal) => { await hold(signal); return undefined })
vi.spyOn(provider, 'merge').mockImplementation(async (_ref, _request, signal) => { await hold(signal); return undefined })
vi.spyOn(provider, 'diff').mockImplementation(async (_ref, signal) => { await hold(signal); return 'diff' })
vi.spyOn(provider, 'fileText').mockImplementation(async (_ref, _commit, _path, signal) => { await hold(signal); return 'file' })
const fiber = await provide(ctx, provider)
const service = ctx.forge
const signal = new AbortController().signal
const calls = {
readiness: () => service.readiness(demo, signal),
markReady: () => service.markReady(ref(1), signal),
merge: () => service.merge(ref(1), { method: 'squash', headSha: 'a'.repeat(40) }, signal),
diff: () => service.diff(ref(1), signal),
fileText: () => service.fileText(ref(1), 'a'.repeat(40), 'file.txt', signal),
}
const rejected = expect(calls[method]()).rejects.toThrow('was unregistered')
let disposed = false
try {
await entered.promise
const disposal = fiber.dispose().then(() => { disposed = true })
await aborted.promise
expect(disposed).toBe(false)
release.resolve(undefined)
await rejected
await disposal
} finally {
release.resolve(undefined)
await rejected
}
},
)
@@ -11,6 +11,18 @@ afterEach(async () => {
})
describe('forge registry', () => {
it('rejects a backoff cap shorter than the active poll interval', async () => {
ctx = new Context()
await expect(ctx.plugin(ForgeService, { activeIntervalMs: 30_000, maxBackoffMs: 1_000 }))
.rejects.toThrow('maxBackoffMs must be greater than or equal to activeIntervalMs')
})
it.each([1_000, 2_000])('accepts backoff cap %s at or above the active interval', async (maxBackoffMs) => {
ctx = new Context()
await ctx.plugin(ForgeService, { activeIntervalMs: 1_000, maxBackoffMs })
expect(ctx.forge).toBeDefined()
})
it('resolves remotes and change request URLs through the first provider that serves them', async () => {
ctx = await forgeContext()
await provide(ctx, new FakeProvider('fake'))
@@ -101,7 +113,7 @@ describe('forge registry', () => {
const unregister = ctx.forge.register(new FakeProvider())
expect(ctx.forge.repositoryFromRemote('fake://fake.test/Octo/Demo')).toBeDefined()
unregister()
await unregister()
expect(ctx.forge.repositoryFromRemote('fake://fake.test/Octo/Demo')).toBeUndefined()
})
@@ -1224,10 +1224,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
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.',
signature: 'register(provider: ForgeProvider): () => Promise<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. Unregistering cancels and awaits its in-flight calls; results from the withdrawn registration are discarded.',
parameters: [{ name: 'provider', description: 'provider to serve `provider.forge`.' }],
returns: 'the disposer that unregisters the provider.',
returns: 'the disposer that unregisters the provider and waits for its calls to finish.',
throws: ['when another provider already serves the same forge.'],
},
{
@@ -1459,7 +1459,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
{
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.',
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 or provider 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.',
},
@@ -4809,6 +4809,14 @@ export const EVENT_API: readonly EventApiEntry[] = [
description: 'Observe a durable cold feedback mutation without publishing a live Session. Observers run before write ownership is released and must not await another message-feedback operation for this Session. The payload is borrowed read-only; deep-clone it before transferring ownership (for example, to Session.fromRestore).',
parameters: [{ name: 'inspection', description: 'committed canonical prefix, including the feedback as its last event.' }],
},
{
name: 'forge/providers-changed',
mode: 'parallel',
signature: '\'forge/providers-changed\'(forge: string): void | Promise<void>',
summary: 'Announces a provider registration or withdrawal after the registry changes.',
description: 'Announces a provider registration or withdrawal after the registry changes. Listener failures are logged without preventing other listeners from running.',
parameters: [{ name: 'forge', description: 'family whose provider changed.' }],
},
{
name: 'fs/edit-intent',
mode: 'waterfall',
+1
View File
@@ -244,6 +244,7 @@ export const EVENT_SCOPE_PAGE: Record<string, string> = {
'credentials': 'credentials.md',
'deepseek-account': 'credentials.md',
'domain': 'storage.md',
'forge': 'forge.md',
'fs': 'filesystem.md',
'goal': 'goal.md',
'schedule': 'schedule.md',
+36 -1
View File
@@ -2534,7 +2534,37 @@
},
{
"doc": "docs/subsystems/forge.md",
"symbol": "ChangeRequestSnapshot",
"symbol": "ChangeRequestKey",
"source": "packages/experimental/forge/src/types.ts"
},
{
"doc": "docs/subsystems/forge.md",
"symbol": "ChangeRequestState",
"source": "packages/experimental/forge/src/types.ts"
},
{
"doc": "docs/subsystems/forge.md",
"symbol": "CheckState",
"source": "packages/experimental/forge/src/types.ts"
},
{
"doc": "docs/subsystems/forge.md",
"symbol": "ChangeRequestCheck",
"source": "packages/experimental/forge/src/types.ts"
},
{
"doc": "docs/subsystems/forge.md",
"symbol": "ChecksRollup",
"source": "packages/experimental/forge/src/types.ts"
},
{
"doc": "docs/subsystems/forge.md",
"symbol": "Mergeability",
"source": "packages/experimental/forge/src/types.ts"
},
{
"doc": "docs/subsystems/forge.md",
"symbol": "ReviewDecision",
"source": "packages/experimental/forge/src/types.ts"
},
{
@@ -2542,6 +2572,11 @@
"symbol": "ForgeFailure",
"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",